feat(calls): implement Linux LAN peer calls over SRTP

Reuse existing ChatHub consent and Podcast LibVLC capture for direct bidirectional H.264/AAC media, with embedded call controls and cleanup. Require HTTPS key exchange and reject IP-masking policies for this opt-in LAN/VPN transport.

Add native SRTP, signaling and lifecycle tests; document setup and remaining physical-device validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-26 03:05:23 +00:00
commit cc28ee8b29
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
20 changed files with 1394 additions and 29 deletions

View file

@ -12,6 +12,8 @@ public static class CallSignalValidator
{
if (signal is null)
throw new HubException("A call signal is required.");
if (signal.Endpoint != null)
throw new HubException("WebRTC signaling cannot carry a LAN endpoint.");
if (signal.Kind is "offer" or "answer")
{
if (string.IsNullOrWhiteSpace(signal.Sdp) || Encoding.UTF8.GetByteCount(signal.Sdp) > 24000 ||

View file

@ -27,6 +27,7 @@ public sealed class CallSignalingService(
public bool Offered { get; set; }
public bool Answered { get; set; }
public int CandidateCount { get; set; }
public HashSet<string> EndpointConnections { get; } = [];
public string[] Connections => [CallerConnection, .. CalleeConnections];
}
@ -36,13 +37,13 @@ public sealed class CallSignalingService(
public async Task<CallSession> InviteAsync(
string room, string caller, string callee, string callerConnection,
string[] calleeConnections, bool relayOnly)
string[] calleeConnections, bool relayOnly, string transport = CallTransports.WebRtc)
{
EnsureEnabled();
if (caller == callee || calleeConnections.Length == 0 ||
calleeConnections.Contains(callerConnection))
throw new HubException("Call participants are unavailable.");
ValidateIceConfiguration(relayOnly);
ValidateTransport(transport, relayOnly);
await _gate.WaitAsync();
try
{
@ -52,7 +53,7 @@ public sealed class CallSignalingService(
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));
clock.GetUtcNow().AddSeconds(45), transport);
var call = new PendingCall(session, callerConnection, calleeConnections);
_calls.Add(session.Id, call);
try
@ -83,7 +84,7 @@ public sealed class CallSignalingService(
if (call.AcceptedConnection != null)
throw new HubException("This call was already accepted.");
var requireRelay = call.Session.RelayOnly || relayOnly;
ValidateIceConfiguration(requireRelay);
ValidateTransport(call.Session.Transport, requireRelay);
call.AcceptedConnection = connection;
call.Session = call.Session with
{
@ -92,7 +93,8 @@ public sealed class CallSignalingService(
};
var otherDevices = call.CalleeConnections.Where(c => c != connection).ToArray();
call.CalleeConnections.IntersectWith([connection]);
var accepted = new CallAccepted(call.Session, CreateIceServers(call.Session));
var accepted = new CallAccepted(call.Session, call.Session.Transport == CallTransports.LanSrtp
? [] : CreateIceServers(call.Session));
try
{
if (otherDevices.Length > 0)
@ -122,6 +124,18 @@ public sealed class CallSignalingService(
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.");
if (call.Session.Transport == CallTransports.LanSrtp)
{
ValidateTransport(call.Session.Transport, call.Session.RelayOnly);
if (signal is null || signal.Kind != "rtp-endpoint" || signal.Endpoint?.IsValid() != true ||
signal.Sdp != null || signal.Candidate != null || signal.SdpMid != null ||
signal.SdpMLineIndex != null || call.EndpointConnections.Contains(connection))
throw new HubException("A LAN call requires one valid SRTP endpoint per accepted device.");
var endpointTarget = caller ? call.AcceptedConnection : call.CallerConnection;
await hub.Clients.Client(endpointTarget).SendAsync(CallHubMethods.SignalReceived, id, signal);
call.EndpointConnections.Add(connection);
return;
}
CallSignalValidator.Validate(signal, call.Session.RelayOnly);
switch (signal.Kind)
{
@ -252,6 +266,19 @@ public sealed class CallSignalingService(
throw new HubException("ICE/TURN configuration is unavailable. No direct fallback is allowed.");
}
private void ValidateTransport(string transport, bool relayOnly)
{
if (transport == CallTransports.LanSrtp)
{
if (!_options.EnableLanCalls || relayOnly)
throw new HubException("LAN calls must be enabled and cannot hide participant IP addresses.");
}
else if (transport == CallTransports.WebRtc)
ValidateIceConfiguration(relayOnly);
else
throw new HubException("Unsupported call transport.");
}
private static bool ValidUrl(string url, params string[] schemes)
{
if (string.IsNullOrWhiteSpace(url) || url.Length > 512 || url.Any(char.IsWhiteSpace))