feat(calls): add consent-based signaling to existing ChatHub

Wire shared PostIt call signaling, account-derived relay policy and temporary TURN credentials. Keep calls disabled pending native Android/Linux media integration. Document experimental Android crash and laboratory removal.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-26 01:00:19 +00:00
commit 5e48831d6d
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
20 changed files with 1368 additions and 16 deletions

View file

@ -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.

View file

@ -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.
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.

View file

@ -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<InvalidOperationException>(() => client.InviteAsync("General", "bob", ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.AcceptAsync(id, ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.EndAsync(id, ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<ObjectDisposedException>(() => client.AcceptAsync(Guid.NewGuid(), TestContext.Current.CancellationToken));
Assert.Equal(HubConnectionState.Disconnected, connection.State);
using var replacement = new CallSignalingClient(connection);
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<CallAccepted>(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<CallSignal>(JsonSerializer.Serialize(signal, options), options));
}
}

View file

@ -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<CallSession>? Invitation;
public event Action<CallAccepted>? Accepted;
public event Action<CallEnded>? Ended;
public event Action<Guid, CallSignal>? Signal;
public event Action? Interrupted;
public CallSignalingClient(HubConnection connection)
{
_connection = connection;
_subscriptions =
[
connection.On<CallSession>(CallHubMethods.Invitation, call => Invitation?.Invoke(call)),
connection.On<CallAccepted>(CallHubMethods.Accepted, call => Accepted?.Invoke(call)),
connection.On<CallEnded>(CallHubMethods.Ended, call => Ended?.Invoke(call)),
connection.On<Guid, CallSignal>(CallHubMethods.SignalReceived,
(id, signal) => Signal?.Invoke(id, signal))
];
connection.Reconnecting += OnInterruptedAsync;
connection.Closed += OnInterruptedAsync;
}
public Task<CallSession> InviteAsync(string room, string user, CancellationToken ct = default)
{
EnsureConnected();
return _connection.InvokeAsync<CallSession>(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();
}
}

View file

@ -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<string, string, string>("notifyUser", (type, user, msg) =>
{

View file

@ -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);

View file

@ -39,11 +39,12 @@ namespace Yavsc.WebApi.Controllers
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITrueEmailSender _emailSender;
private readonly ICalendarManager _calendarManager;
private readonly CallSignalingService _calls;
public ApiAccountController(
ILoggerFactory loggerFactory, ApplicationDbContext dbContext,
IOptions<SiteSettings> siteSettings, UserManager<ApplicationUser> 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 });
}

View file

@ -87,6 +87,11 @@ internal class Program
.AddJsonProtocol(options =>
options.PayloadSerializerOptions.IncludeFields = true);
services.AddSingleton<IConnexionManager, HubConnectionManager>();
services.Configure<AudioVideoCallOptions>(
builder.Configuration.GetSection(AudioVideoCallOptions.SectionName));
services.AddSingleton(TimeProvider.System);
services.AddSingleton<CallSignalingService>();
services.AddHostedService(provider => provider.GetRequiredService<CallSignalingService>());
// DbContextBuilder
services.AddDbContext<ApplicationDbContext>(options =>

View file

@ -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<CallSession> 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<bool> 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);
}
}

View file

@ -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);
}

View file

@ -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<string> 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.");
}
}
}
}

View file

@ -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<ChatHub> hub,
IOptions<AudioVideoCallOptions> options,
TimeProvider clock,
ILogger<CallSignalingService> 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<string> 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<Guid, PendingCall> _calls = [];
private readonly AudioVideoCallOptions _options = options.Value;
public async Task<CallSession> 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<CallSession> 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<CallIceServer> 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();
}
}

View file

@ -91,7 +91,13 @@ namespace Yavsc.Services
List<string> 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<string> GetConnexionIds(string userName)
{
return ChatCxIds.ContainsKey(userName) ? ChatCxIds[userName] : null;
if (!ChatCxIds.TryGetValue(userName, out var connections))
return Array.Empty<string>();
lock (connections)
return connections.ToArray();
}
/// <summary>

View file

@ -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; }
}

View file

@ -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<IOptions<AudioVideoCallOptions>>().Value;
var calls = fixture.Services.GetRequiredService<CallSignalingService>();
var delivery = fixture.Services.GetRequiredService<RecordingMessageDelivery>();
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<HubException>(() => calls.GetSessionAsync(call.Id, id, "preference-caller"));
}
finally
{
await calls.EndForUserAsync(id, "test-cleanup");
options.Enabled = false;
}
}
}

View file

@ -84,6 +84,9 @@ public sealed class ApiWebServerFixture : WebHostFixture
builder.Services.AddTransient<ICalendarManager, CalendarManager>();
builder.Services.AddSingleton<IHubContext<ChatHub>>(sp => sp.GetRequiredService<RecordingMessageDelivery>());
builder.Services.AddSingleton<IConnexionManager, HubConnectionManager>();
builder.Services.Configure<Yavsc.Settings.AudioVideoCallOptions>(_ => { });
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CallSignalingService>();
builder.Services.AddTransient<IYavscMessageSender, YavscMessageSender>();
builder.Services.AddAuthorization(options =>
{

View file

@ -16,8 +16,8 @@ public sealed class RecordingMessageDelivery : ITrueEmailSender, IHubContext<Cha
IGroupManager IHubContext<ChatHub>.Groups => throw new NotSupportedException();
public IClientProxy All => throw new NotSupportedException();
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
public IClientProxy Client(string connectionId) => new RecordingClient(this, connectionId);
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => throw new NotSupportedException();
public IClientProxy Client(string connectionId) => new RecordingClient(this, [connectionId]);
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => new RecordingClient(this, connectionIds);
public IClientProxy Group(string groupName) => throw new NotSupportedException();
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => throw new NotSupportedException();
public IClientProxy Groups(IReadOnlyList<string> groupNames) => throw new NotSupportedException();
@ -36,11 +36,12 @@ public sealed class RecordingMessageDelivery : ITrueEmailSender, IHubContext<Cha
Pushes.Clear();
}
private sealed class RecordingClient(RecordingMessageDelivery delivery, string connectionId) : IClientProxy
private sealed class RecordingClient(RecordingMessageDelivery delivery, IReadOnlyList<string> 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;
}
}

View file

@ -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<ApplicationDbContext>(o => o.UseInMemoryDatabase(Guid.NewGuid().ToString()))
.BuildServiceProvider();
var manager = new HubConnectionManager(services.GetRequiredService<IServiceScopeFactory>());
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 _);
}
}
}

View file

@ -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<ApplicationDbContext>()
.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<CallSignalingService>.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<ILoggerFactory>(),
_services.GetRequiredService<Microsoft.Extensions.Localization.IStringLocalizerFactory>(),
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<HubException>(() => hub.InviteCall("General", "bob"));
f.Connections.Present.Add("bob");
using var spoof = f.Hub("bob", "alice-1");
await Assert.ThrowsAsync<HubException>(() => spoof.InviteCall("General", "alice"));
await Assert.ThrowsAsync<HubException>(() => 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<HubException>(() => 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<CallAccepted>(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<HubException>(() => 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<HubException>(() => 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<HubException>(() => 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<CallEnded>(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<object, object?> Items { get; } = new Dictionary<object, object?>();
public override IFeatureCollection Features { get; } = new FeatureCollection();
public override CancellationToken ConnectionAborted => CancellationToken.None;
public override void Abort() { }
}
private sealed class Connections : IConnexionManager
{
public HashSet<string> Present { get; } = ["alice", "bob"];
private readonly Dictionary<string, string> _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<string> 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<string, string> 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<ChannelShortInfo> ListChannels(string pattern) => throw new NotSupportedException();
}
}

View file

@ -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<CallSignalingService>.Instance);
}
public Task<CallSession> 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<HubException>(() => 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<CallSession>(Assert.Single(message.Arguments));
await Assert.ThrowsAsync<HubException>(() =>
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<HubException>(() => f.Service.AcceptAsync(call.Id, "bob", "bob-2", false));
await Assert.ThrowsAsync<HubException>(() => f.Service.SignalAsync(call.Id, "bob", "bob-2", Candidate()));
await Assert.ThrowsAsync<HubException>(() => 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<CallEnded>(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<CallAccepted>(
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<HubException>(() =>
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<HubException>(() => 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<CallAccepted>(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<HubException>(() => 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<IOException>(() => f.Invite());
f.Hub.FailMethod = null;
var call = await f.Invite();
f.Hub.FailMethod = CallHubMethods.Accepted;
await Assert.ThrowsAsync<IOException>(() => 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<HubException>(() => f.Service.AcceptAsync(call.Id, "mallory", "bob-1", false));
await Assert.ThrowsAsync<HubException>(() => f.Service.AcceptAsync(call.Id, "bob", "uninvited", false));
await f.Service.AcceptAsync(call.Id, "bob", "bob-1", false);
await Assert.ThrowsAsync<HubException>(() => f.Service.SignalAsync(call.Id, "bob", "bob-1", new("offer", Sdp)));
await Assert.ThrowsAsync<HubException>(() => f.Service.SignalAsync(call.Id, "bob", "bob-1", new("answer", Sdp)));
await Assert.ThrowsAsync<HubException>(() => f.Service.SignalAsync(call.Id, "alice", "other-device", new("offer", Sdp)));
await Assert.ThrowsAsync<HubException>(() => 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<HubException>(() => f.Service.SignalAsync(call.Id, "alice", "alice-device", new("offer", Sdp)));
await Assert.ThrowsAsync<HubException>(() => 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<HubException>(() => f.Invite());
await Assert.ThrowsAsync<HubException>(() =>
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<CallEnded>(f.Hub.Messages.Last().Arguments[0]).Reason);
await Assert.ThrowsAsync<HubException>(() => 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<CallEnded>(f.Hub.Messages.Last().Arguments[0]).Reason);
await Assert.ThrowsAsync<HubException>(() =>
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<HubException>(() => f.Service.EndAsync(call.Id, "mallory", "mallory-1"));
await f.Service.EndAsync(call.Id, "bob", "bob-2");
Assert.Equal("cancelled-or-declined", Assert.IsType<CallEnded>(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<CallEnded>(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<CallEnded>(f.Hub.Messages.Last().Arguments[0]).Reason);
await f.Invite();
await f.Service.EndForUserAsync("alice", "profile-changed");
Assert.Equal("profile-changed", Assert.IsType<CallEnded>(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<HubException>(() => 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<HubException>(() =>
CallSignalValidator.Validate(new("candidate", Candidate: candidate, SdpMid: "0", SdpMLineIndex: 0), true));
Assert.Throws<HubException>(() =>
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<HubException>(() => 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<HubException>(() => CallSignalValidator.Validate(signal, false));
}
}
internal sealed class RecordingCallHub : IHubContext<ChatHub>, IHubClients
{
internal sealed record Message(string[] Targets, string Method, object?[] Arguments);
public List<Message> Messages { get; } = [];
public string? FailMethod { get; set; }
IHubClients IHubContext<ChatHub>.Clients => this;
IGroupManager IHubContext<ChatHub>.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<string> ids) => new Proxy(this, ids.ToArray());
public IClientProxy All => throw new NotSupportedException();
public IClientProxy AllExcept(IReadOnlyList<string> ids) => throw new NotSupportedException();
public IClientProxy Group(string name) => throw new NotSupportedException();
public IClientProxy GroupExcept(string name, IReadOnlyList<string> ids) => throw new NotSupportedException();
public IClientProxy Groups(IReadOnlyList<string> names) => throw new NotSupportedException();
public IClientProxy User(string id) => throw new NotSupportedException();
public IClientProxy Users(IReadOnlyList<string> ids) => throw new NotSupportedException();
}