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