|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.IO.Pipes;
using System.Net.Sockets;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using Aspire.Hosting.RemoteHost.CodeGeneration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StreamJsonRpc;
namespace Aspire.Hosting.RemoteHost;
internal sealed class JsonRpcServer : BackgroundService
{
private readonly string _socketPath;
private readonly IServiceScopeFactory _scopeFactory;
private readonly CodeGenerationService _codeGenerationService;
private readonly ILogger<JsonRpcServer> _logger;
private Socket? _listenSocket;
private bool _disposed;
private int _activeClientCount;
public JsonRpcServer(
IConfiguration configuration,
IServiceScopeFactory scopeFactory,
CodeGenerationService codeGenerationService,
ILogger<JsonRpcServer> logger)
{
_scopeFactory = scopeFactory;
_codeGenerationService = codeGenerationService;
_logger = logger;
var socketPath = configuration["REMOTE_APP_HOST_SOCKET_PATH"];
if (string.IsNullOrEmpty(socketPath))
{
var tempDir = Path.GetTempPath();
socketPath = Path.Combine(tempDir, "aspire", "remote-app-host.sock");
}
_socketPath = socketPath;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Starting RemoteAppHost JsonRpc Server on {SocketPath}...", _socketPath);
if (OperatingSystem.IsWindows())
{
await StartNamedPipeServerAsync(stoppingToken).ConfigureAwait(false);
}
else
{
await StartUnixSocketServerAsync(stoppingToken).ConfigureAwait(false);
}
_logger.LogInformation("Goodbye!");
}
[SupportedOSPlatform("windows")]
private async Task StartNamedPipeServerAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting JsonRpc server on named pipe: {SocketPath}", _socketPath);
// Create pipe security that only allows the current user to connect
// This is equivalent to the Unix socket permission (owner read/write only)
var pipeSecurity = new PipeSecurity();
var currentUser = WindowsIdentity.GetCurrent().User;
if (currentUser != null)
{
pipeSecurity.AddAccessRule(new PipeAccessRule(
currentUser,
PipeAccessRights.FullControl,
AccessControlType.Allow));
}
while (!cancellationToken.IsCancellationRequested)
{
try
{
_logger.LogDebug("Waiting for client connection...");
// Create a new named pipe server for each connection with security restrictions
var pipeServer = NamedPipeServerStreamAcl.Create(
_socketPath,
PipeDirection.InOut,
NamedPipeServerStream.MaxAllowedServerInstances,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous,
inBufferSize: 0,
outBufferSize: 0,
pipeSecurity);
await pipeServer.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Client connected");
Interlocked.Increment(ref _activeClientCount);
// Handle the connection in a separate task - pipe stream is owned by handler
_ = Task.Run(() => HandleClientStreamAsync(pipeServer, ownsStream: true, cancellationToken), cancellationToken);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Server shutdown requested");
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in server loop, retrying in 1 second...");
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
}
}
_logger.LogInformation("Server stopped");
}
private async Task StartUnixSocketServerAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting JsonRpc server on Unix domain socket: {SocketPath}", _socketPath);
// Delete existing socket file if it exists
if (File.Exists(_socketPath))
{
File.Delete(_socketPath);
}
// Ensure the directory exists
var directory = Path.GetDirectoryName(_socketPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var endpoint = new UnixDomainSocketEndPoint(_socketPath);
_listenSocket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
_listenSocket.Bind(endpoint);
// M3: Set restrictive permissions on socket file (owner read/write only)
// This prevents other users on the system from connecting to the socket
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
{
File.SetUnixFileMode(_socketPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
_listenSocket.Listen(10);
while (!cancellationToken.IsCancellationRequested)
{
try
{
_logger.LogDebug("Waiting for client connection...");
var clientSocket = await _listenSocket.AcceptAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Client connected");
Interlocked.Increment(ref _activeClientCount);
// Handle the connection in a separate task - NetworkStream owns the socket
var stream = new NetworkStream(clientSocket, ownsSocket: true);
_ = Task.Run(() => HandleClientStreamAsync(stream, ownsStream: true, cancellationToken), cancellationToken);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Server shutdown requested");
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in server loop, retrying in 1 second...");
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
}
}
_logger.LogInformation("Server stopped");
}
private async Task HandleClientStreamAsync(Stream clientStream, bool ownsStream, CancellationToken cancellationToken)
{
var clientId = Guid.NewGuid().ToString("N")[..8]; // Short client identifier
var disconnectReason = "unknown";
// Create a DI scope for this client connection
// All scoped services (HandleRegistry, RemoteAppHostService, etc.) are per-client
_logger.LogDebug("Creating DI scope for client {ClientId}", clientId);
var scope = _scopeFactory.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
// Resolve the scoped RemoteAppHostService
var clientService = scope.ServiceProvider.GetRequiredService<RemoteAppHostService>();
try
{
// Use System.Text.Json formatter instead of the default Newtonsoft.Json formatter
var formatter = new SystemTextJsonFormatter();
var handler = new HeaderDelimitedMessageHandler(clientStream, clientStream, formatter);
using var jsonRpc = new JsonRpc(handler, clientService);
// Add the shared CodeGenerationService as an additional target for generateCode method
jsonRpc.AddLocalRpcTarget(_codeGenerationService);
jsonRpc.StartListening();
// Enable bidirectional communication - allow .NET to call back to TypeScript
clientService.SetClientConnection(jsonRpc);
_logger.LogDebug("JsonRpc connection established for client {ClientId} (bidirectional)", clientId);
// Wait for the connection to be closed by the client, an error, or cancellation
using var registration = cancellationToken.Register(() =>
{
disconnectReason = "server shutdown";
try { jsonRpc.Dispose(); }
catch { /* ignore disposal errors during cancellation */ }
});
try
{
await jsonRpc.Completion.ConfigureAwait(false);
disconnectReason = "graceful disconnect";
_logger.LogDebug("Client {ClientId}: {DisconnectReason}", clientId, disconnectReason);
}
catch (ConnectionLostException ex)
{
disconnectReason = "connection lost (client disconnected unexpectedly)";
_logger.LogDebug(ex, "Client {ClientId}: {DisconnectReason}", clientId, disconnectReason);
}
catch (ObjectDisposedException)
{
// This happens when server shutdown causes jsonRpc.Dispose()
disconnectReason ??= "server shutdown";
_logger.LogDebug("Client {ClientId}: {DisconnectReason}", clientId, disconnectReason);
}
catch (IOException ex)
{
disconnectReason = "stream closed (client terminated)";
_logger.LogDebug(ex, "Client {ClientId}: {DisconnectReason}", clientId, disconnectReason);
}
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Client {ClientId} I/O error", clientId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Client {ClientId} unexpected error", clientId);
}
finally
{
// Clean up stream if we own it
if (ownsStream)
{
try
{
clientStream.Dispose();
}
catch
{
// Ignore errors during close
}
}
_logger.LogDebug("Connection cleanup completed for client {ClientId}", clientId);
// Decrement active client count
var remaining = Interlocked.Decrement(ref _activeClientCount);
_logger.LogDebug("Active clients remaining: {RemainingClients}", remaining);
}
}
public override void Dispose()
{
if (!_disposed)
{
_disposed = true;
_listenSocket?.Dispose();
// Clean up socket file
if (File.Exists(_socketPath))
{
try
{
File.Delete(_socketPath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete socket file: {SocketPath}", _socketPath);
}
}
_logger.LogDebug("JsonRpcServer disposed");
}
base.Dispose();
}
}
|