Blog/Services/SignalingService.cs 11.6 K · 300 lines · raw · history

1 using System.Buffers;
2 using System.Collections.Concurrent;
3 using System.Net.WebSockets;
4 using System.Text;
5 using System.Text.RegularExpressions;
6 using Microsoft.Extensions.Options;
7
8 namespace Blog.Services;
9
10 public sealed class SendOptions
11 {
12 public const string Section = "Send";
13
14 /// <summary>
15 /// The ICE servers the page hands to <c>RTCPeerConnection</c>, so two browsers behind NAT can
16 /// find a route to each other. STUN only: it tells a peer what its public address looks like
17 /// and then steps aside. A TURN server would <em>relay</em> the bytes, which is the one thing
18 /// this page is for not doing, so there is deliberately none — a pair of symmetric NATs with
19 /// no route between them fails instead of quietly going through a server.
20 /// </summary>
21 public string[] IceServers { get; set; } = ["stun:stun.l.google.com:19302"];
22
23 /// <summary>How many rooms may exist at once, before a new code is turned away.</summary>
24 public int MaxRooms { get; set; } = 500;
25
26 /// <summary>
27 /// The largest signalling message that will be relayed. An SDP offer carrying a handful of
28 /// candidates is a few kilobytes; anything near this is not a handshake.
29 /// </summary>
30 public int MaxMessageBytes { get; set; } = 64 * 1024;
31
32 /// <summary>
33 /// How long a signalling socket is kept open. Closing it does not interrupt a transfer: once
34 /// the peer connection is up the two browsers talk to each other directly, and this socket has
35 /// nothing left to carry.
36 /// </summary>
37 public TimeSpan RoomLifetime { get; set; } = TimeSpan.FromMinutes(30);
38 }
39
40 /// <summary>
41 /// The introduction service behind /Send: it carries the WebRTC handshake between the two browsers
42 /// in a room, and carries nothing else. The file goes peer to peer over a data channel that this
43 /// process is not part of and cannot see.
44 /// </summary>
45 /// <remarks>
46 /// <para>
47 /// The server never mints a room code and never stores one. The page generates twelve Crockford
48 /// base32 characters — sixty bits out of the browser's CSPRNG — and a room springs into existence
49 /// when the first socket asks for it and disappears when the last one leaves. So there is no
50 /// registry to expire, no state to persist, and nothing to clean up on a restart beyond dropping
51 /// the sockets.
52 /// </para>
53 /// <para>
54 /// A room holds at most two peers and a third is refused, so nobody can slip into a pair that has
55 /// already formed. What this does <em>not</em> give you is protection from the server itself: it
56 /// relays the DTLS fingerprints the two peers authenticate each other with, so whoever runs this
57 /// process could stand in the middle if they rewrote them. The page says so.
58 /// </para>
59 /// <para>
60 /// Messages are relayed verbatim as text. Nothing here parses SDP — the server has no opinion
61 /// about what the two ends agree on, which is also why a browser can change its mind about codecs
62 /// or restart ICE without this code learning a new message type.
63 /// </para>
64 /// </remarks>
65 public sealed partial class SignalingService(IOptions<SendOptions> options, ILogger<SignalingService> logger)
66 {
67 private readonly SendOptions _options = options.Value;
68 private readonly ConcurrentDictionary<string, Room> _rooms = new(StringComparer.Ordinal);
69
70 /// <summary>
71 /// The shape the page generates: Crockford base32, which drops I, L, O and U so a code read
72 /// out loud survives the trip. Codes are checked rather than parsed — the server only needs
73 /// them to be a bounded, boring dictionary key.
74 /// </summary>
75 [GeneratedRegex("^[0-9A-HJKMNP-TV-Z]{8,32}$")]
76 private static partial Regex RoomCode { get; }
77
78 public static bool IsRoomCode(string code) => RoomCode.IsMatch(code);
79
80 /// <summary>
81 /// Runs one peer's signalling socket to completion: join the room, relay until it goes away.
82 /// </summary>
83 public async Task RelayAsync(string code, WebSocket socket, CancellationToken cancellationToken)
84 {
85 using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
86 lifetime.CancelAfter(_options.RoomLifetime);
87 var token = lifetime.Token;
88
89 var peer = new Peer(socket);
90
91 if (Join(code, peer) is not { } room)
92 {
93 await peer.SendAsync("""{"type":"busy"}""", token).ConfigureAwait(false);
94 await CloseAsync(socket, WebSocketCloseStatus.PolicyViolation, "Room full", token).ConfigureAwait(false);
95 return;
96 }
97
98 try
99 {
100 await AnnounceAsync(room, token).ConfigureAwait(false);
101 await PumpAsync(room, peer, token).ConfigureAwait(false);
102 }
103 catch (OperationCanceledException)
104 {
105 // Either the request was aborted or the room hit its lifetime. Both are ordinary ends.
106 }
107 catch (WebSocketException exception)
108 {
109 logger.LogDebug(exception, "Send socket ended abruptly");
110 }
111 finally
112 {
113 await LeaveAsync(code, room, peer).ConfigureAwait(false);
114 }
115 }
116
117 /// <summary>
118 /// Puts the peer in the room named by the code, creating the room if it is the first one there.
119 /// Null when the room already has its two peers, or the server is holding as many rooms as it
120 /// is willing to.
121 /// </summary>
122 private Room? Join(string code, Peer peer)
123 {
124 while (true)
125 {
126 if (!_rooms.TryGetValue(code, out var room))
127 {
128 if (_rooms.Count >= _options.MaxRooms) return null;
129 room = _rooms.GetOrAdd(code, _ => new Room());
130 }
131
132 lock (room.Gate)
133 {
134 // The last peer left between the lookup and the lock, taking the room out of the
135 // dictionary with it. Whatever is under that key now is what we want.
136 if (room.Removed) continue;
137 if (room.Peers.Count >= 2) return null;
138
139 room.Peers.Add(peer);
140 return room;
141 }
142 }
143 }
144
145 /// <summary>
146 /// Tells everyone in the room where they stand. Alone, you wait; in a pair, the one who just
147 /// arrived makes the offer, because it is the only one that knows both ends are present.
148 /// </summary>
149 private static async Task AnnounceAsync(Room room, CancellationToken cancellationToken)
150 {
151 Peer[] peers;
152 lock (room.Gate) peers = [.. room.Peers];
153
154 if (peers.Length == 1)
155 {
156 await peers[0].SendAsync("""{"type":"waiting"}""", cancellationToken).ConfigureAwait(false);
157 return;
158 }
159
160 for (var i = 0; i < peers.Length; i++)
161 {
162 var initiator = i == peers.Length - 1 ? "true" : "false";
163 await peers[i].SendAsync($$"""{"type":"ready","initiator":{{initiator}}}""", cancellationToken).ConfigureAwait(false);
164 }
165 }
166
167 /// <summary>Reads this peer's messages and hands each one to the other peer, unread.</summary>
168 private async Task PumpAsync(Room room, Peer peer, CancellationToken cancellationToken)
169 {
170 var buffer = ArrayPool<byte>.Shared.Rent(8 * 1024);
171 var message = new ArrayBufferWriter<byte>(8 * 1024);
172
173 try
174 {
175 while (peer.Socket.State == WebSocketState.Open)
176 {
177 var result = await peer.Socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
178
179 if (result.MessageType == WebSocketMessageType.Close) return;
180
181 if (result.MessageType != WebSocketMessageType.Text)
182 {
183 await CloseAsync(peer.Socket, WebSocketCloseStatus.InvalidMessageType, "Text only", cancellationToken).ConfigureAwait(false);
184 return;
185 }
186
187 message.Write(buffer.AsSpan(0, result.Count));
188
189 if (message.WrittenCount > _options.MaxMessageBytes)
190 {
191 await CloseAsync(peer.Socket, WebSocketCloseStatus.MessageTooBig, "Signalling message too large", cancellationToken).ConfigureAwait(false);
192 return;
193 }
194
195 if (!result.EndOfMessage) continue;
196
197 var text = Encoding.UTF8.GetString(message.WrittenSpan);
198 message.ResetWrittenCount();
199
200 Peer[] others;
201 lock (room.Gate) others = [.. room.Peers.Where(other => other != peer)];
202
203 foreach (var other in others)
204 {
205 await other.SendAsync(text, cancellationToken).ConfigureAwait(false);
206 }
207 }
208 }
209 finally
210 {
211 ArrayPool<byte>.Shared.Return(buffer);
212 }
213 }
214
215 /// <summary>
216 /// Takes the peer out of the room, tells whoever is left, and drops the room once it is empty.
217 /// </summary>
218 private async Task LeaveAsync(string code, Room room, Peer peer)
219 {
220 Peer[] remaining;
221
222 lock (room.Gate)
223 {
224 room.Peers.Remove(peer);
225 remaining = [.. room.Peers];
226
227 // Marked before the removal, so a peer that is mid-Join on this room sees it and takes
228 // the room that replaces it instead of joining one nobody can reach.
229 if (remaining.Length == 0) room.Removed = true;
230 }
231
232 if (remaining.Length == 0)
233 {
234 _rooms.TryRemove(new KeyValuePair<string, Room>(code, room));
235 return;
236 }
237
238 foreach (var other in remaining)
239 {
240 await other.SendAsync("""{"type":"peer-left"}""", CancellationToken.None).ConfigureAwait(false);
241 }
242 }
243
244 private static async Task CloseAsync(WebSocket socket, WebSocketCloseStatus status, string reason, CancellationToken cancellationToken)
245 {
246 if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived)) return;
247
248 try
249 {
250 await socket.CloseAsync(status, reason, cancellationToken).ConfigureAwait(false);
251 }
252 catch (Exception exception) when (exception is WebSocketException or OperationCanceledException or ObjectDisposedException)
253 {
254 // The other end is already gone; there is nobody to say goodbye to.
255 }
256 }
257
258 private sealed class Room
259 {
260 public readonly Lock Gate = new();
261
262 /// <summary>Never more than two, so a list beats anything cleverer.</summary>
263 public readonly List<Peer> Peers = [];
264
265 /// <summary>Set under <see cref="Gate"/> once this room is on its way out of the dictionary.</summary>
266 public bool Removed;
267 }
268
269 private sealed class Peer(WebSocket socket)
270 {
271 /// <summary>One write at a time: two interleaved sends would corrupt the frame.</summary>
272 private readonly SemaphoreSlim _sending = new(1, 1);
273
274 public WebSocket Socket { get; } = socket;
275
276 public async Task SendAsync(string message, CancellationToken cancellationToken)
277 {
278 await _sending.WaitAsync(cancellationToken).ConfigureAwait(false);
279
280 try
281 {
282 if (Socket.State != WebSocketState.Open) return;
283
284 await Socket.SendAsync(
285 Encoding.UTF8.GetBytes(message),
286 WebSocketMessageType.Text,
287 endOfMessage: true,
288 cancellationToken).ConfigureAwait(false);
289 }
290 catch (Exception exception) when (exception is WebSocketException or OperationCanceledException or ObjectDisposedException)
291 {
292 // A peer that has gone away is not this peer's problem: its own pump will notice.
293 }
294 finally
295 {
296 _sending.Release();
297 }
298 }
299 }
300 }