using System.Buffers; using System.Collections.Concurrent; using System.Net.WebSockets; using System.Text; using System.Text.RegularExpressions; using Microsoft.Extensions.Options; namespace Blog.Services; public sealed class SendOptions { public const string Section = "Send"; /// /// The ICE servers the page hands to RTCPeerConnection, so two browsers behind NAT can /// find a route to each other. STUN only: it tells a peer what its public address looks like /// and then steps aside. A TURN server would relay the bytes, which is the one thing /// this page is for not doing, so there is deliberately none — a pair of symmetric NATs with /// no route between them fails instead of quietly going through a server. /// public string[] IceServers { get; set; } = ["stun:stun.l.google.com:19302"]; /// How many rooms may exist at once, before a new code is turned away. public int MaxRooms { get; set; } = 500; /// /// The largest signalling message that will be relayed. An SDP offer carrying a handful of /// candidates is a few kilobytes; anything near this is not a handshake. /// public int MaxMessageBytes { get; set; } = 64 * 1024; /// /// How long a signalling socket is kept open. Closing it does not interrupt a transfer: once /// the peer connection is up the two browsers talk to each other directly, and this socket has /// nothing left to carry. /// public TimeSpan RoomLifetime { get; set; } = TimeSpan.FromMinutes(30); } /// /// The introduction service behind /Send: it carries the WebRTC handshake between the two browsers /// in a room, and carries nothing else. The file goes peer to peer over a data channel that this /// process is not part of and cannot see. /// /// /// /// The server never mints a room code and never stores one. The page generates twelve Crockford /// base32 characters — sixty bits out of the browser's CSPRNG — and a room springs into existence /// when the first socket asks for it and disappears when the last one leaves. So there is no /// registry to expire, no state to persist, and nothing to clean up on a restart beyond dropping /// the sockets. /// /// /// A room holds at most two peers and a third is refused, so nobody can slip into a pair that has /// already formed. What this does not give you is protection from the server itself: it /// relays the DTLS fingerprints the two peers authenticate each other with, so whoever runs this /// process could stand in the middle if they rewrote them. The page says so. /// /// /// Messages are relayed verbatim as text. Nothing here parses SDP — the server has no opinion /// about what the two ends agree on, which is also why a browser can change its mind about codecs /// or restart ICE without this code learning a new message type. /// /// public sealed partial class SignalingService(IOptions options, ILogger logger) { private readonly SendOptions _options = options.Value; private readonly ConcurrentDictionary _rooms = new(StringComparer.Ordinal); /// /// The shape the page generates: Crockford base32, which drops I, L, O and U so a code read /// out loud survives the trip. Codes are checked rather than parsed — the server only needs /// them to be a bounded, boring dictionary key. /// [GeneratedRegex("^[0-9A-HJKMNP-TV-Z]{8,32}$")] private static partial Regex RoomCode { get; } public static bool IsRoomCode(string code) => RoomCode.IsMatch(code); /// /// Runs one peer's signalling socket to completion: join the room, relay until it goes away. /// public async Task RelayAsync(string code, WebSocket socket, CancellationToken cancellationToken) { using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); lifetime.CancelAfter(_options.RoomLifetime); var token = lifetime.Token; var peer = new Peer(socket); if (Join(code, peer) is not { } room) { await peer.SendAsync("""{"type":"busy"}""", token).ConfigureAwait(false); await CloseAsync(socket, WebSocketCloseStatus.PolicyViolation, "Room full", token).ConfigureAwait(false); return; } try { await AnnounceAsync(room, token).ConfigureAwait(false); await PumpAsync(room, peer, token).ConfigureAwait(false); } catch (OperationCanceledException) { // Either the request was aborted or the room hit its lifetime. Both are ordinary ends. } catch (WebSocketException exception) { logger.LogDebug(exception, "Send socket ended abruptly"); } finally { await LeaveAsync(code, room, peer).ConfigureAwait(false); } } /// /// Puts the peer in the room named by the code, creating the room if it is the first one there. /// Null when the room already has its two peers, or the server is holding as many rooms as it /// is willing to. /// private Room? Join(string code, Peer peer) { while (true) { if (!_rooms.TryGetValue(code, out var room)) { if (_rooms.Count >= _options.MaxRooms) return null; room = _rooms.GetOrAdd(code, _ => new Room()); } lock (room.Gate) { // The last peer left between the lookup and the lock, taking the room out of the // dictionary with it. Whatever is under that key now is what we want. if (room.Removed) continue; if (room.Peers.Count >= 2) return null; room.Peers.Add(peer); return room; } } } /// /// Tells everyone in the room where they stand. Alone, you wait; in a pair, the one who just /// arrived makes the offer, because it is the only one that knows both ends are present. /// private static async Task AnnounceAsync(Room room, CancellationToken cancellationToken) { Peer[] peers; lock (room.Gate) peers = [.. room.Peers]; if (peers.Length == 1) { await peers[0].SendAsync("""{"type":"waiting"}""", cancellationToken).ConfigureAwait(false); return; } for (var i = 0; i < peers.Length; i++) { var initiator = i == peers.Length - 1 ? "true" : "false"; await peers[i].SendAsync($$"""{"type":"ready","initiator":{{initiator}}}""", cancellationToken).ConfigureAwait(false); } } /// Reads this peer's messages and hands each one to the other peer, unread. private async Task PumpAsync(Room room, Peer peer, CancellationToken cancellationToken) { var buffer = ArrayPool.Shared.Rent(8 * 1024); var message = new ArrayBufferWriter(8 * 1024); try { while (peer.Socket.State == WebSocketState.Open) { var result = await peer.Socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); if (result.MessageType == WebSocketMessageType.Close) return; if (result.MessageType != WebSocketMessageType.Text) { await CloseAsync(peer.Socket, WebSocketCloseStatus.InvalidMessageType, "Text only", cancellationToken).ConfigureAwait(false); return; } message.Write(buffer.AsSpan(0, result.Count)); if (message.WrittenCount > _options.MaxMessageBytes) { await CloseAsync(peer.Socket, WebSocketCloseStatus.MessageTooBig, "Signalling message too large", cancellationToken).ConfigureAwait(false); return; } if (!result.EndOfMessage) continue; var text = Encoding.UTF8.GetString(message.WrittenSpan); message.ResetWrittenCount(); Peer[] others; lock (room.Gate) others = [.. room.Peers.Where(other => other != peer)]; foreach (var other in others) { await other.SendAsync(text, cancellationToken).ConfigureAwait(false); } } } finally { ArrayPool.Shared.Return(buffer); } } /// /// Takes the peer out of the room, tells whoever is left, and drops the room once it is empty. /// private async Task LeaveAsync(string code, Room room, Peer peer) { Peer[] remaining; lock (room.Gate) { room.Peers.Remove(peer); remaining = [.. room.Peers]; // Marked before the removal, so a peer that is mid-Join on this room sees it and takes // the room that replaces it instead of joining one nobody can reach. if (remaining.Length == 0) room.Removed = true; } if (remaining.Length == 0) { _rooms.TryRemove(new KeyValuePair(code, room)); return; } foreach (var other in remaining) { await other.SendAsync("""{"type":"peer-left"}""", CancellationToken.None).ConfigureAwait(false); } } private static async Task CloseAsync(WebSocket socket, WebSocketCloseStatus status, string reason, CancellationToken cancellationToken) { if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)) return; try { await socket.CloseAsync(status, reason, cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is WebSocketException or OperationCanceledException or ObjectDisposedException) { // The other end is already gone; there is nobody to say goodbye to. } } private sealed class Room { public readonly Lock Gate = new(); /// Never more than two, so a list beats anything cleverer. public readonly List Peers = []; /// Set under once this room is on its way out of the dictionary. public bool Removed; } private sealed class Peer(WebSocket socket) { /// One write at a time: two interleaved sends would corrupt the frame. private readonly SemaphoreSlim _sending = new(1, 1); public WebSocket Socket { get; } = socket; public async Task SendAsync(string message, CancellationToken cancellationToken) { await _sending.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (Socket.State != WebSocketState.Open) return; await Socket.SendAsync( Encoding.UTF8.GetBytes(message), WebSocketMessageType.Text, endOfMessage: true, cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is WebSocketException or OperationCanceledException or ObjectDisposedException) { // A peer that has gone away is not this peer's problem: its own pump will notice. } finally { _sending.Release(); } } } }