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

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