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

@ -15,6 +15,7 @@ public sealed class CallSignalingClientTests
var id = Guid.NewGuid();
var ct = TestContext.Current.CancellationToken;
await Assert.ThrowsAsync<InvalidOperationException>(() => client.InviteAsync("General", "bob", ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.InviteLanAsync("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));
@ -50,5 +51,10 @@ public sealed class CallSignalingClientTests
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));
var lan = call with { RelayOnly = false, Transport = CallTransports.LanSrtp };
Assert.Equal(lan, JsonSerializer.Deserialize<CallSession>(JsonSerializer.Serialize(lan, options), options));
var endpoint = new CallSignal("rtp-endpoint",
Endpoint: new("192.168.1.2", 5004, new string('a', 32), new string('b', 28)));
Assert.Equal(endpoint, JsonSerializer.Deserialize<CallSignal>(JsonSerializer.Serialize(endpoint, options), options));
}
}

View file

@ -0,0 +1,192 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using PostIt.Services;
using PostIt.ViewModels.Chat;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Tests;
public sealed class LanCallViewModelTests
{
private static CallSession Session() => new(Guid.NewGuid(), "General", "alice", "bob", false,
DateTimeOffset.UtcNow.AddMinutes(1), CallTransports.LanSrtp);
private static LanCallViewModel Configure(Signaling signaling, Func<string, int, ILanCallMedia> create)
{
var vm = new LanCallViewModel(signaling, create)
{
Address = "127.0.0.1", Camera = new("Camera", "/dev/video0"),
Microphone = new("Micro", "default"), AllowAddressDisclosure = true,
Room = "General", Target = "bob"
};
vm.Addresses.Add("127.0.0.1");
return vm;
}
[AvaloniaFact]
public async Task No_capture_starts_before_explicit_acceptance_and_endpoint_exchange()
{
var signaling = new Signaling();
var media = new FakeMedia();
var created = 0;
await using var vm = Configure(signaling, (_, _) => { created++; return media; });
signaling.Invite(vm, signaling.Call);
await WaitAsync(() => vm.Incoming);
Assert.Equal(0, created);
Assert.Empty(signaling.Sent);
await vm.AcceptCommand.ExecuteAsync(null);
await WaitAsync(() => vm.LocalVideo != null);
Assert.Equal(1, created);
Assert.False(media.Sending);
Assert.Single(signaling.Sent);
Assert.Equal("rtp-endpoint", signaling.Sent[0].Kind);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
Assert.Same(media.LocalVideo, vm.LocalVideo);
Assert.Same(media.RemoteVideo, vm.RemoteVideo);
await vm.HangupAsync();
Assert.True(media.Disposed);
Assert.False(vm.Busy);
Assert.Null(vm.LocalVideo);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
}
[AvaloniaFact]
public async Task Caller_waits_for_consent_and_interruption_releases_media_without_reconnecting()
{
var signaling = new Signaling();
var media = new FakeMedia();
var created = 0;
await using var vm = Configure(signaling, (_, _) => { created++; return media; });
await vm.InviteCommand.ExecuteAsync(null);
Assert.True(vm.Busy);
Assert.Equal(0, created);
signaling.Ready();
await WaitAsync(() => vm.LocalVideo != null);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
signaling.Disconnect();
await WaitAsync(() => media.Disposed);
Assert.False(vm.Busy);
Assert.Null(vm.RemoteVideo);
Assert.Contains("interrompue", vm.Status);
}
[AvaloniaFact]
public async Task Missing_disclosure_consent_or_devices_blocks_the_invitation()
{
var signaling = new Signaling();
await using var vm = Configure(signaling, (_, _) => throw new Exception("Must not capture"));
vm.AllowAddressDisclosure = false;
await vm.InviteCommand.ExecuteAsync(null);
Assert.Equal(0, signaling.Invites);
Assert.Contains("Confirmez", vm.Status);
vm.AllowAddressDisclosure = true;
vm.Camera = null;
await vm.InviteCommand.ExecuteAsync(null);
Assert.Equal(0, signaling.Invites);
Assert.Contains("camera", vm.Status);
Assert.False(vm.Busy);
}
[AvaloniaFact]
public async Task Refusal_never_creates_media_and_late_signals_cannot_restart_capture()
{
var signaling = new Signaling();
var created = 0;
await using var vm = Configure(signaling, (_, _) => { created++; return new FakeMedia(); });
signaling.Invite(vm, signaling.Call);
await WaitAsync(() => vm.Incoming);
await vm.HangupAsync();
signaling.Ready();
signaling.RemoteEndpoint(signaling.Call.Id);
await Task.Delay(30, TestContext.Current.CancellationToken);
Assert.Equal(0, created);
Assert.False(vm.Busy);
}
[AvaloniaFact]
public async Task Hangup_cancels_pending_native_start_without_waiting_for_startup_timeout()
{
var signaling = new Signaling();
var media = new FakeMedia { DelayStartup = true };
await using var vm = Configure(signaling, (_, _) => media);
await vm.InviteCommand.ExecuteAsync(null);
signaling.Ready();
await WaitAsync(() => vm.LocalVideo != null);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
await vm.HangupAsync().WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
Assert.True(media.Disposed);
Assert.False(vm.Busy);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
}
[AvaloniaFact]
public async Task Media_failure_ends_the_call_and_disposes_capture()
{
var signaling = new Signaling();
var media = new FakeMedia();
await using var vm = Configure(signaling, (_, _) => media);
await vm.InviteCommand.ExecuteAsync(null);
signaling.Ready();
await WaitAsync(() => vm.LocalVideo != null);
media.Fail();
await WaitAsync(() => media.Disposed);
Assert.False(vm.Busy);
Assert.Contains("test media failure", vm.Status);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
}
private static async Task WaitAsync(Func<bool> condition)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
while (!condition()) await Task.Delay(10, timeout.Token);
}
private sealed class FakeMedia : ILanCallMedia
{
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Control LocalVideo { get; } = new Border();
public Control RemoteVideo { get; } = new Border();
public LanCallEndpoint Endpoint { get; } = new("127.0.0.1", 5004, new string('a', 32), new string('b', 28));
public Task Completion => _completion.Task;
public bool HasRemoteMedia => false;
public bool Sending { get; private set; }
public bool Disposed { get; private set; }
public bool DelayStartup { get; init; }
public async Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct)
{
Sending = true;
if (DelayStartup) await Task.Delay(Timeout.Infinite, ct);
}
public void Fail() => _completion.TrySetException(new IOException("test media failure"));
public ValueTask DisposeAsync() { Disposed = true; _completion.TrySetResult(); return ValueTask.CompletedTask; }
}
private sealed class Signaling : ILanCallSignaling
{
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 CallSession Call { get; } = Session();
public List<CallSignal> Sent { get; } = [];
public List<Guid> EndedCalls { get; } = [];
public int Invites { get; private set; }
public Task<CallSession> InviteLanAsync(string room, string user, CancellationToken ct = default)
{ Invites++; return Task.FromResult(Call); }
public Task AcceptAsync(Guid id, CancellationToken ct = default) { Ready(); return Task.CompletedTask; }
public Task EndAsync(Guid id, CancellationToken ct = default)
{ EndedCalls.Add(id); Ended?.Invoke(new(id, "hangup")); return Task.CompletedTask; }
public Task SendAsync(Guid id, CallSignal signal, CancellationToken ct = default)
{ Sent.Add(signal); return Task.CompletedTask; }
public void Invite(LanCallViewModel _, CallSession call) => Invitation?.Invoke(call);
public void Ready() => Accepted?.Invoke(new(Call, []));
public void RemoteEndpoint(Guid id) => Signal?.Invoke(id, new("rtp-endpoint",
Endpoint: new("127.0.0.1", 5006, new string('c', 32), new string('d', 28))));
public void Disconnect() => Interrupted?.Invoke();
}
}

View file

@ -0,0 +1,113 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using LibVLCSharp.Shared;
using PostIt.Services;
namespace PostIt.Tests;
public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
{
public static bool HasFixture => OperatingSystem.IsLinux() &&
File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA"));
[Theory(Skip = "Set POSTIT_TEST_MEDIA to an audio/video file; requires Linux LibVLC SRTP and H.264/AAC encoders.",
SkipUnless = nameof(HasFixture))]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task Two_peers_decode_bidirectional_encrypted_audio_video_and_reject_wrong_keys(bool wrongKey, bool preview)
{
var ct = TestContext.Current.CancellationToken;
var firstPort = FreePort();
var secondPort = FreePort(firstPort);
await using var first = new LinuxLanCallMedia("127.0.0.1", firstPort, headless: true);
await using var second = new LinuxLanCallMedia("127.0.0.1", secondPort, headless: true);
using var firstVideo = new DecodedVideo(first.Receiver);
using var secondVideo = new DecodedVideo(second.Receiver);
await first.StartReceivingAsync(ct);
await second.StartReceivingAsync(ct);
await Task.Delay(300, ct);
Media Source(LibVLC vlc)
{
var media = new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!)));
media.AddOption(":input-repeat=5");
return media;
}
await first.StartSendingAsync(wrongKey ? second.Endpoint with { Key = new string('0', 32) } : second.Endpoint,
Source, preview, ct);
await second.StartSendingAsync(first.Endpoint, Source, preview, ct);
var elapsed = Stopwatch.StartNew();
while (elapsed.Elapsed < TimeSpan.FromSeconds(wrongKey ? 8 : 20))
{
if (first.Completion.IsCompleted) await first.Completion;
if (second.Completion.IsCompleted) await second.Completion;
if (!wrongKey && HasMedia(first, firstVideo) && HasMedia(second, secondVideo) &&
first.HasRemoteMedia && second.HasRemoteMedia) break;
await Task.Delay(100, ct);
}
var a = first.Statistics;
var b = second.Statistics;
output.WriteLine($"A: audioSamples={firstVideo.AudioSamples}, rendered={firstVideo.Frames}; " +
$"B: audioSamples={secondVideo.AudioSamples}, rendered={secondVideo.Frames}; elapsed={elapsed.ElapsedMilliseconds}ms");
Assert.True(HasMedia(first, firstVideo), "First peer must decode received audio and video.");
Assert.True(first.HasRemoteMedia, "The UI must detect initial audio/video reception.");
if (wrongKey)
{
Assert.Equal(0, b.DecodedVideo);
Assert.Equal(0, b.DecodedAudio);
Assert.Equal(0, secondVideo.Frames);
Assert.Equal(0, secondVideo.AudioSamples);
Assert.False(second.HasRemoteMedia);
}
else
{
Assert.True(HasMedia(second, secondVideo), "Second peer must decode received audio and video.");
Assert.True(second.HasRemoteMedia);
}
}
private static bool HasMedia(LinuxLanCallMedia peer, DecodedVideo video) =>
peer.Statistics.DecodedAudio > 0 && video.AudioSamples > 4800 && video.Frames >= 10;
private static int FreePort(int except = 0)
{
for (var port = 32000; port < 60000; port += 2)
{
if (port == except) continue;
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
try { socket.Bind(new IPEndPoint(IPAddress.Loopback, port)); return port; }
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) { }
}
throw new IOException("No free RTP port available.");
}
private sealed class DecodedVideo : IDisposable
{
private readonly IntPtr _pixels = Marshal.AllocHGlobal(640 * 480 * 4);
private int _frames;
private long _audioSamples;
public int Frames => Volatile.Read(ref _frames);
public long AudioSamples => Interlocked.Read(ref _audioSamples);
private readonly MediaPlayer _player;
public DecodedVideo(MediaPlayer player)
{
_player = player;
player.SetAudioFormat("S16N", 48000, 1);
player.SetAudioCallbacks((_, _, count, _) => Interlocked.Add(ref _audioSamples, count),
null, null, null, null);
player.SetVideoFormat("RV32", 640, 480, 640 * 4);
player.SetVideoCallbacks((_, planes) =>
{
Marshal.WriteIntPtr(planes, _pixels);
return IntPtr.Zero;
}, null, (_, _) => Interlocked.Increment(ref _frames));
}
public void Dispose()
{
_player.Stop();
Marshal.FreeHGlobal(_pixels);
}
}
}