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:
parent
56ca115f50
commit
5e48831d6d
20 changed files with 1368 additions and 16 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
54
src/PostIt/PostIt.Tests/CallSignalingClientTests.cs
Normal file
54
src/PostIt/PostIt.Tests/CallSignalingClientTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
81
src/PostIt/PostIt/Services/CallSignalingClient.cs
Normal file
81
src/PostIt/PostIt/Services/CallSignalingClient.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
{
|
||||
|
|
|
|||
29
src/Yavsc.Abstract/Chat/Calls/CallContracts.cs
Normal file
29
src/Yavsc.Abstract/Chat/Calls/CallContracts.cs
Normal 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);
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
76
src/Yavsc.Server/Hubs/ChatHub.Calls.cs
Normal file
76
src/Yavsc.Server/Hubs/ChatHub.Calls.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
77
src/Yavsc.Server/Services/CallSignalValidator.cs
Normal file
77
src/Yavsc.Server/Services/CallSignalValidator.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
285
src/Yavsc.Server/Services/CallSignalingService.cs
Normal file
285
src/Yavsc.Server/Services/CallSignalingService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
13
src/Yavsc.Server/Settings/AudioVideoCallOptions.cs
Normal file
13
src/Yavsc.Server/Settings/AudioVideoCallOptions.cs
Normal 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; }
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
39
src/test/Yavsc.Server.Tests/CallConnectionPresenceTests.cs
Normal file
39
src/test/Yavsc.Server.Tests/CallConnectionPresenceTests.cs
Normal 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 _);
|
||||
}
|
||||
}
|
||||
}
|
||||
162
src/test/Yavsc.Server.Tests/CallHubTests.cs
Normal file
162
src/test/Yavsc.Server.Tests/CallHubTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
313
src/test/Yavsc.Server.Tests/CallSignalingTests.cs
Normal file
313
src/test/Yavsc.Server.Tests/CallSignalingTests.cs
Normal 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();
|
||||
}
|
||||
Loading…
Reference in a new issue