diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b3cb013..5093a78f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added +* [PostIt/API] Begin Android/Linux call integration on the existing authenticated ChatHub: typed invitation, single-device consent, bounded SDP/ICE exchange and hangup; derive relay policy from account preferences and issue temporary coturn credentials. Attach the shared PostIt signaling client to the chat connection, end calls on disconnect/room departure/privacy activation, and add targeted regression tests. Calls remain disabled by default; native media adapters and Android/Linux end-to-end validation are not implemented yet. +* [PostIt] Clarify reuse of the existing ChatHub for call signaling and document the inconclusive Android native WebRTC experiment; discard the isolated laboratory without changing production chat or media behavior. * [PostIt/API] Persist the account-level `HideIpAddressInCalls` preference (default false), expose it through the authenticated profile API and PostIt profile editor, and preserve it when older clients omit it. Prepare future TURN-only calls without enabling media transport yet. * [PostIt] Show a local live capture preview on Android and Desktop without opening a second camera or reading back uploaded segments. Share Android's oriented EGL frames with an aspect-preserving preview surface; duplicate Desktop LibVLC output without local microphone playback. * [Podcasts] Prepare authenticated, seekable MP4 replays for new and existing Completed broadcasts using server-side FFmpeg stream copy and faststart. Retain MPEG-TS, account for both copies in the owner quota, recover preparation after restarts, and expose owner-only retries plus GET/HEAD byte-range playback. diff --git a/doc/architecture/audio-video-calls.md b/doc/architecture/audio-video-calls.md index c172e22c1..b6b157cbf 100644 --- a/doc/architecture/audio-video-calls.md +++ b/doc/architecture/audio-video-calls.md @@ -2,12 +2,14 @@ ## Statut et périmètre -Ce document décrit un **plan d'implémentation** : les appels ne sont pas encore -disponibles. La préférence de confidentialité est persistée et éditable dans -le profil, mais ne pilote aucun transport média à ce stade. +Ce document décrit l'architecture cible et l'implémentation commencée : +les appels ne sont pas encore disponibles. La préférence de confidentialité +est persistée et éditable dans le profil. Les premiers contrats de +signalisation sont intégrés au chat, désactivés par défaut ; aucun transport +média natif n'est encore raccordé. Il complète la [topologie de PostIt](postit.md) et les [mécanismes de notification existants](notifications.md). - +œ La cible couvre : - les appels audio-vidéo bidirectionnels entre deux utilisateurs ; @@ -29,8 +31,11 @@ La cible couvre : | Appels de groupe | SFU compatible WebRTC | Réception et redistribution sélective des flux | SignalR transporte les messages de contrôle, **jamais les flux audio-vidéo**. -La signalisation peut utiliser l'infrastructure SignalR existante, mais les -contrats d'appel et leurs autorisations doivent être distincts du chat. +La signalisation doit réutiliser le [ChatHub existant](../../src/Yavsc.Server/Hubs/ChatHub.cs), +déjà exposé par [Yavsc.Api](../../src/Yavsc.Api/Program.cs), et ses salles de +discussion. Il n'est pas nécessaire de créer un second service de salles. +Les contrats d'appel et leurs autorisations restent à définir explicitement : +l'appartenance à une salle ne vaut pas acceptation d'un appel. Le choix des bibliothèques natives, du SFU et des codecs effectivement disponibles reste à valider par un prototype multi-plateforme. @@ -212,4 +217,140 @@ la signalisation des premiers appels à deux. - Activation du masquage pendant un appel : suspension des médias, remplacement vérifié du transport et retour d'état fidèle dans l'interface. - Échecs de permissions, de signalisation, de TURN et du SFU visibles, - ressources caméra/micro et connexions libérées à la fin de l'appel. \ No newline at end of file + ressources caméra/micro et connexions libérées à la fin de l'appel. + +## Première tranche intégrée : signalisation Android et Desktop Linux + +Le périmètre initial retenu est **Android + Desktop Linux**. Il n'ajoute +aucun serveur ni aucune salle : les méthodes suivantes appartiennent au +[ChatHub](../../src/Yavsc.Server/Hubs/ChatHub.Calls.cs) existant. +Les [contrats partagés](../../src/Yavsc.Abstract/Chat/Calls/CallContracts.cs) +sont utilisés par le serveur et PostIt. + +| Méthode | Effet | +|---|---| +| `InviteCall(roomName, callee)` | Invite un autre compte présent dans la salle et retourne un identifiant d'appel | +| `AcceptCall(callId)` | Accepte sur un seul appareil, notifie les deux pairs et ferme l'invitation sur les autres appareils | +| `SendCallSignal(callId, signal)` | Transmet l'offre, la réponse ou un candidat ICE uniquement à l'autre appareil accepté | +| `EndCall(callId)` | Annule, refuse ou termine l'appel depuis un appareil autorisé | + +Événements : `CallInvitation`, `CallAccepted`, `CallSignal(callId, signal)` +et `CallEnded`. **Accepté ne veut pas dire connecté** : l'événement accepté +exprime le consentement et fournit la politique ICE et les paramètres +STUN/TURN, pas une réussite de l'échange média. + +Le serveur vérifie les comptes, la présence dans la salle et le blocage +entre utilisateurs. Les appareils d'un compte reçoivent l'invitation ; +seul le premier qui accepte peut ensuite émettre la réponse et les +candidats. L'appelant reste lié à sa connexion d'origine. Les médias +ne sont jamais diffusés au groupe SignalR de la salle. + +La préférence persistée est lue à l'invitation et relue à l'acceptation. +Pour cette première version à deux, si l'un des comptes demande le +masquage, **les deux transports doivent être relayés**, règle conservatrice +plus stricte que la politique individuelle cible. L'acceptation ne peut +pas diminuer la protection annoncée à l'invitation. Sans TURN configuré, +un appel imposant le relais échoue explicitement. + +La signalisation refuse les candidats non relayés, les adresses associées +non masquées et les adresses directes dans les champs SDP contrôlés lorsque +le relais est imposé. Ces contrôles de forme ne constituent pas un audit +exhaustif de toutes les extensions SDP possibles : la non-divulgation doit +encore être prouvée avec les SDP et statistiques des moteurs réellement +intégrés. Aucun SDP n'est réécrit. + +L'activation du masquage ou le changement de pseudo par l'API de profil +termine l'appel existant ; le client devra libérer les médias et proposer +un nouvel appel. La renégociation transparente du transport n'est pas +implémentée. La déconnexion d'un pair sélectionné, le départ ou l'exclusion +de la salle terminent également l'appel. Pas de reprise automatique après +reconnexion : elle crée une nouvelle identité de connexion. + +Les appels sont temporaires, en mémoire et **sur une seule instance API**. +Une invitation expire après 45 secondes, une session acceptée après une +heure ; les identifiants TURN expirent cinq minutes plus tard. Cette borne +initiale ne remplace pas un futur renouvellement d'identifiants pour les +appels longs. Un compte ne peut avoir qu'un appel réservé ou accepté à la +fois. Le registre est limité à 128 appels et 256 candidats par appel. +La tranche ne permet qu'une offre initiale de l'appelant et une réponse ; +les redémarrages ICE et renégociations restent à implémenter. + +Configuration serveur, section `AudioVideoCalls` : + +- `Enabled` : `false` par défaut, à conserver tant que les moteurs ne sont + pas validés ; +- `StunUrls` : tableau d'URI `stun:` ou `stuns:` ; +- `TurnUrls` : tableau d'URI `turn:` ou `turns:`, avec transport optionnel ; +- `TurnSharedSecret` : secret REST coturn, **uniquement côté serveur**, + à fournir par le gestionnaire de secrets ou la variable d'environnement + `AudioVideoCalls__TurnSharedSecret`, jamais dans le dépôt. + +Les credentials TURN temporaires sont calculés côté serveur avec le +format REST coturn (expiration, nonce aléatoire et HMAC-SHA1). Aucun secret +de longue durée n'est envoyé aux clients. Aucun service TURN n'a été +déployé ou configuré par cette tranche. + +Le [transport PostIt](../../src/PostIt/PostIt/Services/CallSignalingClient.cs) +utilise la connexion authentifiée du `ChatViewModel`, sans seconde connexion +ni interrogation HTTP. Il expose les commandes et événements typés et +signale les interruptions ; il ne capture aucun média. L'interface ne +propose pas encore de bouton d'appel ; une éventuelle invitation est +signalée comme non utilisable faute de moteur natif, sans acceptation +automatique. + +### Travail natif restant avant activation + +- Android : intégrer un adaptateur libwebrtc dans l'application existante, + isoler le problème JNI observé, vérifier les versions Java/natives et + conserver les diagnostics fatals utiles, sans journaliser SDP ou secrets. +- Desktop Linux : intégrer un moteur WebRTC natif dans PostIt.Desktop + avec capture réelle et rendu Avalonia ; un processus Python de test + GStreamer ne vaut pas cette intégration. +- Côté partagé : relier consentement, états réels du moteur, mise en + sourdine, arrêt et libération des ressources à la signalisation. +- Valider un appel Android ↔ Desktop Linux avec caméra avant et microphone + réels, rendu et audio distants, VP8/Opus, puis transport TURN sélectionné + dans les statistiques, sans repli direct lorsque le masquage est demandé. + +Les tests de cette tranche portent sur les contrats, autorisations, courses +d'acceptation, expirations, limites, interruption et préférence persistée. +La compilation des applications Android et Desktop ne prouve pas le +fonctionnement de WebRTC natif : le crash antérieur n'est pas résolu. + +## Retour sur l'essai Android du 25 octobre 2026 + +Le laboratoire indépendant a été supprimé ; il ne constitue pas une base +de salles de discussion ni une implémentation de PostIt. La suite doit +réutiliser le ChatHub existant pour la signalisation. + +L'essai en échec opposait un téléphone Android 15 arm64 à Chromium, +en mode TURN uniquement, avec signalisation HTTP locale et TURN/TCP +accessibles par redirections USB adb. Il n'utilisait ni ChatHub ni +PostIt.Desktop Linux. + +Les observations doivent être distinguées : + +- Camera2, la capture audio et un premier rendu EGL local ont démarré. + Cela ne valide ni le transport ni le rendu distant. +- Le processus Android a subi un SIGABRT dans + `libjingle_peerconnection_so.so`. Une première pile passait par + `PeerConnection.nativeAddIceCandidate` ; après modification du traitement + des candidats, un arrêt natif persistait sur `network_thread`. +- Le test a attendu un échange média qui n'est jamais arrivé. Le dernier + rapport Browser indiquait `connection: new`, zéro octet audio/vidéo et + aucune paire ICE sélectionnée ; aucun rapport Android n'a validé l'appel. +- Le problème a été reproduit avec les SDK libwebrtc 150.7871.04 et + 144.7559.15. L'attente des descriptions distantes, la résolution du + `sdpMid` et l'ajout asynchrone des candidats n'ont pas suffi. + +Il s'agit donc d'un **crash natif pendant la tentative d'établissement**, +pas d'un refus de codec ou d'un échec TURN identifié. Le dernier appel JNI +visible ne prouve pas la cause du crash. Sans message fatal détaillé ni +pile native symbolisée, un défaut du code expérimental, de son intégration +Java/JNI ou de libwebrtc reste à départager. Rien ne démontre une +impossibilité de WebRTC sur Android ou une défaillance du ChatHub. + +Les essais Browser/GStreamer réussis utilisaient des sources synthétiques +et un décodage sans affichage côté GStreamer : ils ne valident pas +PostIt.Desktop Linux, ses périphériques réels ou son rendu Avalonia. +La validation multi-plateforme demeure incomplète. \ No newline at end of file diff --git a/src/PostIt/PostIt.Tests/CallSignalingClientTests.cs b/src/PostIt/PostIt.Tests/CallSignalingClientTests.cs new file mode 100644 index 000000000..60a1c784b --- /dev/null +++ b/src/PostIt/PostIt.Tests/CallSignalingClientTests.cs @@ -0,0 +1,54 @@ +using System.Text.Json; +using Microsoft.AspNetCore.SignalR.Client; +using PostIt.Services; +using Yavsc.Abstract.Chat.Calls; + +namespace PostIt.Tests; + +public sealed class CallSignalingClientTests +{ + [Fact] + public async Task All_operations_fail_explicitly_without_a_connected_chat_transport() + { + await using var connection = new HubConnectionBuilder().WithUrl("http://127.0.0.1:1/chathub").Build(); + using var client = new CallSignalingClient(connection); + var id = Guid.NewGuid(); + var ct = TestContext.Current.CancellationToken; + await Assert.ThrowsAsync(() => client.InviteAsync("General", "bob", ct)); + await Assert.ThrowsAsync(() => client.AcceptAsync(id, ct)); + await Assert.ThrowsAsync(() => client.EndAsync(id, ct)); + await Assert.ThrowsAsync(() => client.SendAsync(id, new("offer", "sdp"), ct)); + } + + [Fact] + public async Task Disposing_call_subscriptions_does_not_dispose_the_chat_connection() + { + await using var connection = new HubConnectionBuilder().WithUrl("http://127.0.0.1:1/chathub").Build(); + var client = new CallSignalingClient(connection); + client.Dispose(); + client.Dispose(); + await Assert.ThrowsAsync(() => client.AcceptAsync(Guid.NewGuid(), TestContext.Current.CancellationToken)); + Assert.Equal(HubConnectionState.Disconnected, connection.State); + using var replacement = new CallSignalingClient(connection); + await Assert.ThrowsAsync(() => replacement.InviteAsync("General", "bob", TestContext.Current.CancellationToken)); + } + + [Fact] + public void Shared_contracts_round_trip_with_signalr_web_json_naming() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + var call = new CallSession(Guid.NewGuid(), "General", "alice", "bob", true, + DateTimeOffset.Parse("2026-10-26T01:00:00Z")); + var ready = new CallAccepted(call, [new(["turns:turn.example.test:5349"], "expires:nonce", "credential")]); + var json = JsonSerializer.Serialize(ready, options); + var read = JsonSerializer.Deserialize(json, options); + Assert.NotNull(read); + Assert.Equal(call, read.Session); + Assert.Equal(ready.IceServers[0].Urls, read.IceServers[0].Urls); + Assert.Equal(ready.IceServers[0].Username, read.IceServers[0].Username); + Assert.Equal(ready.IceServers[0].Credential, read.IceServers[0].Credential); + var signal = new CallSignal("candidate", Candidate: "candidate:1 1 udp 100 192.0.2.1 5000 typ relay", + SdpMid: "video", SdpMLineIndex: 1); + Assert.Equal(signal, JsonSerializer.Deserialize(JsonSerializer.Serialize(signal, options), options)); + } +} diff --git a/src/PostIt/PostIt/Services/CallSignalingClient.cs b/src/PostIt/PostIt/Services/CallSignalingClient.cs new file mode 100644 index 000000000..f4c5ffd91 --- /dev/null +++ b/src/PostIt/PostIt/Services/CallSignalingClient.cs @@ -0,0 +1,81 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using Yavsc.Abstract.Chat.Calls; + +namespace PostIt.Services; + +public sealed class CallSignalingClient : IDisposable +{ + private readonly HubConnection _connection; + private readonly IDisposable[] _subscriptions; + private bool _disposed; + + public event Action? Invitation; + public event Action? Accepted; + public event Action? Ended; + public event Action? Signal; + public event Action? Interrupted; + + public CallSignalingClient(HubConnection connection) + { + _connection = connection; + _subscriptions = + [ + connection.On(CallHubMethods.Invitation, call => Invitation?.Invoke(call)), + connection.On(CallHubMethods.Accepted, call => Accepted?.Invoke(call)), + connection.On(CallHubMethods.Ended, call => Ended?.Invoke(call)), + connection.On(CallHubMethods.SignalReceived, + (id, signal) => Signal?.Invoke(id, signal)) + ]; + connection.Reconnecting += OnInterruptedAsync; + connection.Closed += OnInterruptedAsync; + } + + public Task InviteAsync(string room, string user, CancellationToken ct = default) + { + EnsureConnected(); + return _connection.InvokeAsync(CallHubMethods.Invite, room, user, ct); + } + + public Task AcceptAsync(Guid callId, CancellationToken ct = default) + { + EnsureConnected(); + return _connection.InvokeAsync(CallHubMethods.Accept, callId, ct); + } + + public Task EndAsync(Guid callId, CancellationToken ct = default) + { + EnsureConnected(); + return _connection.InvokeAsync(CallHubMethods.End, callId, ct); + } + + public Task SendAsync(Guid callId, CallSignal signal, CancellationToken ct = default) + { + EnsureConnected(); + return _connection.InvokeAsync(CallHubMethods.Signal, callId, signal, ct); + } + + private Task OnInterruptedAsync(Exception? error) + { + Interrupted?.Invoke(); + return Task.CompletedTask; + } + + private void EnsureConnected() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_connection.State != HubConnectionState.Connected) + throw new InvalidOperationException("La connexion de chat est interrompue ; l'appel ne peut pas continuer."); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _connection.Reconnecting -= OnInterruptedAsync; + _connection.Closed -= OnInterruptedAsync; + foreach (var subscription in _subscriptions) subscription.Dispose(); + } +} diff --git a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs index 7d5b516d6..bb13e3c0a 100644 --- a/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/Chat/ChatViewModel.cs @@ -37,6 +37,8 @@ namespace PostIt.ViewModels.Chat private HubConnection? _connection; + public CallSignalingClient? Calls { get; private set; } + [ObservableProperty] public partial string CurrentRoom { get; set; } = "General"; @@ -130,6 +132,8 @@ namespace PostIt.ViewModels.Chat private async Task DisconnectAsync() { + Calls?.Dispose(); + Calls = null; if (_connection != null) { await _connection.StopAsync(); @@ -150,6 +154,13 @@ namespace PostIt.ViewModels.Chat { try { + if (_connection != null) + { + Calls?.Dispose(); + Calls = null; + await _connection.DisposeAsync(); + _connection = null; + } Status = new StatusNotice("Connexion en cours...", StatusSeverity.Info, new RelayCheckCommand("Vérifier la connexion", async () => await CheckConnectionAsync())); @@ -188,6 +199,12 @@ namespace PostIt.ViewModels.Chat .WithAutomaticReconnect() .Build(); + Calls = new CallSignalingClient(_connection); + Calls.Invitation += call => Avalonia.Threading.Dispatcher.UIThread.Post(() => + Log($"Invitation audio-vidéo de {call.Caller} dans {call.RoomName} ; moteur natif non disponible.")); + Calls.Ended += call => Avalonia.Threading.Dispatcher.UIThread.Post(() => + Log($"Appel terminé : {call.Reason}.")); + // 1. Ecoute des notifications globales (notifyUser) _connection.On("notifyUser", (type, user, msg) => { diff --git a/src/Yavsc.Abstract/Chat/Calls/CallContracts.cs b/src/Yavsc.Abstract/Chat/Calls/CallContracts.cs new file mode 100644 index 000000000..259819907 --- /dev/null +++ b/src/Yavsc.Abstract/Chat/Calls/CallContracts.cs @@ -0,0 +1,29 @@ +#nullable enable + +namespace Yavsc.Abstract.Chat.Calls; + +public static class CallHubMethods +{ + public const string Invite = "InviteCall"; + public const string Accept = "AcceptCall"; + public const string End = "EndCall"; + public const string Signal = "SendCallSignal"; + public const string Invitation = "CallInvitation"; + public const string Accepted = "CallAccepted"; + public const string Ended = "CallEnded"; + public const string SignalReceived = "CallSignal"; +} + +public sealed record CallSession( + Guid Id, string RoomName, string Caller, string Callee, bool RelayOnly, + DateTimeOffset ExpiresAt); + +public sealed record CallIceServer(string[] Urls, string? Username = null, string? Credential = null); + +public sealed record CallAccepted(CallSession Session, CallIceServer[] IceServers); + +public sealed record CallEnded(Guid CallId, string Reason); + +public sealed record CallSignal( + string Kind, string? Sdp = null, string? Candidate = null, + string? SdpMid = null, int? SdpMLineIndex = null); diff --git a/src/Yavsc.Api/Controllers/accounting/AccountController.cs b/src/Yavsc.Api/Controllers/accounting/AccountController.cs index 9be2e498c..d91534096 100644 --- a/src/Yavsc.Api/Controllers/accounting/AccountController.cs +++ b/src/Yavsc.Api/Controllers/accounting/AccountController.cs @@ -39,11 +39,12 @@ namespace Yavsc.WebApi.Controllers private readonly UserManager _userManager; private readonly ITrueEmailSender _emailSender; private readonly ICalendarManager _calendarManager; + private readonly CallSignalingService _calls; public ApiAccountController( ILoggerFactory loggerFactory, ApplicationDbContext dbContext, IOptions siteSettings, UserManager userManager, - ITrueEmailSender emailSender, ICalendarManager calendarManager) + ITrueEmailSender emailSender, ICalendarManager calendarManager, CallSignalingService calls) { _logger = loggerFactory.CreateLogger(nameof(ApiAccountController)); _dbContext = dbContext; @@ -51,6 +52,7 @@ namespace Yavsc.WebApi.Controllers _userManager = userManager; _emailSender = emailSender; _calendarManager = calendarManager; + _calls = calls; } [HttpGet("me")] @@ -178,6 +180,8 @@ namespace Yavsc.WebApi.Controllers return NotFound(); } + var previousUserName = user.UserName; + var privacyActivated = request.HideIpAddressInCalls == true && !user.HideIpAddressInCalls; if (!string.IsNullOrWhiteSpace(request.UserName)) { var trimmedUserName = request.UserName.Trim(); @@ -224,6 +228,9 @@ namespace Yavsc.WebApi.Controllers } await _dbContext.SaveChangesAsync(uid); + if (previousUserName != null && + (privacyActivated || previousUserName != user.UserName)) + await _calls.EndForUserAsync(previousUserName, "profile-changed"); return Ok(new { status = "saved", fullName = user.FullName, address = user.PostalAddress?.Address, googleCalendarId = user.DedicatedGoogleCalendar, hideIpAddressInCalls = user.HideIpAddressInCalls }); } diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs index 132cb26c7..bb5bf98b1 100644 --- a/src/Yavsc.Api/Program.cs +++ b/src/Yavsc.Api/Program.cs @@ -87,6 +87,11 @@ internal class Program .AddJsonProtocol(options => options.PayloadSerializerOptions.IncludeFields = true); services.AddSingleton(); + services.Configure( + builder.Configuration.GetSection(AudioVideoCallOptions.SectionName)); + services.AddSingleton(TimeProvider.System); + services.AddSingleton(); + services.AddHostedService(provider => provider.GetRequiredService()); // DbContextBuilder services.AddDbContext(options => diff --git a/src/Yavsc.Server/Hubs/ChatHub.Calls.cs b/src/Yavsc.Server/Hubs/ChatHub.Calls.cs new file mode 100644 index 000000000..a3c2b31e8 --- /dev/null +++ b/src/Yavsc.Server/Hubs/ChatHub.Calls.cs @@ -0,0 +1,76 @@ +#nullable enable + +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Yavsc.Abstract.Chat.Calls; +using Yavsc.Server.Helpers; + +namespace Yavsc.Server.Hubs; + +public partial class ChatHub +{ + public async Task InviteCall(string roomName, string callee) + { + var caller = CallUser(); + if (string.IsNullOrWhiteSpace(roomName) || !InputValidator.ValidateRoomName(roomName) || + string.IsNullOrWhiteSpace(callee) || !InputValidator.ValidateUserName(callee)) + throw new HubException("Invalid call participant or room."); + RequireCallPresence(roomName, caller, callee); + var relayOnly = await RequiresRelayAsync(caller, callee); + return await _calls.InviteAsync(roomName, caller, callee, Context.ConnectionId, + _cxManager.GetConnexionIds(callee)?.Distinct().ToArray() ?? [], relayOnly); + } + + public async Task AcceptCall(Guid callId) + { + var user = CallUser(); + var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId); + RequireCallPresence(session.RoomName, session.Caller, session.Callee); + await _calls.AcceptAsync(callId, user, Context.ConnectionId, + await RequiresRelayAsync(session.Caller, session.Callee)); + } + + public Task EndCall(Guid callId) => _calls.EndAsync(callId, CallUser(), Context.ConnectionId); + + public async Task SendCallSignal(Guid callId, CallSignal signal) + { + var user = CallUser(); + var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId); + RequireCallPresence(session.RoomName, session.Caller, session.Callee); + if (!session.RelayOnly && await RequiresRelayAsync(session.Caller, session.Callee)) + { + await _calls.EndForUserAsync(user, "privacy-changed"); + throw new HubException("Call ended because the IP privacy policy changed. Start a new call."); + } + await _calls.SignalAsync(callId, user, Context.ConnectionId, signal); + } + + private string CallUser() + { + var user = Context.User?.GetUserName(); + if (string.IsNullOrWhiteSpace(user) || _cxManager.GetUserName(Context.ConnectionId) != user) + throw new HubException("An authenticated chat connection is required."); + return user; + } + + private void RequireCallPresence(string room, string caller, string callee) + { + if (!_cxManager.IsConnected(caller) || !_cxManager.IsConnected(callee) || + !_cxManager.IsPresent(room, caller) || !_cxManager.IsPresent(room, callee)) + throw new HubException("Both call participants must be present in the chat room."); + } + + private async Task RequiresRelayAsync(string caller, string callee) + { + var users = await _dbContext.Users.AsNoTracking() + .Where(u => u.UserName == caller || u.UserName == callee) + .Select(u => new { u.Id, u.HideIpAddressInCalls }).ToArrayAsync(Context.ConnectionAborted); + if (users.Length != 2) + throw new HubException("Call participants are unavailable."); + var ids = users.Select(u => u.Id).ToArray(); + if (await _dbContext.BlackListed.AnyAsync( + b => ids.Contains(b.OwnerId) && ids.Contains(b.UserId), Context.ConnectionAborted)) + throw new HubException("Calls between these participants are not allowed."); + return users.Any(u => u.HideIpAddressInCalls); + } +} diff --git a/src/Yavsc.Server/Hubs/ChatHub.cs b/src/Yavsc.Server/Hubs/ChatHub.cs index f2a1d08bc..2bb371753 100644 --- a/src/Yavsc.Server/Hubs/ChatHub.cs +++ b/src/Yavsc.Server/Hubs/ChatHub.cs @@ -45,15 +45,17 @@ namespace Yavsc.Server.Hubs private readonly IConnexionManager _cxManager; private readonly IStringLocalizer _localizer; private readonly ILogger _logger; + private readonly CallSignalingService _calls; public HubInputValidator InputValidator { get; } public ChatHub(ApplicationDbContext dbContext, ILoggerFactory loggerFactory, IStringLocalizerFactory stringLocalizerFactory, - IConnexionManager connexionManager) + IConnexionManager connexionManager, CallSignalingService calls) { _dbContext = dbContext; + _calls = calls; _localizer = stringLocalizerFactory.Create(typeof(ChatHub)); _cxManager = connexionManager; @@ -96,6 +98,7 @@ namespace Yavsc.Server.Hubs public override async Task OnDisconnectedAsync(Exception ex) { + await _calls.DisconnectAsync(Context.ConnectionId); string userName = Context.User?.GetUserName(); if (userName != null) { @@ -216,6 +219,7 @@ namespace Yavsc.Server.Hubs // in case of Kick returned false, being not allowed to, or for what ever other else failure, // the error handler will send an error message while handling the error. if (!_cxManager.Kick(Context.ConnectionId, userName, roomName, reason)) return; + await _calls.EndForUserAsync(userName, "removed-from-room", roomName); } var cxIds = _cxManager.GetConnexionIds(userName); if (cxIds != null) foreach (var cx in cxIds) @@ -240,7 +244,7 @@ namespace Yavsc.Server.Hubs throw new NotImplementedException(); } - public void Part(string roomName, string reason) + public async Task Part(string roomName, string reason) { if (!InputValidator.ValidateRoomName(roomName)) return; if (!InputValidator.ValidateReason(reason)) return; @@ -249,6 +253,7 @@ namespace Yavsc.Server.Hubs var roomGroupName = ChatHubConstants.HubGroupRoomsPrefix + roomName; var group = Clients.Group(roomGroupName); var userName = _cxManager.GetUserName(Context.ConnectionId); + await _calls.EndForUserAsync(userName, "left-room", roomName); group.SendAsync("notifyRoom", NotificationTypes.UserPart, roomName, $"{userName}: {reason}"); Groups.RemoveFromGroupAsync(Context.ConnectionId, roomGroupName); } diff --git a/src/Yavsc.Server/Services/CallSignalValidator.cs b/src/Yavsc.Server/Services/CallSignalValidator.cs new file mode 100644 index 000000000..02f637150 --- /dev/null +++ b/src/Yavsc.Server/Services/CallSignalValidator.cs @@ -0,0 +1,77 @@ +#nullable enable + +using System.Text; +using Microsoft.AspNetCore.SignalR; +using Yavsc.Abstract.Chat.Calls; + +namespace Yavsc.Services; + +public static class CallSignalValidator +{ + public static void Validate(CallSignal signal, bool relayOnly) + { + if (signal is null) + throw new HubException("A call signal is required."); + if (signal.Kind is "offer" or "answer") + { + if (string.IsNullOrWhiteSpace(signal.Sdp) || Encoding.UTF8.GetByteCount(signal.Sdp) > 24000 || + signal.Sdp.Replace("\r\n", "").Any(c => c is '\r' or '\n') || + !signal.Sdp.StartsWith("v=0\r\n", StringComparison.Ordinal) || + !signal.Sdp.Contains("\r\nm=", StringComparison.Ordinal) || + signal.Candidate != null || signal.SdpMid != null || signal.SdpMLineIndex != null) + throw new HubException("Invalid SDP signal."); + if (relayOnly) ValidateRelaySdp(signal.Sdp); + return; + } + if (signal.Kind != "candidate" || signal.Sdp != null || + string.IsNullOrWhiteSpace(signal.SdpMid) || signal.SdpMid.Length > 64 || + signal.SdpMid.Any(char.IsControl) || signal.SdpMLineIndex is not (>= 0 and <= 16) || + string.IsNullOrWhiteSpace(signal.Candidate) || Encoding.UTF8.GetByteCount(signal.Candidate) > 2048 || + signal.Candidate.Any(char.IsControl) || !signal.Candidate.StartsWith("candidate:", StringComparison.Ordinal)) + throw new HubException("Invalid ICE signal."); + if (relayOnly && !IsRelayCandidate(signal.Candidate)) + throw new HubException("Only relay ICE candidates are allowed."); + } + + private static bool IsRelayCandidate(string candidate) + { + var tokens = candidate.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length < 8 || tokens[6] != "typ" || tokens[7] != "relay") + return false; + for (var i = 8; i < tokens.Length; i++) + if (tokens[i] == "raddr" && + (i + 1 >= tokens.Length || !IsUnspecified(tokens[i + 1]))) + return false; + return true; + } + + private static bool IsUnspecified(string address) => address is "0.0.0.0" or "::"; + + private static void ValidateRelaySdp(string sdp) + { + var lines = sdp.Split("\r\n", StringSplitOptions.RemoveEmptyEntries); + HashSet relayAddresses = []; + foreach (var line in lines.Where(l => l.StartsWith("a=candidate:", StringComparison.Ordinal))) + { + if (!IsRelayCandidate(line[2..])) + throw new HubException("SDP contains a non-relay candidate or a related address."); + relayAddresses.Add(line.Split(' ', StringSplitOptions.RemoveEmptyEntries)[4]); + } + foreach (var line in lines) + { + if (line.StartsWith("o=", StringComparison.Ordinal)) + { + var address = line.Split(' ', StringSplitOptions.RemoveEmptyEntries).Last(); + if (!IsUnspecified(address) && address != "127.0.0.1") + throw new HubException("SDP origin must not expose a participant address."); + } + if (line.StartsWith("c=", StringComparison.Ordinal) || + (line.StartsWith("a=rtcp:", StringComparison.Ordinal) && line.Contains(" IN ", StringComparison.Ordinal))) + { + var address = line.Split(' ', StringSplitOptions.RemoveEmptyEntries).Last(); + if (!IsUnspecified(address) && !relayAddresses.Contains(address)) + throw new HubException("SDP connection address is not an included relay address."); + } + } + } +} diff --git a/src/Yavsc.Server/Services/CallSignalingService.cs b/src/Yavsc.Server/Services/CallSignalingService.cs new file mode 100644 index 000000000..c7e94d9db --- /dev/null +++ b/src/Yavsc.Server/Services/CallSignalingService.cs @@ -0,0 +1,285 @@ +#nullable enable + +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Yavsc.Abstract.Chat.Calls; +using Yavsc.Server.Hubs; +using Yavsc.Settings; + +namespace Yavsc.Services; + +public sealed class CallSignalingService( + IHubContext hub, + IOptions options, + TimeProvider clock, + ILogger logger) : BackgroundService +{ + private sealed class PendingCall(CallSession session, string callerConnection, string[] calleeConnections) + { + public CallSession Session { get; set; } = session; + public string CallerConnection { get; } = callerConnection; + public HashSet CalleeConnections { get; } = [.. calleeConnections]; + public string? AcceptedConnection { get; set; } + public bool Offered { get; set; } + public bool Answered { get; set; } + public int CandidateCount { get; set; } + public string[] Connections => [CallerConnection, .. CalleeConnections]; + } + + private readonly SemaphoreSlim _gate = new(1); + private readonly Dictionary _calls = []; + private readonly AudioVideoCallOptions _options = options.Value; + + public async Task InviteAsync( + string room, string caller, string callee, string callerConnection, + string[] calleeConnections, bool relayOnly) + { + EnsureEnabled(); + if (caller == callee || calleeConnections.Length == 0 || + calleeConnections.Contains(callerConnection)) + throw new HubException("Call participants are unavailable."); + ValidateIceConfiguration(relayOnly); + await _gate.WaitAsync(); + try + { + await ExpireCoreAsync(); + if (_calls.Count >= 128 || _calls.Values.Any(c => + c.Session.Caller == caller || c.Session.Callee == caller || + c.Session.Caller == callee || c.Session.Callee == callee)) + throw new HubException("A participant is already in a call."); + var session = new CallSession(Guid.NewGuid(), room, caller, callee, relayOnly, + clock.GetUtcNow().AddSeconds(45)); + var call = new PendingCall(session, callerConnection, calleeConnections); + _calls.Add(session.Id, call); + try + { + await hub.Clients.Clients(calleeConnections).SendAsync(CallHubMethods.Invitation, session); + } + catch + { + _calls.Remove(session.Id); + logger.LogWarning("Could not deliver call invitation {CallId}", session.Id); + throw; + } + return session; + } + finally { _gate.Release(); } + } + + public async Task AcceptAsync(Guid id, string user, string connection, bool relayOnly) + { + EnsureEnabled(); + await _gate.WaitAsync(); + try + { + await ExpireCoreAsync(); + var call = Find(id); + if (call.Session.Callee != user || !call.CalleeConnections.Contains(connection)) + throw new HubException("Only an invited device can accept this call."); + if (call.AcceptedConnection != null) + throw new HubException("This call was already accepted."); + var requireRelay = call.Session.RelayOnly || relayOnly; + ValidateIceConfiguration(requireRelay); + call.AcceptedConnection = connection; + call.Session = call.Session with + { + RelayOnly = requireRelay, + ExpiresAt = clock.GetUtcNow().AddHours(1) + }; + var otherDevices = call.CalleeConnections.Where(c => c != connection).ToArray(); + call.CalleeConnections.IntersectWith([connection]); + var accepted = new CallAccepted(call.Session, CreateIceServers(call.Session)); + try + { + if (otherDevices.Length > 0) + await hub.Clients.Clients(otherDevices).SendAsync( + CallHubMethods.Ended, new CallEnded(id, "answered-elsewhere")); + await hub.Clients.Clients([call.CallerConnection, connection]) + .SendAsync(CallHubMethods.Accepted, accepted); + } + catch + { + await FinishCoreAsync(call, "delivery-failed"); + throw; + } + } + finally { _gate.Release(); } + } + + public async Task SignalAsync(Guid id, string user, string connection, CallSignal signal) + { + EnsureEnabled(); + await _gate.WaitAsync(); + try + { + await ExpireCoreAsync(); + var call = Find(id); + var caller = call.CallerConnection == connection && call.Session.Caller == user; + var callee = call.AcceptedConnection == connection && call.Session.Callee == user; + if ((!caller && !callee) || call.AcceptedConnection == null) + throw new HubException("Media signaling requires acceptance on this device."); + CallSignalValidator.Validate(signal, call.Session.RelayOnly); + switch (signal.Kind) + { + case "offer": + if (!caller || call.Offered) + throw new HubException("Only the caller can send the initial offer once."); + break; + case "answer": + if (!callee || !call.Offered || call.Answered) + throw new HubException("An answer requires the initial offer."); + break; + case "candidate": + if ((caller && !call.Offered) || (callee && !call.Answered) || call.CandidateCount >= 256) + throw new HubException("ICE candidate is out of sequence or exceeds the call limit."); + break; + } + var target = caller ? call.AcceptedConnection : call.CallerConnection; + await hub.Clients.Client(target).SendAsync(CallHubMethods.SignalReceived, id, signal); + if (signal.Kind == "offer") call.Offered = true; + if (signal.Kind == "answer") call.Answered = true; + if (signal.Kind == "candidate") call.CandidateCount++; + } + finally { _gate.Release(); } + } + + public async Task EndAsync(Guid id, string user, string connection) + { + await _gate.WaitAsync(); + try + { + var call = Find(id); + if (!(call.Session.Caller == user && call.CallerConnection == connection) && + !(call.Session.Callee == user && (call.AcceptedConnection == connection || + (call.AcceptedConnection == null && call.CalleeConnections.Contains(connection))))) + throw new HubException("This device does not participate in the call."); + await FinishCoreAsync(call, call.AcceptedConnection == null ? "cancelled-or-declined" : "hangup"); + } + finally { _gate.Release(); } + } + + public async Task GetSessionAsync(Guid id, string user, string connection) + { + EnsureEnabled(); + await _gate.WaitAsync(); + try + { + await ExpireCoreAsync(); + var call = Find(id); + if (!(call.Session.Caller == user && call.CallerConnection == connection) && + !(call.Session.Callee == user && call.CalleeConnections.Contains(connection))) + throw new HubException("This device does not participate in the call."); + return call.Session; + } + finally { _gate.Release(); } + } + + public async Task EndForUserAsync(string user, string reason, string? room = null) + { + await _gate.WaitAsync(); + try + { + foreach (var call in _calls.Values.Where(c => + (c.Session.Caller == user || c.Session.Callee == user) && + (room == null || c.Session.RoomName == room)).ToArray()) + await FinishCoreAsync(call, reason); + } + finally { _gate.Release(); } + } + + public async Task DisconnectAsync(string connection) + { + await _gate.WaitAsync(); + try + { + foreach (var call in _calls.Values.ToArray()) + { + if (call.CallerConnection == connection || call.AcceptedConnection == connection) + await FinishCoreAsync(call, "disconnected"); + else if (call.CalleeConnections.Remove(connection) && call.CalleeConnections.Count == 0) + await FinishCoreAsync(call, "disconnected"); + } + } + finally { _gate.Release(); } + } + + public async Task ExpireAsync() + { + await _gate.WaitAsync(); + try { await ExpireCoreAsync(); } + finally { _gate.Release(); } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), clock); + while (await timer.WaitForNextTickAsync(stoppingToken)) + await ExpireAsync(); + } + + private PendingCall Find(Guid id) => _calls.TryGetValue(id, out var call) + ? call : throw new HubException("Call not found or expired."); + + private async Task ExpireCoreAsync() + { + foreach (var call in _calls.Values.Where(c => c.Session.ExpiresAt <= clock.GetUtcNow()).ToArray()) + await FinishCoreAsync(call, "expired"); + } + + private async Task FinishCoreAsync(PendingCall call, string reason) + { + _calls.Remove(call.Session.Id); + await hub.Clients.Clients(call.Connections) + .SendAsync(CallHubMethods.Ended, new CallEnded(call.Session.Id, reason)); + } + + private void EnsureEnabled() + { + if (!_options.Enabled) + throw new HubException("Audio/video calls are not enabled on this server."); + } + + private void ValidateIceConfiguration(bool relayOnly) + { + if (_options.StunUrls.Any(url => !ValidUrl(url, "stun", "stuns")) || + _options.TurnUrls.Any(url => !ValidUrl(url, "turn", "turns")) || + (_options.TurnUrls.Length > 0 && string.IsNullOrWhiteSpace(_options.TurnSharedSecret)) || + (relayOnly && _options.TurnUrls.Length == 0)) + throw new HubException("ICE/TURN configuration is unavailable. No direct fallback is allowed."); + } + + private static bool ValidUrl(string url, params string[] schemes) + { + if (string.IsNullOrWhiteSpace(url) || url.Length > 512 || url.Any(char.IsWhiteSpace)) + return false; + var separator = url.IndexOf(':'); + if (separator <= 0 || !schemes.Contains(url[..separator])) + return false; + var endpoint = url[(separator + 1)..]; + if (endpoint.Contains('/') || !Uri.TryCreate("http://" + endpoint, UriKind.Absolute, out var uri) || + string.IsNullOrWhiteSpace(uri.Host) || uri.Port <= 0 || + uri.UserInfo.Length > 0 || uri.Fragment.Length > 0) + return false; + return uri.Query.Length == 0 || (url.StartsWith("turn", StringComparison.Ordinal) && + uri.Query is "?transport=udp" or "?transport=tcp"); + } + + private CallIceServer[] CreateIceServers(CallSession session) + { + List result = []; + if (!session.RelayOnly && _options.StunUrls.Length > 0) + result.Add(new CallIceServer(_options.StunUrls)); + if (_options.TurnUrls.Length > 0) + { + var username = $"{session.ExpiresAt.AddMinutes(5).ToUnixTimeSeconds()}:{Guid.NewGuid():N}"; + var credential = Convert.ToBase64String(HMACSHA1.HashData( + Encoding.UTF8.GetBytes(_options.TurnSharedSecret!), Encoding.UTF8.GetBytes(username))); + result.Add(new CallIceServer(_options.TurnUrls, username, credential)); + } + return result.ToArray(); + } +} diff --git a/src/Yavsc.Server/Services/HubConnectionManager.cs b/src/Yavsc.Server/Services/HubConnectionManager.cs index 98d57085e..661a645bd 100644 --- a/src/Yavsc.Server/Services/HubConnectionManager.cs +++ b/src/Yavsc.Server/Services/HubConnectionManager.cs @@ -91,7 +91,13 @@ namespace Yavsc.Services List cxIds; if (ChatCxIds.TryGetValue(uname, out cxIds)) { - cxIds.Remove(connectionId); + lock (cxIds) + { + cxIds.Remove(connectionId); + // Room presence is account-wide while another device is connected. + if (cxIds.Count > 0) + return; + } foreach (var room in ChatRoomPresence[uname]) { Part(connectionId, room, "connexion aborted"); @@ -250,7 +256,10 @@ namespace Yavsc.Services public IEnumerable GetConnexionIds(string userName) { - return ChatCxIds.ContainsKey(userName) ? ChatCxIds[userName] : null; + if (!ChatCxIds.TryGetValue(userName, out var connections)) + return Array.Empty(); + lock (connections) + return connections.ToArray(); } /// diff --git a/src/Yavsc.Server/Settings/AudioVideoCallOptions.cs b/src/Yavsc.Server/Settings/AudioVideoCallOptions.cs new file mode 100644 index 000000000..b86c58fbf --- /dev/null +++ b/src/Yavsc.Server/Settings/AudioVideoCallOptions.cs @@ -0,0 +1,13 @@ +#nullable enable + +namespace Yavsc.Settings; + +public sealed class AudioVideoCallOptions +{ + public const string SectionName = "AudioVideoCalls"; + + public bool Enabled { get; set; } + public string[] StunUrls { get; set; } = []; + public string[] TurnUrls { get; set; } = []; + public string? TurnSharedSecret { get; set; } +} diff --git a/src/test/Yavsc.Api.Tests/AccountCallPreferenceTests.cs b/src/test/Yavsc.Api.Tests/AccountCallPreferenceTests.cs index d3a1407cc..dadf914a9 100644 --- a/src/test/Yavsc.Api.Tests/AccountCallPreferenceTests.cs +++ b/src/test/Yavsc.Api.Tests/AccountCallPreferenceTests.cs @@ -4,10 +4,15 @@ using System.Net.Http.Json; using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.AspNetCore.SignalR; using Yavsc.Abstract.Identity; +using Yavsc.Abstract.Chat.Calls; using Yavsc.Api.Test.Fixtures; using Yavsc.Models; using Yavsc.Tests.Shared; +using Yavsc.Services; +using Yavsc.Settings; namespace Yavsc.Api.Test; @@ -146,4 +151,32 @@ public sealed class AccountCallPreferenceTests(ApiWebServerFixture fixture) : IC Assert.False(profile.HideIpAddressInCalls); Assert.NotEqual("Must not be saved", profile.FullName); } + + [Fact] + public async Task Activating_privacy_ends_an_existing_call_before_returning_success() + { + var ct = TestContext.Current.CancellationToken; + var id = await CreateUserAsync(); + var other = await CreateUserAsync(); + var options = fixture.Services.GetRequiredService>().Value; + var calls = fixture.Services.GetRequiredService(); + var delivery = fixture.Services.GetRequiredService(); + options.Enabled = true; + try + { + var call = await calls.InviteAsync("General", id, other, "preference-caller", ["preference-callee"], false); + await calls.AcceptAsync(call.Id, other, "preference-callee", false); + using var client = Client(id); + using var response = await client.PutAsJsonAsync(Endpoint, new { hideIpAddressInCalls = true }, ct); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Contains(delivery.Pushes, p => p.Method == CallHubMethods.Ended && + p.Arguments[0] is CallEnded ended && ended.CallId == call.Id && ended.Reason == "profile-changed"); + await Assert.ThrowsAsync(() => calls.GetSessionAsync(call.Id, id, "preference-caller")); + } + finally + { + await calls.EndForUserAsync(id, "test-cleanup"); + options.Enabled = false; + } + } } diff --git a/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs b/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs index 36f1afbe8..30815c4ec 100644 --- a/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs +++ b/src/test/Yavsc.Api.Tests/Fixtures/ApiWebServerFixture.cs @@ -84,6 +84,9 @@ public sealed class ApiWebServerFixture : WebHostFixture builder.Services.AddTransient(); builder.Services.AddSingleton>(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); + builder.Services.Configure(_ => { }); + builder.Services.AddSingleton(TimeProvider.System); + builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddAuthorization(options => { diff --git a/src/test/Yavsc.Api.Tests/Fixtures/RecordingMessageDelivery.cs b/src/test/Yavsc.Api.Tests/Fixtures/RecordingMessageDelivery.cs index e26bd4f9a..5ef68126a 100644 --- a/src/test/Yavsc.Api.Tests/Fixtures/RecordingMessageDelivery.cs +++ b/src/test/Yavsc.Api.Tests/Fixtures/RecordingMessageDelivery.cs @@ -16,8 +16,8 @@ public sealed class RecordingMessageDelivery : ITrueEmailSender, IHubContext.Groups => throw new NotSupportedException(); public IClientProxy All => throw new NotSupportedException(); public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); - public IClientProxy Client(string connectionId) => new RecordingClient(this, connectionId); - public IClientProxy Clients(IReadOnlyList connectionIds) => throw new NotSupportedException(); + public IClientProxy Client(string connectionId) => new RecordingClient(this, [connectionId]); + public IClientProxy Clients(IReadOnlyList connectionIds) => new RecordingClient(this, connectionIds); public IClientProxy Group(string groupName) => throw new NotSupportedException(); public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => throw new NotSupportedException(); public IClientProxy Groups(IReadOnlyList groupNames) => throw new NotSupportedException(); @@ -36,11 +36,12 @@ public sealed class RecordingMessageDelivery : ITrueEmailSender, IHubContext connectionIds) : IClientProxy { public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { - delivery.Pushes.Enqueue(new Push(connectionId, method, args)); + foreach (var connectionId in connectionIds) + delivery.Pushes.Enqueue(new Push(connectionId, method, args)); return Task.CompletedTask; } } diff --git a/src/test/Yavsc.Server.Tests/CallConnectionPresenceTests.cs b/src/test/Yavsc.Server.Tests/CallConnectionPresenceTests.cs new file mode 100644 index 000000000..bf633f831 --- /dev/null +++ b/src/test/Yavsc.Server.Tests/CallConnectionPresenceTests.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Yavsc.Models; +using Yavsc.Services; + +namespace Yavsc.Server.Tests; + +public sealed class CallConnectionPresenceTests +{ + [Fact] + public void Disconnecting_an_unselected_device_preserves_the_accounts_chat_presence() + { + using var services = new ServiceCollection().AddLogging().AddLocalization() + .AddDbContext(o => o.UseInMemoryDatabase(Guid.NewGuid().ToString())) + .BuildServiceProvider(); + var manager = new HubConnectionManager(services.GetRequiredService()); + var user = "call-" + Guid.NewGuid().ToString("N"); + var room = "call-" + Guid.NewGuid().ToString("N"); + var selected = Guid.NewGuid().ToString("N"); + var other = Guid.NewGuid().ToString("N"); + manager.OnConnected(selected, user, false); + manager.OnConnected(other, user, false); + manager.Join(room, selected); + try + { + var snapshot = manager.GetConnexionIds(user); + manager.OnDisconnected(other); + Assert.True(manager.IsConnected(user)); + Assert.True(manager.IsPresent(room, user)); + Assert.Equal([selected], manager.GetConnexionIds(user)); + Assert.Equal(2, snapshot.Count()); + } + finally + { + manager.OnDisconnected(selected); + HubConnectionManager.Channels.TryRemove(room, out _); + } + } +} diff --git a/src/test/Yavsc.Server.Tests/CallHubTests.cs b/src/test/Yavsc.Server.Tests/CallHubTests.cs new file mode 100644 index 000000000..a169b85c3 --- /dev/null +++ b/src/test/Yavsc.Server.Tests/CallHubTests.cs @@ -0,0 +1,162 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Yavsc.Abstract.Chat.Calls; +using Yavsc.Models; +using Yavsc.Server.Hubs; +using Yavsc.Services; +using Yavsc.Settings; +using Yavsc.ViewModels.Chat; + +namespace Yavsc.Server.Tests; + +public sealed class CallHubTests +{ + private sealed class Fixture : IDisposable + { + public ApplicationDbContext Db { get; } = new( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options); + public RecordingCallHub Delivery { get; } = new(); + public Connections Connections { get; } = new(); + public CallSignalingService Calls { get; } + private readonly ServiceProvider _services; + + public Fixture() + { + _services = new ServiceCollection().AddLogging().AddLocalization().BuildServiceProvider(); + Calls = new(Delivery, Options.Create(new AudioVideoCallOptions + { + Enabled = true, TurnUrls = ["turn:turn.example.test:3478"], TurnSharedSecret = "test-only" + }), TimeProvider.System, NullLogger.Instance); + Db.Users.AddRange( + new ApplicationUser { Id = "alice-id", UserName = "alice", Email = "alice@example.test" }, + new ApplicationUser { Id = "bob-id", UserName = "bob", Email = "bob@example.test" }); + Db.SaveChanges(); + Connections.OnConnected("alice-1", "alice", false); + Connections.OnConnected("bob-1", "bob", false); + } + + public ChatHub Hub(string user, string connection) => new(Db, + _services.GetRequiredService(), + _services.GetRequiredService(), + Connections, Calls) { Context = new Caller(user, connection) }; + + public void Dispose() + { + Calls.Dispose(); + Db.Dispose(); + _services.Dispose(); + } + } + + [Fact] + public async Task Invitation_requires_existing_accounts_presence_and_the_authenticated_connection() + { + using var f = new Fixture(); + using var hub = f.Hub("alice", "alice-1"); + f.Connections.Present.Remove("bob"); + await Assert.ThrowsAsync(() => hub.InviteCall("General", "bob")); + f.Connections.Present.Add("bob"); + using var spoof = f.Hub("bob", "alice-1"); + await Assert.ThrowsAsync(() => spoof.InviteCall("General", "alice")); + await Assert.ThrowsAsync(() => hub.InviteCall("General", "alice")); + Assert.Empty(f.Delivery.Messages); + f.Db.Users.Remove(await f.Db.Users.SingleAsync(u => u.Id == "bob-id", TestContext.Current.CancellationToken)); + await f.Db.SaveChangesAsync(TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(() => hub.InviteCall("General", "bob")); + } + + [Fact] + public async Task Persistent_preference_is_read_at_invitation_and_rechecked_at_acceptance() + { + using var f = new Fixture(); + using var caller = f.Hub("alice", "alice-1"); + using var callee = f.Hub("bob", "bob-1"); + var call = await caller.InviteCall("General", "bob"); + Assert.False(call.RelayOnly); + var bob = await f.Db.Users.SingleAsync(u => u.Id == "bob-id", TestContext.Current.CancellationToken); + bob.HideIpAddressInCalls = true; + await f.Db.SaveChangesAsync(TestContext.Current.CancellationToken); + await callee.AcceptCall(call.Id); + Assert.True(Assert.IsType(f.Delivery.Messages.Last().Arguments[0]).Session.RelayOnly); + await caller.EndCall(call.Id); + Assert.True((await caller.InviteCall("General", "bob")).RelayOnly); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Blacklisting_either_participant_prevents_invitations(bool reverse) + { + using var f = new Fixture(); + f.Db.BlackListed.Add(new() + { + OwnerId = reverse ? "alice-id" : "bob-id", + UserId = reverse ? "bob-id" : "alice-id" + }); + await f.Db.SaveChangesAsync(TestContext.Current.CancellationToken); + using var hub = f.Hub("alice", "alice-1"); + await Assert.ThrowsAsync(() => hub.InviteCall("General", "bob")); + Assert.Empty(f.Delivery.Messages); + } + + [Fact] + public async Task Leaving_the_room_prevents_acceptance_and_privacy_change_prevents_direct_signaling() + { + using var f = new Fixture(); + using var caller = f.Hub("alice", "alice-1"); + using var callee = f.Hub("bob", "bob-1"); + var call = await caller.InviteCall("General", "bob"); + f.Connections.Present.Remove("bob"); + await Assert.ThrowsAsync(() => callee.AcceptCall(call.Id)); + f.Connections.Present.Add("bob"); + await callee.AcceptCall(call.Id); + var bob = await f.Db.Users.SingleAsync(u => u.Id == "bob-id", TestContext.Current.CancellationToken); + bob.HideIpAddressInCalls = true; + await f.Db.SaveChangesAsync(TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(() => caller.SendCallSignal(call.Id, + new("offer", "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n"))); + Assert.DoesNotContain(f.Delivery.Messages, m => m.Method == CallHubMethods.SignalReceived); + Assert.Equal("privacy-changed", Assert.IsType(f.Delivery.Messages.Last().Arguments[0]).Reason); + } + + private sealed class Caller(string name, string connection) : HubCallerContext + { + public override string ConnectionId => connection; + public override string? UserIdentifier => name; + public override ClaimsPrincipal User { get; } = new(new ClaimsIdentity( + [new Claim("name", name)], "test")); + public override IDictionary Items { get; } = new Dictionary(); + public override IFeatureCollection Features { get; } = new FeatureCollection(); + public override CancellationToken ConnectionAborted => CancellationToken.None; + public override void Abort() { } + } + + private sealed class Connections : IConnexionManager + { + public HashSet Present { get; } = ["alice", "bob"]; + private readonly Dictionary _names = []; + public string GetUserName(string cxId) => _names.GetValueOrDefault(cxId)!; + public bool IsConnected(string user) => _names.ContainsValue(user); + public bool IsPresent(string room, string user) => room == "General" && Present.Contains(user); + public IEnumerable GetConnexionIds(string user) => _names.Where(n => n.Value == user).Select(n => n.Key); + public void OnConnected(string id, string user, bool isCop) => _names[id] = user; + public void OnDisconnected(string id) => _names.Remove(id); + public void SetErrorHandler(Action errorHandler) { } + public ChatRoomInfo Join(string room, string id) => throw new NotSupportedException(); + public bool Part(string id, string room, string reason) => throw new NotSupportedException(); + public bool Kick(string id, string user, string room, string reason) => throw new NotSupportedException(); + public bool Op(string room, string user) => throw new NotSupportedException(); + public bool DeOp(string room, string user) => throw new NotSupportedException(); + public bool Hop(string room, string user) => throw new NotSupportedException(); + public bool DeHop(string room, string user) => throw new NotSupportedException(); + public bool TryGetChanInfo(string room, out ChatRoomInfo info) => throw new NotSupportedException(); + public IEnumerable ListChannels(string pattern) => throw new NotSupportedException(); + } +} diff --git a/src/test/Yavsc.Server.Tests/CallSignalingTests.cs b/src/test/Yavsc.Server.Tests/CallSignalingTests.cs new file mode 100644 index 000000000..0124c4b60 --- /dev/null +++ b/src/test/Yavsc.Server.Tests/CallSignalingTests.cs @@ -0,0 +1,313 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Yavsc.Abstract.Chat.Calls; +using Yavsc.Server.Hubs; +using Yavsc.Services; +using Yavsc.Settings; + +namespace Yavsc.Server.Tests; + +public sealed class CallSignalingTests +{ + private const string Sdp = "v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\nc=IN IP4 0.0.0.0\r\n"; + private static CallSignal Candidate(string type = "relay") => + new("candidate", Candidate: $"candidate:1 1 udp 100 192.0.2.1 5000 typ {type} raddr 0.0.0.0 rport 0", + SdpMid: "0", SdpMLineIndex: 0); + + private sealed class Clock : TimeProvider + { + public DateTimeOffset Now { get; set; } = DateTimeOffset.Parse("2026-10-26T00:00:00Z"); + public override DateTimeOffset GetUtcNow() => Now; + } + + private sealed class Fixture : IDisposable + { + public RecordingCallHub Hub { get; } = new(); + public Clock Clock { get; } = new(); + public AudioVideoCallOptions Options { get; } = new() + { + Enabled = true, + StunUrls = ["stun:stun.example.test:3478"], + TurnUrls = ["turn:turn.example.test:3478?transport=udp", "turns:turn.example.test:5349?transport=tcp"], + TurnSharedSecret = "test-only-secret" + }; + public CallSignalingService Service { get; } + public Fixture() + { + Service = new(Hub, Microsoft.Extensions.Options.Options.Create(Options), Clock, + NullLogger.Instance); + } + public Task Invite(bool relay = false) => + Service.InviteAsync("General", "alice", "bob", "alice-device", ["bob-1", "bob-2"], relay); + public void Dispose() => Service.Dispose(); + } + + [Fact] + public async Task Calls_are_disabled_by_default() + { + using var f = new Fixture(); + f.Options.Enabled = new AudioVideoCallOptions().Enabled; + await Assert.ThrowsAsync(() => f.Invite()); + Assert.Empty(f.Hub.Messages); + } + + [Fact] + public async Task Invitation_does_not_grant_media_signaling_or_disclose_turn_credentials() + { + using var f = new Fixture(); + var call = await f.Invite(); + Assert.Equal(f.Clock.Now.AddSeconds(45), call.ExpiresAt); + var message = Assert.Single(f.Hub.Messages); + Assert.Equal(CallHubMethods.Invitation, message.Method); + Assert.Equal(["bob-1", "bob-2"], message.Targets); + Assert.IsType(Assert.Single(message.Arguments)); + await Assert.ThrowsAsync(() => + f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp))); + } + + [Fact] + public async Task First_acceptance_wins_and_other_devices_cannot_signal_or_end_the_call() + { + using var f = new Fixture(); + var call = await f.Invite(); + await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false); + await Assert.ThrowsAsync(() => f.Service.AcceptAsync(call.Id, "bob", "bob-2", false)); + await Assert.ThrowsAsync(() => f.Service.SignalAsync(call.Id, "bob", "bob-2", Candidate())); + await Assert.ThrowsAsync(() => f.Service.EndAsync(call.Id, "bob", "bob-2")); + var dismissed = Assert.Single(f.Hub.Messages, m => m.Method == CallHubMethods.Ended); + Assert.Equal(["bob-2"], dismissed.Targets); + Assert.Equal("answered-elsewhere", Assert.IsType(dismissed.Arguments[0]).Reason); + var ready = Assert.Single(f.Hub.Messages, m => m.Method == CallHubMethods.Accepted); + Assert.Equal(["alice-device", "bob-1"], ready.Targets); + } + + [Fact] + public async Task Concurrent_acceptance_selects_exactly_one_device() + { + using var f = new Fixture(); + var call = await f.Invite(); + var attempts = new[] { "bob-1", "bob-2" }.Select(async connection => + await Record.ExceptionAsync(() => f.Service.AcceptAsync(call.Id, "bob", connection, false))); + var results = await Task.WhenAll(attempts); + Assert.Single(results, r => r == null); + Assert.Single(results, r => r is HubException); + Assert.Single(f.Hub.Messages, m => m.Method == CallHubMethods.Accepted); + } + + [Fact] + public async Task Privacy_can_only_be_strengthened_on_acceptance_and_turn_credentials_expire() + { + using var f = new Fixture(); + var call = await f.Invite(); + await f.Service.AcceptAsync(call.Id, "bob", "bob-1", true); + var ready = Assert.IsType( + f.Hub.Messages.Single(m => m.Method == CallHubMethods.Accepted).Arguments[0]); + Assert.True(ready.Session.RelayOnly); + var ice = Assert.Single(ready.IceServers); + Assert.Equal(f.Options.TurnUrls, ice.Urls); + Assert.NotNull(ice.Username); + Assert.StartsWith($"{ready.Session.ExpiresAt.AddMinutes(5).ToUnixTimeSeconds()}:", ice.Username); + Assert.Equal(Convert.ToBase64String(HMACSHA1.HashData( + Encoding.UTF8.GetBytes(f.Options.TurnSharedSecret!), Encoding.UTF8.GetBytes(ice.Username))), ice.Credential); + await f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp)); + await Assert.ThrowsAsync(() => + f.Service.SignalAsync(call.Id, "alice", "alice-device", Candidate("host"))); + } + + [Fact] + public async Task Relay_invitation_cannot_be_downgraded_and_missing_turn_fails_closed() + { + using var f = new Fixture(); + f.Options.TurnUrls = []; + await Assert.ThrowsAsync(() => f.Invite(true)); + Assert.Empty(f.Hub.Messages); + f.Options.TurnUrls = ["turn:turn.example.test:3478"]; + var call = await f.Invite(true); + await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false); + Assert.True(Assert.IsType(f.Hub.Messages.Last().Arguments[0]).Session.RelayOnly); + } + + [Theory] + [InlineData("https://turn.example.test")] + [InlineData("turn:")] + [InlineData("turn://turn.example.test")] + [InlineData("turn:user:password@turn.example.test")] + [InlineData("turn:turn.example.test:0")] + [InlineData("turn:turn.example.test?transport=invalid")] + [InlineData("turn:turn.example.test#fragment")] + public async Task Invalid_turn_configuration_is_rejected_before_inviting(string url) + { + using var f = new Fixture(); + f.Options.TurnUrls = [url]; + await Assert.ThrowsAsync(() => f.Invite(true)); + Assert.Empty(f.Hub.Messages); + } + + [Fact] + public async Task Failed_delivery_does_not_leave_participants_busy() + { + using var f = new Fixture(); + f.Hub.FailMethod = CallHubMethods.Invitation; + await Assert.ThrowsAsync(() => f.Invite()); + f.Hub.FailMethod = null; + var call = await f.Invite(); + f.Hub.FailMethod = CallHubMethods.Accepted; + await Assert.ThrowsAsync(() => f.Service.AcceptAsync(call.Id, "bob", "bob-1", false)); + f.Hub.FailMethod = null; + Assert.NotEqual(call.Id, (await f.Invite()).Id); + } + + [Fact] + public async Task Signaling_is_bound_to_identity_device_consent_and_initial_offer_order() + { + using var f = new Fixture(); + var call = await f.Invite(); + await Assert.ThrowsAsync(() => f.Service.AcceptAsync(call.Id, "mallory", "bob-1", false)); + await Assert.ThrowsAsync(() => f.Service.AcceptAsync(call.Id, "bob", "uninvited", false)); + await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false); + await Assert.ThrowsAsync(() => f.Service.SignalAsync(call.Id, "bob", "bob-1", new("offer", Sdp))); + await Assert.ThrowsAsync(() => f.Service.SignalAsync(call.Id, "bob", "bob-1", new("answer", Sdp))); + await Assert.ThrowsAsync(() => f.Service.SignalAsync(call.Id, "alice", "other-device", new("offer", Sdp))); + await Assert.ThrowsAsync(() => f.Service.SignalAsync(call.Id, "mallory", "alice-device", new("offer", Sdp))); + await f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp)); + await f.Service.SignalAsync(call.Id, "alice", "alice-device", Candidate("host")); + await f.Service.SignalAsync(call.Id, "bob", "bob-1", new("answer", Sdp)); + await f.Service.SignalAsync(call.Id, "bob", "bob-1", Candidate("host")); + var signals = f.Hub.Messages.Where(m => m.Method == CallHubMethods.SignalReceived).ToArray(); + Assert.Equal(4, signals.Length); + Assert.Equal(["bob-1"], signals[0].Targets); + Assert.Equal(["alice-device"], signals[2].Targets); + Assert.All(signals, m => Assert.Equal(call.Id, m.Arguments[0])); + await Assert.ThrowsAsync(() => f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp))); + await Assert.ThrowsAsync(() => f.Service.SignalAsync(call.Id, "bob", "bob-1", new("answer", Sdp))); + } + + [Fact] + public async Task Busy_users_cannot_be_invited_again_and_expiry_releases_them() + { + using var f = new Fixture(); + var call = await f.Invite(); + await Assert.ThrowsAsync(() => f.Invite()); + await Assert.ThrowsAsync(() => + f.Service.InviteAsync("Other", "carol", "bob", "carol-1", ["bob-1"], false)); + f.Clock.Now = call.ExpiresAt; + await f.Service.ExpireAsync(); + Assert.Equal("expired", Assert.IsType(f.Hub.Messages.Last().Arguments[0]).Reason); + await Assert.ThrowsAsync(() => f.Service.AcceptAsync(call.Id, "bob", "bob-1", false)); + Assert.NotEqual(call.Id, (await f.Invite()).Id); + } + + [Fact] + public async Task Unselected_device_disconnect_does_not_end_an_accepted_call() + { + using var f = new Fixture(); + var call = await f.Invite(); + await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false); + await f.Service.DisconnectAsync("bob-2"); + await f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp)); + await f.Service.DisconnectAsync("bob-1"); + Assert.Equal("disconnected", Assert.IsType(f.Hub.Messages.Last().Arguments[0]).Reason); + await Assert.ThrowsAsync(() => + f.Service.SignalAsync(call.Id, "alice", "alice-device", Candidate())); + } + + [Fact] + public async Task Decline_hangup_room_departure_and_profile_change_release_the_call() + { + using var f = new Fixture(); + var call = await f.Invite(); + await Assert.ThrowsAsync(() => f.Service.EndAsync(call.Id, "mallory", "mallory-1")); + await f.Service.EndAsync(call.Id, "bob", "bob-2"); + Assert.Equal("cancelled-or-declined", Assert.IsType(f.Hub.Messages.Last().Arguments[0]).Reason); + call = await f.Invite(); + await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false); + await f.Service.EndAsync(call.Id, "alice", "alice-device"); + Assert.Equal("hangup", Assert.IsType(f.Hub.Messages.Last().Arguments[0]).Reason); + call = await f.Invite(); + await f.Service.EndForUserAsync("bob", "left-room", "Other"); + Assert.Equal(call.Id, (await f.Service.GetSessionAsync(call.Id, "alice", "alice-device")).Id); + await f.Service.EndForUserAsync("bob", "left-room", "General"); + Assert.Equal("left-room", Assert.IsType(f.Hub.Messages.Last().Arguments[0]).Reason); + await f.Invite(); + await f.Service.EndForUserAsync("alice", "profile-changed"); + Assert.Equal("profile-changed", Assert.IsType(f.Hub.Messages.Last().Arguments[0]).Reason); + } + + [Fact] + public async Task Candidate_count_is_bounded() + { + using var f = new Fixture(); + var call = await f.Invite(); + await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false); + await f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp)); + for (var i = 0; i < 256; i++) + await f.Service.SignalAsync(call.Id, "alice", "alice-device", Candidate()); + await Assert.ThrowsAsync(() => f.Service.SignalAsync(call.Id, "alice", "alice-device", Candidate())); + } + + [Theory] + [InlineData("candidate:1 1 udp 100 192.0.2.1 5000 typ host")] + [InlineData("candidate:1 1 udp 100 192.0.2.1 5000 typ host typ relay")] + [InlineData("candidate:1 1 udp 100 192.0.2.1 5000 typ relay raddr 10.0.0.1 rport 5000")] + public void Relay_policy_rejects_direct_forged_and_related_addresses(string candidate) + { + Assert.Throws(() => + CallSignalValidator.Validate(new("candidate", Candidate: candidate, SdpMid: "0", SdpMLineIndex: 0), true)); + Assert.Throws(() => + CallSignalValidator.Validate(new("offer", Sdp + "a=" + candidate + "\r\n"), true)); + } + + [Theory] + [InlineData("o=- 1 1 IN IP4 10.0.0.1\r\n")] + [InlineData("c=IN IP4 10.0.0.1\r\n")] + [InlineData("a=rtcp:5000 IN IP4 10.0.0.1\r\n")] + public void Relay_sdp_must_not_expose_a_direct_address(string line) => + Assert.Throws(() => CallSignalValidator.Validate(new("offer", Sdp + line), true)); + + [Fact] + public void Signal_validation_enforces_exact_shapes_and_size_limits() + { + CallSignalValidator.Validate(new("offer", Sdp), true); + CallSignalValidator.Validate(Candidate(), true); + CallSignal[] invalid = + [ + new("bogus"), new("offer", "v=0"), new("offer", Sdp + new string('x', 24000)), + new("offer", Sdp + new string('\u00e9', 12000)), new("offer", Sdp + "a=ignored\nc=IN IP4 10.0.0.1"), + new("answer", Sdp, "unexpected"), Candidate() with { SdpMid = null }, + Candidate() with { SdpMLineIndex = -1 }, Candidate() with { SdpMLineIndex = 17 }, + Candidate() with { Candidate = new string('x', 2049) }, Candidate() with { Sdp = Sdp }, + Candidate() with { Candidate = Candidate().Candidate + "\r\ninjected" } + ]; + foreach (var signal in invalid) Assert.Throws(() => CallSignalValidator.Validate(signal, false)); + } +} + +internal sealed class RecordingCallHub : IHubContext, IHubClients +{ + internal sealed record Message(string[] Targets, string Method, object?[] Arguments); + public List Messages { get; } = []; + public string? FailMethod { get; set; } + IHubClients IHubContext.Clients => this; + IGroupManager IHubContext.Groups => throw new NotSupportedException(); + private sealed class Proxy(RecordingCallHub hub, string[] targets) : IClientProxy + { + public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) + { + if (hub.FailMethod == method) throw new IOException("Test delivery failed."); + hub.Messages.Add(new(targets, method, args)); + return Task.CompletedTask; + } + } + public IClientProxy Client(string id) => new Proxy(this, [id]); + public IClientProxy Clients(IReadOnlyList ids) => new Proxy(this, ids.ToArray()); + public IClientProxy All => throw new NotSupportedException(); + public IClientProxy AllExcept(IReadOnlyList ids) => throw new NotSupportedException(); + public IClientProxy Group(string name) => throw new NotSupportedException(); + public IClientProxy GroupExcept(string name, IReadOnlyList ids) => throw new NotSupportedException(); + public IClientProxy Groups(IReadOnlyList names) => throw new NotSupportedException(); + public IClientProxy User(string id) => throw new NotSupportedException(); + public IClientProxy Users(IReadOnlyList ids) => throw new NotSupportedException(); +}