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();
}
}
}
}