using System.Buffers.Text; using System.Globalization; using System.Net; using System.Net.Sockets; using System.Net.WebSockets; using System.Security.Cryptography; using System.Text; namespace DodoSSH.Client.Terminal; /// /// Serves the renderer page and carries terminal frames, over one loopback socket. /// /// /// /// The data plane is a socket rather than the WebView's JavaScript bridge. The bridge is UI-thread /// bound string evaluation with no backpressure signal, which at terminal throughput means thousands /// of script evaluations per second on the thread that also has to paint. A socket gives ordering, /// binary payloads and flow control for free. /// /// /// The page is served from the same listener as the socket, which is what makes the Origin /// header predictable — it is always http://127.0.0.1:{port}. Loading the page through a /// WebView virtual-host mapping instead would produce a different origin on each backend and give /// nothing to validate against. /// /// /// What the token and origin check actually defend against. Not a hostile process running as /// the same user: that process can already read this one's memory, so nothing here is a boundary /// against it. They defend against a web page in the user's browser, which can and does attempt /// WebSocket connections to loopback ports, and against a second copy of the application /// accidentally attaching to the wrong terminal. Both are real; neither is stopped by the socket /// being loopback alone. /// /// public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable { /// Subprotocol the renderer must request. public const string SubProtocol = "dodossh.terminal.v1"; /// Path the renderer page is served from. public const string PagePath = "/terminal"; /// Path the WebSocket upgrade is accepted on. public const string SocketPath = "/socket"; /// Placeholder in the page that is replaced with the connection token. public const string TokenPlaceholder = "__DODOSSH_TOKEN__"; /// Placeholder in the page that is replaced with the socket URL. public const string SocketUrlPlaceholder = "__DODOSSH_SOCKET__"; /// RFC 6455 §1.3: the fixed GUID mixed into the handshake response. private const string HandshakeGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; private const int MaximumRequestBytes = 16 * 1024; private const int ReceiveBufferBytes = 64 * 1024; private readonly TcpListener listener; private readonly ITerminalAssetProvider assets; private readonly Dictionary pumps = []; private readonly Lock pumpGate = new(); private readonly SemaphoreSlim sendGate = new(1, 1); private readonly CancellationTokenSource lifetime = new(); private readonly TaskCompletionSource rendererAttached = new(TaskCreationOptions.RunContinuationsAsynchronously); private WebSocket? socket; private int accepted; private int disposed; /// Where the renderer's files come from. public TerminalDataPlane(ITerminalAssetProvider assets) { ArgumentNullException.ThrowIfNull(assets); this.assets = assets; Token = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32)); listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); Port = ((IPEndPoint)listener.LocalEndpoint).Port; } /// The port the OS assigned. public int Port { get; } /// The single-use connection token embedded in the served page. public string Token { get; } /// Where the WebView should navigate. public Uri PageUrl => new( string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}{PagePath}"), UriKind.Absolute); /// Completes once the renderer has attached its socket. public Task RendererAttached => rendererAttached.Task; /// Raised when the page asks for a different font size. /// /// Raised on the socket's receive loop rather than any UI thread, so a handler that touches view models /// has to marshal. forwards it as it arrives and leaves that to the /// shell, which is where the thread affinity is known. /// public event EventHandler? FontSizeStepRequested; /// Registers a session so inbound frames can be routed to it. public void Register(uint sessionId, TerminalSessionPump pump) { ArgumentNullException.ThrowIfNull(pump); lock (pumpGate) { pumps[sessionId] = pump; } } /// Forgets a session that has ended. public void Unregister(uint sessionId) { lock (pumpGate) { pumps.Remove(sessionId); } } /// Accepts connections until disposed. public async Task RunAsync(CancellationToken cancellationToken) { using var linked = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, lifetime.Token); while (!linked.Token.IsCancellationRequested) { TcpClient client; try { client = await listener.AcceptTcpClientAsync(linked.Token).ConfigureAwait(false); } catch (OperationCanceledException) { return; } // Each connection on its own task, and deliberately not awaited. An upgraded WebSocket // lives for the whole session, so handling connections in sequence would leave the accept // loop parked inside the receive loop and every later request unanswered — the page's // script and stylesheet among them. Concurrency needs no coordination here because the // single-attach guard is an interlocked exchange. _ = HandleConnectionAsync(client, linked.Token); } } private async Task HandleConnectionAsync(TcpClient client, CancellationToken cancellationToken) { try { await HandleAsync(client, cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is IOException or SocketException or WebSocketException or OperationCanceledException or ObjectDisposedException) { // A renderer that went away mid-handshake, or a shutdown in progress. Ordinary. } finally { client.Dispose(); } } /// public async ValueTask SendAsync(ReadOnlyMemory frame, CancellationToken cancellationToken) { var current = socket; if (current is null || current.State != WebSocketState.Open) { // Dropped rather than queued. A terminal whose renderer has gone has nothing to catch up // on, and buffering for one that may never return is the unbounded growth the credit // window exists to prevent. return; } // WebSocket forbids concurrent sends, and every session shares this one socket. await sendGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { await current .SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken) .ConfigureAwait(false); } finally { sendGate.Release(); } } /// public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref disposed, 1) == 1) { return; } await lifetime.CancelAsync().ConfigureAwait(false); rendererAttached.TrySetCanceled(); listener.Dispose(); socket?.Dispose(); sendGate.Dispose(); lifetime.Dispose(); } private async Task HandleAsync(TcpClient client, CancellationToken cancellationToken) { var stream = client.GetStream(); await using var streamScope = stream.ConfigureAwait(false); var request = await ReadRequestAsync(stream, cancellationToken).ConfigureAwait(false); if (request is null) { return; } if (string.Equals(request.Path, SocketPath, StringComparison.Ordinal)) { await UpgradeAsync(stream, request, cancellationToken).ConfigureAwait(false); return; } await ServeAssetAsync(stream, request.Path, cancellationToken).ConfigureAwait(false); } private async Task ServeAssetAsync(Stream stream, string path, CancellationToken cancellationToken) { var asset = assets.Find(path); if (asset is null) { await WriteResponseAsync(stream, "404 Not Found", "text/plain", "Not found"u8.ToArray(), cancellationToken) .ConfigureAwait(false); return; } var content = asset.Content; if (string.Equals(path, PagePath, StringComparison.Ordinal)) { // The token and socket URL are substituted at serve time rather than being written into // the file, so the token never touches disk and never appears in a URL that could reach a // log or a browser history. var text = Encoding.UTF8.GetString(content) .Replace(TokenPlaceholder, Token, StringComparison.Ordinal) .Replace( SocketUrlPlaceholder, string.Create(CultureInfo.InvariantCulture, $"ws://127.0.0.1:{Port}{SocketPath}"), StringComparison.Ordinal); content = Encoding.UTF8.GetBytes(text); } await WriteResponseAsync(stream, "200 OK", asset.ContentType, content, cancellationToken) .ConfigureAwait(false); } private async Task UpgradeAsync( Stream stream, HttpRequestLine request, CancellationToken cancellationToken) { if (!IsAcceptableUpgrade(request)) { await WriteResponseAsync( stream, "403 Forbidden", "text/plain", "Rejected"u8.ToArray(), cancellationToken) .ConfigureAwait(false); return; } if (Interlocked.Exchange(ref accepted, 1) == 1) { // One renderer, one socket. A second attach would be either a bug or something else on the // machine having found the port. await WriteResponseAsync( stream, "409 Conflict", "text/plain", "Already attached"u8.ToArray(), cancellationToken) .ConfigureAwait(false); return; } var key = request.Headers.GetValueOrDefault("sec-websocket-key")!; var accept = ComputeHandshakeAccept(key); var handshake = "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: " + accept + "\r\n" + "Sec-WebSocket-Protocol: " + SubProtocol + "\r\n\r\n"; await stream.WriteAsync(Encoding.ASCII.GetBytes(handshake), cancellationToken).ConfigureAwait(false); await stream.FlushAsync(cancellationToken).ConfigureAwait(false); // The handshake is ours; the framing is the BCL's. Hand-rolling masking, fragmentation and // control frames would be a great deal of code for no gain. using var webSocket = WebSocket.CreateFromStream( stream, new WebSocketCreationOptions { IsServer = true, SubProtocol = SubProtocol, KeepAliveInterval = TimeSpan.FromSeconds(30), }); socket = webSocket; rendererAttached.TrySetResult(); await ReceiveLoopAsync(webSocket, cancellationToken).ConfigureAwait(false); } /// /// The origin is checked because a page in the user's browser can attempt a WebSocket connection to /// a loopback port, and the token because that page could otherwise simply guess the path. Neither /// defends against a process running as this user; that one can read our memory regardless. /// private bool IsAcceptableUpgrade(HttpRequestLine request) { var origin = request.Headers.GetValueOrDefault("origin"); var expectedOrigin = string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{Port}"); if (!string.Equals(origin, expectedOrigin, StringComparison.OrdinalIgnoreCase)) { return false; } if (request.Headers.GetValueOrDefault("sec-websocket-key") is null) { return false; } var protocols = request.Headers.GetValueOrDefault("sec-websocket-protocol") ?? string.Empty; var offered = protocols .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); if (!offered.Contains(SubProtocol, StringComparer.Ordinal)) { return false; } // Carried as a subprotocol rather than a query parameter, which keeps it out of anything that // logs URLs. var presented = offered.FirstOrDefault(p => p.StartsWith("token.", StringComparison.Ordinal)); return presented is not null && CryptographicOperations.FixedTimeEquals( Encoding.ASCII.GetBytes(presented["token.".Length..]), Encoding.ASCII.GetBytes(Token)); } private async Task ReceiveLoopAsync(WebSocket webSocket, CancellationToken cancellationToken) { var buffer = new byte[ReceiveBufferBytes]; while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) { var result = await webSocket .ReceiveAsync(buffer.AsMemory(), cancellationToken) .ConfigureAwait(false); if (result.MessageType == WebSocketMessageType.Close) { return; } if (!result.EndOfMessage) { // A frame larger than the receive buffer. Terminal input and acknowledgements are tiny, // so this is either a bug or something hostile; dropping is the safe response. continue; } Dispatch(buffer.AsSpan(0, result.Count)); } } private void Dispatch(ReadOnlySpan frame) { if (!TerminalFrame.TryRead(frame, out var opcode, out var sessionId, out var payload)) { return; } // Answered before the session lookup, because it is the one frame that is not about a session. The // page sends whichever id it had to hand, and a chord pressed in a terminal whose shell has just // ended is still a request to make the text bigger. if ((TerminalClientOpcode)opcode is TerminalClientOpcode.FontSizeStep) { if (TerminalFrame.TryReadFontSizeStep(payload, out var step)) { FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step)); } return; } TerminalSessionPump? pump; lock (pumpGate) { pump = pumps.GetValueOrDefault(sessionId); } if (pump is null) { // A frame for a session that has already ended. Ordinary during teardown. return; } switch ((TerminalClientOpcode)opcode) { case TerminalClientOpcode.Input: // Fire and forget: a blocked write must not stall acknowledgements for other sessions // sharing this socket. _ = pump.WriteInputAsync(payload.ToArray(), lifetime.Token).AsTask(); break; case TerminalClientOpcode.Acknowledge: if (TerminalFrame.TryReadAcknowledgement(payload, out var rendered)) { pump.Acknowledge(rendered); } break; case TerminalClientOpcode.Resize: if (TerminalFrame.TryReadResize(payload, out var size)) { pump.Resize(size); } break; default: // Unknown opcode from a newer page than this host. Ignored rather than fatal. break; } } /// /// Computes the Sec-WebSocket-Accept value RFC 6455 §4.2.2 requires. /// /// /// /// SHA-1 is mandated by the specification and is not doing security work here. The value proves /// only that the server understood the WebSocket handshake rather than being a plain HTTP server /// that echoed the request; it authenticates nothing and protects no data. Substituting SHA-256 /// would simply make the handshake fail with every client in existence. /// /// /// The alternative that avoids SHA-1 in our own code is /// HttpListener.AcceptWebSocketAsync, which throws PlatformNotSupportedException /// off Windows — so it would trade a documented suppression for a platform restriction. /// /// [System.Diagnostics.CodeAnalysis.SuppressMessage( "Security", "CA5350:Do Not Use Weak Cryptographic Algorithms", Justification = "RFC 6455 mandates SHA-1 for the handshake; it carries no security property.")] [System.Diagnostics.CodeAnalysis.SuppressMessage( "ApiDesign", "RS0030:Do not use banned APIs", Justification = "RFC 6455 mandates SHA-1 for the handshake; it carries no security property.")] private static string ComputeHandshakeAccept(string key) => Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + HandshakeGuid))); private static async Task WriteResponseAsync( Stream stream, string status, string contentType, byte[] body, CancellationToken cancellationToken) { var header = "HTTP/1.1 " + status + "\r\n" + "Content-Type: " + contentType + "\r\n" + "Content-Length: " + body.Length.ToString(CultureInfo.InvariantCulture) + "\r\n" // The page carries a connection token, so it must never be cached anywhere. + "Cache-Control: no-store\r\n" + "Connection: close\r\n\r\n"; await stream.WriteAsync(Encoding.ASCII.GetBytes(header), cancellationToken).ConfigureAwait(false); await stream.WriteAsync(body, cancellationToken).ConfigureAwait(false); await stream.FlushAsync(cancellationToken).ConfigureAwait(false); } private static async Task ReadRequestAsync( Stream stream, CancellationToken cancellationToken) { var buffer = new byte[MaximumRequestBytes]; var count = 0; while (count < buffer.Length) { var read = await stream .ReadAsync(buffer.AsMemory(count), cancellationToken) .ConfigureAwait(false); if (read == 0) { break; } count += read; if (Encoding.ASCII.GetString(buffer, 0, count).Contains("\r\n\r\n", StringComparison.Ordinal)) { break; } } var lines = Encoding.ASCII.GetString(buffer, 0, count).Split("\r\n"); var parts = lines[0].Split(' '); if (parts.Length < 2 || !string.Equals(parts[0], "GET", StringComparison.Ordinal)) { return null; } var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var line in lines.Skip(1)) { if (line.Length == 0) { break; } var separator = line.IndexOf(':', StringComparison.Ordinal); if (separator > 0) { headers[line[..separator].Trim()] = line[(separator + 1)..].Trim(); } } var path = parts[1]; var query = path.IndexOf('?', StringComparison.Ordinal); return new HttpRequestLine(query < 0 ? path : path[..query], headers); } private sealed record HttpRequestLine(string Path, IReadOnlyDictionary Headers); }