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 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;
/// Raised when the page has text for the system clipboard.
///
/// A right-click over a selection. Raised on the socket's receive loop, like
/// and with the same obligation on a handler that touches a view
/// model — and the clipboard itself is a UI-thread object on more than one platform, so this one has to
/// be marshalled even where nothing is bound to.
///
public event EventHandler? ClipboardCopyRequested;
/// Raised when the page wants the system clipboard's text pasted into one of its panes.
///
/// A right-click with nothing selected. Raised on the socket's receive loop; see
/// . The answer goes back as
/// , which is 's to send.
///
public event EventHandler? ClipboardPasteRequested;
///
/// Raised after a socket attaches — the first one, and every later takeover.
///
///
///
/// Raised after has been swapped in but before starts
/// consuming it, on the socket-accept thread — the same thread that is in the middle of
/// for this connection. is this event's one
/// subscriber, and it uses the ordering to replay session state before anything the fresh page sends
/// (a resize, an early acknowledgement) can be dispatched; see its remark for why the two racing is
/// harmless regardless.
///
///
/// Unlike , which resolves once and answers "has a renderer ever attached"
/// for , this fires every time — because a takeover
/// is exactly the case was never meant to describe again.
///
///
public event EventHandler? SocketAttached;
/// 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. Two upgrades racing each other need no coordination
// here either, because the takeover in UpgradeAsync swaps the shared socket field with an
// interlocked exchange rather than assuming it is the only writer.
_ = 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);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Not a cancellation despite the type: .NET's ManagedWebSocket wraps a send that fails because
// the underlying connection is already gone — which is exactly what a killed renderer's socket
// looks like — in an OperationCanceledException of its own manufacture, regardless of whether
// anyone actually cancelled anything. The filter is what tells the two apart: if the caller's
// own token were the cause, IsCancellationRequested would be true here and this catch does not
// apply, so a real cancellation still propagates. Everything below about why this must not
// fault the caller applies here exactly as it does to the exception types in the next catch.
}
catch (Exception exception)
when (exception is WebSocketException or ObjectDisposedException
or InvalidOperationException or IOException)
{
// The state check above is not atomic with the send, and a WebView renderer process killed by
// Android leaves its socket reporting Open long after nobody is reading from the other end. This
// has to read as "nobody listening" — the same as the no-socket case above — and never as a
// fault: SendAsync is called from TerminalSessionPump.SendOutputAsync inside the flush loop, and
// letting this exception escape would fault that loop. A faulted flush loop stops draining the
// credit window, the reader blocks once it fills, and the SSH session behind it freezes for good
// while LiveSessionCount still counts it as running. A dropped frame is recoverable — a frozen
// session is not.
}
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);
}
///
///
/// Takeover, not rejection. A valid upgrade always wins the socket, even when one is already
/// attached — the old socket is aborted and the newcomer takes its place. Refusing a second attach used
/// to be the rule, on the theory that one renderer lives for the whole process. That is WebView2's
/// truth and not Android's: the platform kills the WebView's renderer process under memory pressure or
/// simply for being backgrounded, the page reloads, and the reload's socket is a second valid upgrade —
/// refusing it left the terminal permanently unreachable with no way back short of restarting the app.
///
///
/// Refusing protects nothing here anyway: only our own page knows the token (see the type-level remark
/// on what the token defends against), so a second valid upgrade is our page, reattaching.
/// Waiting for the old socket to notice it is dead and close on its own is not a safer alternative
/// either — a killed renderer process sends no TCP FIN, so the old receive loop can sit unaware for the
/// whole 30-second keepalive interval, and every reload landing in that window would still find the
/// door held shut by a socket nobody is on the other end of. Taking over immediately is what makes a
/// reload actually reattach.
///
///
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;
}
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),
});
// Whatever was attached before is displaced, not merely overwritten: Exchange hands back the old
// reference so it can be aborted rather than left to linger as a socket nothing reads from again.
// Abort rather than a graceful close — a close frame would wait on a peer that, per the remark
// above, may never notice it should reply, and the newcomer already proved it is our page.
var previous = Interlocked.Exchange(ref socket, webSocket);
previous?.Abort();
rendererAttached.TrySetResult();
SocketAttached?.Invoke(this, EventArgs.Empty);
try
{
await ReceiveLoopAsync(webSocket, cancellationToken).ConfigureAwait(false);
}
finally
{
// Cleared only if the field still holds this connection's own socket. A takeover has already
// swapped in a newer one by the time an aborted receive loop unwinds to here, and clearing the
// field regardless would race the newcomer: whichever of the two finished last would win, and
// it must always be the newcomer, never this one going away.
Interlocked.CompareExchange(ref socket, null, webSocket);
}
}
///
/// 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;
}
if (Announce(opcode, sessionId, payload))
{
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;
}
}
///
/// Raises the frames no pump needs to see, and says whether this was one of them.
///
///
///
/// Answered before the session lookup, because none of these three touches a pump. A text-size chord is
/// about the person reading rather than about one terminal, and the page sends whichever id it had to
/// hand. A copy is about the machine's clipboard. A paste request is answered by sending a frame back
/// rather than by writing to a pty, and does its own
/// liveness check — which is the one that can honestly refuse.
///
///
/// Which is also what keeps all three working in a pane whose shell has just ended, and that is when
/// they are most wanted: squinting at the message a remote left behind is exactly when somebody makes
/// the text bigger or copies the line. Dropping the frame at the pump lookup would have made a dead tab
/// the one place the gesture silently did nothing.
///
///
private bool Announce(byte opcode, uint sessionId, ReadOnlySpan payload)
{
switch ((TerminalClientOpcode)opcode)
{
case TerminalClientOpcode.FontSizeStep:
if (TerminalFrame.TryReadFontSizeStep(payload, out var step))
{
FontSizeStepRequested?.Invoke(this, new TerminalFontSizeStepEventArgs(step));
}
return true;
case TerminalClientOpcode.ClipboardCopy:
// An empty copy is dropped rather than forwarded. The clipboard is shared with everything
// else on the machine, so "copy nothing" would be a gesture that quietly destroys whatever
// was on it — and the page does not send one, which makes this a guard against a frame
// arriving from something other than the page we shipped.
if (payload.Length > 0)
{
ClipboardCopyRequested?.Invoke(
this,
new TerminalClipboardCopyEventArgs(Encoding.UTF8.GetString(payload)));
}
return true;
case TerminalClientOpcode.ClipboardPaste:
ClipboardPasteRequested?.Invoke(this, new TerminalClipboardPasteEventArgs(sessionId));
return true;
default:
return false;
}
}
///
/// 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);
}