File: Terminal\TerminalWebSocketProxy.cs
Web Access
Project: src\src\Aspire.Dashboard\Aspire.Dashboard.csproj (Aspire.Dashboard)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Net.WebSockets;
using System.Net.Sockets;
using System.Text.Json;
using Aspire.Dashboard.Configuration;
using Aspire.Dashboard.Model;
using Grpc.Core;
using Hex1b;
using Hex1b.Reflow;
 
namespace Aspire.Dashboard.Terminal;
 
/// <summary>
/// Presents a remote HMP1 terminal to a browser using Hex1b's HWT1 adapter.
/// </summary>
internal static class TerminalWebSocketProxy
{
    // Private Aspire wire contract, not an HWT protocol status: the AppHost must
    // confirm completion or permanently reject attachment to the terminal ID.
    private const WebSocketCloseStatus TerminalEndedCloseStatus = (WebSocketCloseStatus)4000;
    private static readonly TimeSpan s_handshakeTimeout = TimeSpan.FromSeconds(10);
    private static readonly TimeSpan s_sendTimeout = TimeSpan.FromMinutes(2);
    private static readonly TimeSpan s_closeTimeout = TimeSpan.FromSeconds(2);
 
    public static void MapTerminalWebSocket(this WebApplication app)
    {
        app.Map("/api/terminal", async (HttpContext context,
                                       ITerminalConnectionResolver resolver,
                                       TerminalViewSessionRegistry sessions,
                                       ILoggerFactory loggerFactory) =>
        {
            var logger = loggerFactory.CreateLogger("Aspire.Dashboard.Terminal.TerminalWebSocketProxy");
            await HandleAsync(context, resolver, sessions, logger, context.TraceIdentifier).ConfigureAwait(false);
        }).RequireAuthorization(FrontendAuthorizationDefaults.PolicyName);
 
        // AppHost-owned terminals use the existing gRPC connection instead of a
        // consumer UDS. Both transports carry HMP1 into the same per-browser mirror.
        app.Map("/api/apphost-terminal", async (HttpContext context,
                                               IDashboardClient dashboardClient,
                                               TerminalViewSessionRegistry sessions,
                                               ILoggerFactory loggerFactory) =>
        {
            var logger = loggerFactory.CreateLogger("Aspire.Dashboard.Terminal.TerminalWebSocketProxy");
            await HandleAppHostTerminalAsync(context, dashboardClient, sessions, logger, context.TraceIdentifier).ConfigureAwait(false);
        }).RequireAuthorization(FrontendAuthorizationDefaults.PolicyName);
    }
 
    internal static async Task HandleAppHostTerminalAsync(HttpContext context,
                                                          IDashboardClient dashboardClient,
                                                          TerminalViewSessionRegistry sessions,
                                                          ILogger logger,
                                                          string connectionId)
    {
        if (!await ValidateUpgradeAsync(context, logger, connectionId).ConfigureAwait(false))
        {
            return;
        }
 
        var terminalId = context.Request.Query["terminalId"].ToString();
        if (string.IsNullOrWhiteSpace(terminalId))
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsync("Missing 'terminalId' query parameter.").ConfigureAwait(false);
            return;
        }
 
        if (!TryGetViewSession(context, sessions, out var session))
        {
            return;
        }
 
        // Keep the call's cancellation token alive for the entire view. Disposing a
        // handshake-only linked token here would detach it from RequestAborted.
        using var attachment = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
        attachment.CancelAfter(s_handshakeTimeout);
        Stream upstream;
        try
        {
            upstream = await dashboardClient.AttachTerminalAsync(terminalId, attachment.Token).ConfigureAwait(false);
            attachment.CancelAfter(Timeout.InfiniteTimeSpan);
        }
        catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
        {
            return;
        }
        catch (Exception ex) when (IsMissingAppHostTerminal(ex))
        {
            logger.LogDebug(ex, "AppHost terminal {TerminalId} no longer exists.", terminalId);
            await CloseEndedAsync(context, session, logger).ConfigureAwait(false);
            return;
        }
        catch (Exception ex) when (ex is RpcException or IOException or TimeoutException or InvalidOperationException or OperationCanceledException)
        {
            await WriteUnavailableAsync(context, logger, connectionId, ex).ConfigureAwait(false);
            return;
        }
 
        await HandleConnectionAsync(context, upstream, session, logger, connectionId).ConfigureAwait(false);
    }
 
    internal static async Task HandleAsync(HttpContext context,
                                          ITerminalConnectionResolver resolver,
                                          TerminalViewSessionRegistry sessions,
                                          ILogger logger,
                                          string connectionId)
    {
        if (!await ValidateUpgradeAsync(context, logger, connectionId).ConfigureAwait(false))
        {
            return;
        }
 
        var resourceName = context.Request.Query["resource"].ToString();
        var replicaText = context.Request.Query["replica"].ToString();
        if (string.IsNullOrWhiteSpace(resourceName))
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsync("Missing 'resource' query parameter.").ConfigureAwait(false);
            return;
        }
 
        var replicaIndex = 0;
        if (!string.IsNullOrWhiteSpace(replicaText) &&
            !int.TryParse(replicaText, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out replicaIndex))
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsync("Invalid 'replica' query parameter.").ConfigureAwait(false);
            return;
        }
 
        if (replicaIndex < 0)
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsync("'replica' must be non-negative.").ConfigureAwait(false);
            return;
        }
 
        if (!TryGetViewSession(context, sessions, out var session))
        {
            return;
        }
 
        // The browser supplies resource identity, never a filesystem path. Resolve
        // before accepting the upgrade so unavailable resources retain HTTP errors.
        Stream? upstream;
        try
        {
            using var timeout = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
            timeout.CancelAfter(s_handshakeTimeout);
            upstream = await resolver.ConnectAsync(resourceName, replicaIndex, timeout.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
        {
            return;
        }
        catch (Exception ex) when (ex is IOException or SocketException or TimeoutException or OperationCanceledException)
        {
            await WriteUnavailableAsync(context, logger, connectionId, ex).ConfigureAwait(false);
            return;
        }
 
        if (upstream is null)
        {
            context.Response.StatusCode = StatusCodes.Status404NotFound;
            await context.Response.WriteAsync("Terminal is not available for the requested resource and replica.").ConfigureAwait(false);
            return;
        }
 
        await HandleConnectionAsync(context, upstream, session, logger, connectionId).ConfigureAwait(false);
    }
 
    private static bool TryGetViewSession(HttpContext context, TerminalViewSessionRegistry sessions, out TerminalViewSession? session)
    {
        session = null;
        if (!context.Request.Query.TryGetValue("viewId", out var viewId))
        {
            // Direct consumers are interactive; this flag is per-view UI policy,
            // not an authorization boundary for the authenticated terminal owner.
            return true;
        }
 
        var endpoint = $"{context.Request.PathBase}{context.Request.Path}{context.Request.QueryString}";
        if (sessions.TryGet(viewId.ToString(), endpoint, out session))
        {
            return true;
        }
 
        context.Response.StatusCode = StatusCodes.Status404NotFound;
        return false;
    }
 
    private static async Task<bool> ValidateUpgradeAsync(HttpContext context, ILogger logger, string connectionId)
    {
        if (!context.WebSockets.IsWebSocketRequest)
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsync("Expected a WebSocket upgrade request.").ConfigureAwait(false);
            return false;
        }
 
        // Browsers send cookies on cross-origin WebSocket upgrades, and antiforgery
        // middleware does not protect these GET requests. Validate Origin before
        // opening either transport, including when frontend auth is disabled.
        // See https://datatracker.ietf.org/doc/html/rfc6455#section-10.2.
        if (!IsAllowedOrigin(context, out var originLogValue))
        {
            logger.LogWarning(
                "Rejecting terminal WebSocket upgrade {ConnectionId} with disallowed Origin '{Origin}'.",
                connectionId, originLogValue);
            context.Response.StatusCode = StatusCodes.Status403Forbidden;
            await context.Response.WriteAsync("Origin not allowed.").ConfigureAwait(false);
            return false;
        }
 
        return true;
    }
 
    private static async Task WriteUnavailableAsync(HttpContext context, ILogger logger, string connectionId, Exception exception)
    {
        logger.LogWarning(exception, "Failed to attach terminal ({ConnectionId}).", connectionId);
        var rpc = exception as RpcException ?? exception.InnerException as RpcException;
        context.Response.StatusCode = rpc?.StatusCode == StatusCode.NotFound
            ? StatusCodes.Status404NotFound
            : StatusCodes.Status503ServiceUnavailable;
        await context.Response.WriteAsync("Terminal is unavailable.").ConfigureAwait(false);
    }
 
    private static bool IsMissingAppHostTerminal(Exception exception)
    {
        var rpc = exception as RpcException ?? exception.InnerException as RpcException;
        return rpc?.StatusCode is StatusCode.NotFound or StatusCode.FailedPrecondition;
    }
 
    private static async Task CloseEndedAsync(HttpContext context, TerminalViewSession? session, ILogger logger)
    {
        session?.MarkEnded();
        // Browsers do not expose HTTP rejection statuses to WebSocket clients.
        // Upgrade only after the AppHost confirms completion/removal, then send
        // the same permanent close signal used by an already-attached terminal.
        using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
        await CloseAsync(socket, TerminalEndedCloseStatus, "Terminal ended", receive: null, logger).ConfigureAwait(false);
    }
 
    private static async Task HandleConnectionAsync(HttpContext context, Stream upstream, TerminalViewSession? session, ILogger logger, string connectionId)
    {
        await using var upstreamLifetime = upstream.ConfigureAwait(false);
        var workload = new Hmp1WorkloadAdapter(new Hmp1ClientOptions
        {
            StreamFactory = _ => Task.FromResult(upstream),
            DisplayName = "Aspire dashboard"
        });
        await using var workloadLifetime = workload.ConfigureAwait(false);
 
        // AttachTerminalAsync sends the selector but does not read the response.
        // Complete the HMP handshake before upgrading so transient failures retain
        // HTTP errors and permanent AppHost terminal removal uses the ended signal.
        try
        {
            using var handshake = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted);
            handshake.CancelAfter(s_handshakeTimeout);
            await workload.ConnectAsync(handshake.Token).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
        {
            return;
        }
        catch (Exception ex) when (upstream is GrpcTerminalClientStream grpc &&
            (grpc.TerminalEnded || IsMissingAppHostTerminal(ex)))
        {
            logger.LogDebug(ex, "AppHost terminal ended before the viewer handshake ({ConnectionId}).", connectionId);
            await CloseEndedAsync(context, session, logger).ConfigureAwait(false);
            return;
        }
        catch (Exception ex) when (ex is IOException or RpcException or InvalidOperationException or
            InvalidDataException or JsonException or OperationCanceledException)
        {
            await WriteUnavailableAsync(context, logger, connectionId, ex).ConfigureAwait(false);
            return;
        }
 
        using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
        logger.LogDebug("Terminal view opened ({ConnectionId}).", connectionId);
        try
        {
            await BridgeAsync(socket, workload, upstream, session, logger, context.RequestAborted).ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            // Also cover mirror construction and disposal: these run outside the
            // pump lifetime, but must not escape an already-upgraded request.
            logger.LogError(ex, "Terminal view failed ({ConnectionId}).", connectionId);
            var ended = upstream is GrpcTerminalClientStream { TerminalEnded: true };
            if (ended)
            {
                session?.MarkEnded();
            }
            await CloseAsync(socket, ended ? TerminalEndedCloseStatus : WebSocketCloseStatus.InternalServerError,
                ended ? "Terminal ended" : "Terminal view failed", receive: null, logger).ConfigureAwait(false);
        }
        finally
        {
            logger.LogDebug("Terminal view closed ({ConnectionId}).", connectionId);
        }
    }
 
    private static async Task BridgeAsync(WebSocket socket, Hmp1WorkloadAdapter workload, Stream upstream,
        TerminalViewSession? session, ILogger logger, CancellationToken cancellationToken)
    {
        // The remote producer owns geometry, primary role and graphics checkpoints.
        // This mirror belongs only to this browser; disposing it releases the HMP
        // peer and its transport, never the producer or the creator's terminal.
        // https://github.com/mitchdenny/hex1b/blob/798b26c/docs/web-terminal.md
        var presentation = new Hwt1PresentationAdapter
        {
            IsReadOnly = session?.ReadOnly == true || upstream is GrpcTerminalClientStream { TerminalEnded: true } || !workload.IsConnected
        };
        await using var presentationLifetime = presentation.ConfigureAwait(false);
        var terminal = Hex1bTerminal.CreateBuilder()
            .WithWorkload(workload)
            .WithPresentation(presentation)
            // HMP preserves soft wraps but does not negotiate reflow policy. Match the
            // AppHost/TerminalHost producer so this replica also reflows retained history.
            // https://github.com/mitchdenny/hex1b/blob/093b67b/docs/web-terminal.md#shell-reflow-configuration
            .WithReflow(GhosttyReflowStrategy.Instance)
            .WithScrollback(10000)
            .Build();
        await using var terminalLifetime = terminal.ConfigureAwait(false);
        await PumpViewAsync(socket, presentation, workload, upstream, session, logger, cancellationToken).ConfigureAwait(false);
    }
 
    private static async Task PumpViewAsync(WebSocket socket, Hwt1PresentationAdapter presentation, Hmp1WorkloadAdapter workload,
        Stream upstream, TerminalViewSession? session, ILogger logger, CancellationToken cancellationToken)
    {
        using var stopping = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        using var sending = CancellationTokenSource.CreateLinkedTokenSource(stopping.Token);
        var send = SendFramesAsync(socket, presentation, sending.Token, stopping.Token);
        var receive = ReceiveMessagesAsync(socket, presentation, workload, session, upstream as GrpcTerminalClientStream, stopping.Token);
        var disconnected = WaitForTransportDisconnectAsync(workload, upstream, logger, stopping.Token);
        var tasks = new[] { send, receive, disconnected };
        var closeStatus = WebSocketCloseStatus.NormalClosure;
        var closeReason = "Terminal closed";
        try
        {
            var completed = await Task.WhenAny(tasks).ConfigureAwait(false);
            await completed.ConfigureAwait(false);
            if (completed == disconnected)
            {
                closeStatus = WebSocketCloseStatus.EndpointUnavailable;
                closeReason = "Terminal transport disconnected";
            }
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // The browser or the dashboard ended this request.
        }
        catch (Exception ex) when (ex is IOException or WebSocketException or RpcException)
        {
            logger.LogDebug(ex, "Terminal transport disconnected.");
            closeStatus = WebSocketCloseStatus.EndpointUnavailable;
            closeReason = "Terminal transport disconnected";
        }
        catch (TimeoutException ex)
        {
            logger.LogWarning(ex, "Terminal view timed out.");
            closeStatus = WebSocketCloseStatus.PolicyViolation;
            closeReason = "Terminal connection timed out";
        }
        catch (Exception ex) when (ex is InvalidDataException or JsonException or InvalidOperationException or
            KeyNotFoundException or FormatException or ArgumentException)
        {
            logger.LogWarning(ex, "Invalid terminal input or state.");
            closeStatus = WebSocketCloseStatus.PolicyViolation;
            closeReason = "Invalid terminal input or state";
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Terminal view failed.");
            closeStatus = WebSocketCloseStatus.InternalServerError;
            closeReason = "Terminal view failed";
        }
        finally
        {
            // A pump failure can race HMP Exit. Let the bounded gRPC drain finish
            // before choosing a close code instead of mistaking transport EOF for
            // completion (or losing an Ended notification immediately after Exit).
            if (workload.DisconnectedTask.IsCompleted && !cancellationToken.IsCancellationRequested)
            {
                await ObserveTeardownAsync(disconnected, logger).ConfigureAwait(false);
            }
 
            // Send the close frame before cancelling ReceiveAsync, which can abort
            // the socket. Stop and join the binary sender first because WebSocket
            // permits only one pending send operation, including CloseOutputAsync.
            try
            {
                await sending.CancelAsync().ConfigureAwait(false);
                try
                {
                    // Cancel only the frame wait, not an in-flight socket send:
                    // cancelling SendAsync also aborts the socket and loses the
                    // completion close. A stalled browser gets a bounded grace period.
                    // Finish observing teardown even if the request was cancelled.
                    await ObserveTeardownAsync(send, logger).WaitAsync(s_closeTimeout, CancellationToken.None).ConfigureAwait(false);
                }
                catch (TimeoutException)
                {
                    socket.Abort();
                }
 
                if (upstream is GrpcTerminalClientStream { TerminalEnded: true })
                {
                    session?.MarkEnded();
                    closeStatus = TerminalEndedCloseStatus;
                    closeReason = "Terminal ended";
                }
 
                await CloseAsync(socket, closeStatus, closeReason, receive, logger).ConfigureAwait(false);
            }
            finally
            {
                try
                {
                    await stopping.CancelAsync().ConfigureAwait(false);
                }
                finally
                {
                    await ObserveTeardownAsync(Task.WhenAll(tasks), logger).ConfigureAwait(false);
                }
            }
        }
    }
 
    private static async Task WaitForTransportDisconnectAsync(Hmp1WorkloadAdapter workload, Stream upstream,
        ILogger logger, CancellationToken cancellationToken)
    {
        await workload.DisconnectedTask.WaitAsync(cancellationToken).ConfigureAwait(false);
        if (upstream is not GrpcTerminalClientStream grpc)
        {
            return;
        }
 
        // HMP Exit may precede the AppHost's terminal-level Ended notification.
        // The HMP reader has stopped by DisconnectedTask, so it is safe to drain
        // remaining opaque bytes here without concurrent reads or HMP parsing.
        using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        timeout.CancelAfter(s_handshakeTimeout);
        try
        {
            var buffer = new byte[4096];
            while (await grpc.ReadAsync(buffer, timeout.Token).ConfigureAwait(false) != 0)
            {
            }
        }
        catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
        {
            logger.LogDebug("Timed out waiting for the AppHost terminal's final transport status.");
            return;
        }
    }
 
    private static async Task ObserveTeardownAsync(Task task, ILogger logger)
    {
        try
        {
            await task.ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
        }
        catch (Exception ex)
        {
            // Observe all tasks even if a second failure occurs while unwinding
            // the first. Neither pump is allowed to outlive this viewer.
            logger.LogDebug(ex, "Terminal pump ended during view teardown.");
        }
    }
 
    private static async Task CloseAsync(WebSocket socket, WebSocketCloseStatus status, string reason, Task? receive, ILogger logger)
    {
        if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
        {
            return;
        }
 
        using var timeout = new CancellationTokenSource(s_closeTimeout);
        try
        {
            if (receive is null)
            {
                await socket.CloseAsync(status, reason, timeout.Token).ConfigureAwait(false);
            }
            else
            {
                await socket.CloseOutputAsync(status, reason, timeout.Token).ConfigureAwait(false);
                // Leave the existing receive in charge of the peer's close reply.
                // Cancelling it immediately after sending can reset the connection
                // before the browser receives the authoritative completion status.
                await ObserveTeardownAsync(receive, logger).WaitAsync(timeout.Token).ConfigureAwait(false);
            }
        }
        catch (Exception ex) when (ex is WebSocketException or OperationCanceledException or InvalidOperationException)
        {
            logger.LogDebug(ex, "Terminal close handshake failed.");
            socket.Abort();
        }
    }
 
    private static async Task SendFramesAsync(WebSocket socket, Hwt1PresentationAdapter presentation,
        CancellationToken frameCancellationToken, CancellationToken cancellationToken)
    {
        while (true)
        {
            // HWT1 frames are ordered complete binary messages. The adapter handles
            // acknowledgements and coalesces state while blocked; never drop frames.
            var frame = await presentation.ReadFrameAsync(frameCancellationToken).ConfigureAwait(false);
            frameCancellationToken.ThrowIfCancellationRequested();
            using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
            timeout.CancelAfter(s_sendTimeout);
            try
            {
                await socket.SendAsync(frame, WebSocketMessageType.Binary, true, timeout.Token).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
            {
                throw new TimeoutException("The browser did not receive a terminal frame.");
            }
        }
    }
 
    private static async Task ReceiveMessagesAsync(WebSocket socket, Hwt1PresentationAdapter presentation,
        Hmp1WorkloadAdapter workload, TerminalViewSession? session, GrpcTerminalClientStream? grpc, CancellationToken cancellationToken)
    {
        // HWT1 commands are UTF-8 JSON, e.g. {"type":"ack","revision":1}. WebSocket
        // fragmentation can split anywhere, including within a UTF-8 code point.
        // Reassemble the whole bounded message before handing it to the public API.
        var buffer = new byte[64 * 1024];
        while (true)
        {
            var length = 0;
            ValueWebSocketReceiveResult result;
            do
            {
                if (length == buffer.Length)
                {
                    throw new InvalidDataException("Terminal input exceeds 64 KiB.");
                }
 
                result = await socket.ReceiveAsync(buffer.AsMemory(length), cancellationToken).ConfigureAwait(false);
                if (result.MessageType == WebSocketMessageType.Close)
                {
                    return;
                }
                if (result.MessageType != WebSocketMessageType.Text)
                {
                    throw new InvalidDataException("Expected a terminal JSON command.");
                }
                length += result.Count;
            }
            while (!result.EndOfMessage);
 
            // Read policy after receiving the complete command: a paste or pointer
            // action started before the component became read-only may arrive later.
            // Hex1b owns validation and the mutation gate, including which commands
            // remain available for read-only viewing, selection, copying and history.
            presentation.IsReadOnly = session?.ReadOnly == true || grpc?.TerminalEnded == true || !workload.IsConnected;
            await presentation.HandleMessageAsync(buffer.AsMemory(0, length), cancellationToken).ConfigureAwait(false);
        }
    }
 
    internal static bool IsAllowedOrigin(HttpContext context, out string originLogValue)
    {
        return WebSocketOriginValidator.IsSameOrigin(context, out originLogValue);
    }
}