File: Utils\EnvironmentChecker\DcpConnectionChecker.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Net.Http.Headers;
using System.Net.Security;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Aspire.Cli.DotNet;
using Aspire.Cli.Resources;
using Aspire.Shared;
using Microsoft.AspNetCore.Certificates.Generation;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Cli.Utils.EnvironmentChecker;
 
internal interface IDcpConnectionChecker
{
    Task<EnvironmentCheckResult> TestConnectionAsync(string dcpDirectory, bool useDeveloperCertificate, CancellationToken cancellationToken);
}
 
internal sealed class DcpConnectionChecker(
    CertificateManager certificateManager,
    IProcessExecutionFactory processExecutionFactory,
    CliExecutionContext executionContext,
    IEnvironment environment,
    ILogger<DcpConnectionChecker> logger) : IDcpConnectionChecker
{
    private static readonly TimeSpan s_connectionTimeout = TimeSpan.FromSeconds(20);
    private static readonly TimeSpan s_processExitTimeout = TimeSpan.FromSeconds(5);
 
    public async Task<EnvironmentCheckResult> TestConnectionAsync(string dcpDirectory, bool useDeveloperCertificate, CancellationToken cancellationToken)
    {
        using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        timeoutCts.CancelAfter(s_connectionTimeout);
 
        try
        {
            await using var session = await DcpConnectionTestSession.StartAsync(
                dcpDirectory,
                useDeveloperCertificate,
                certificateManager,
                processExecutionFactory,
                executionContext,
                environment,
                logger,
                timeoutCts.Token).ConfigureAwait(false);
 
            using var kubeconfig = await session.ReadKubeconfigAsync(timeoutCts.Token).ConfigureAwait(false);
            using var handler = CreateHttpClientHandler(kubeconfig);
            using var client = new HttpClient(handler)
            {
                BaseAddress = kubeconfig.Server,
                Timeout = s_connectionTimeout
            };
 
            if (!string.IsNullOrWhiteSpace(kubeconfig.Token))
            {
                client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", kubeconfig.Token);
            }
 
            using var response = await client.GetAsync(new Uri(kubeconfig.Server, "/version"), timeoutCts.Token).ConfigureAwait(false);
            if (!response.IsSuccessStatusCode)
            {
                return Failed(
                    useDeveloperCertificate,
                    details: string.Format(
                        CultureInfo.CurrentCulture,
                        DoctorCommandStrings.DcpApiServerReturnedHttpStatusMessageFormat,
                        (int)response.StatusCode,
                        response.StatusCode));
            }
 
            await session.StopDcpAsync(client, kubeconfig, timeoutCts.Token).ConfigureAwait(false);
 
            return useDeveloperCertificate
                ? Passed(useDeveloperCertificate, DoctorCommandStrings.DcpDeveloperCertificateConnectionSucceededMessage)
                : Passed(useDeveloperCertificate, DoctorCommandStrings.DcpEphemeralCertificateConnectionSucceededMessage);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            throw;
        }
        catch (OperationCanceledException)
        {
            return Failed(
                useDeveloperCertificate,
                details: string.Format(
                    CultureInfo.CurrentCulture,
                    DoctorCommandStrings.DcpConnectionTimedOutMessageFormat,
                    s_connectionTimeout.TotalSeconds));
        }
        catch (DcpDeveloperCertificateUnavailableException ex)
        {
            return CreateResult(
                useDeveloperCertificate,
                EnvironmentCheckStatus.Warning,
                DoctorCommandStrings.DcpDeveloperCertificateUnavailableMessage,
                details: ex.Message,
                fix: DoctorCommandStrings.DcpDeveloperCertificateTrustFix);
        }
        catch (AuthenticationException ex)
        {
            return Failed(
                useDeveloperCertificate,
                details: string.Format(CultureInfo.CurrentCulture, DoctorCommandStrings.DcpTlsAuthenticationFailedDetailsFormat, ex.Message),
                useDeveloperCertificate ? DoctorCommandStrings.DcpDeveloperCertificateRepairTrustFix : null);
        }
        catch (HttpRequestException ex)
        {
            return Failed(useDeveloperCertificate, details: ex.Message);
        }
        catch (Exception ex)
        {
            return Failed(useDeveloperCertificate, details: ex.Message);
        }
    }
 
    private static HttpClientHandler CreateHttpClientHandler(DcpKubeconfig kubeconfig)
    {
        var handler = new HttpClientHandler();
        if (kubeconfig.ClientCertificate is not null)
        {
            // DCP writes client certificate material into the generated kubeconfig. Supplying it
            // here validates that the kubeconfig is sufficient for client authentication without
            // relying on AppHost-specific process setup.
            handler.ClientCertificates.Add(kubeconfig.ClientCertificate);
        }
 
        if (kubeconfig.CertificateAuthorityCertificates.Count > 0)
        {
            // DCP's generated kubeconfig includes the CA that should authenticate the API server.
            // Trust that CA explicitly so this probe validates the DCP-issued connection material
            // instead of relying on the OS/user certificate stores. Other TLS policy failures still
            // fail the request because the goal is only to replace chain trust, not hostname or
            // certificate validity checks.
            handler.ServerCertificateCustomValidationCallback = (request, certificate, _, sslPolicyErrors) =>
            {
                if (certificate is null)
                {
                    return false;
                }
 
                if ((sslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None)
                {
                    return false;
                }
 
                using var serverCertificate = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert));
                using var chain = new X509Chain();
                chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
                chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
 
                foreach (var authorityCertificate in kubeconfig.CertificateAuthorityCertificates)
                {
                    chain.ChainPolicy.CustomTrustStore.Add(authorityCertificate);
                }
 
                return chain.Build(serverCertificate);
            };
        }
 
        return handler;
    }
 
    private static EnvironmentCheckResult Passed(bool useDeveloperCertificate, string message) =>
        CreateResult(useDeveloperCertificate, EnvironmentCheckStatus.Pass, message);
 
    private static EnvironmentCheckResult Failed(bool useDeveloperCertificate, string? details = null, string? fix = null) =>
        CreateResult(
            useDeveloperCertificate,
            EnvironmentCheckStatus.Fail,
            useDeveloperCertificate
                ? DoctorCommandStrings.DcpDeveloperCertificateConnectionFailedMessage
                : DoctorCommandStrings.DcpEphemeralCertificateConnectionFailedMessage,
            details,
            fix);
 
    private static EnvironmentCheckResult CreateResult(bool useDeveloperCertificate, EnvironmentCheckStatus status, string message, string? details = null, string? fix = null)
    {
        return new EnvironmentCheckResult
        {
            Category = EnvironmentCheckCategories.Aspire,
            Name = CheckName(useDeveloperCertificate),
            Status = status,
            Message = message,
            Details = details,
            Fix = fix
        };
    }
 
    private static string CheckName(bool useDeveloperCertificate)
    {
        return useDeveloperCertificate
            ? DcpConnectionHealthCheck.DeveloperCertificateCheckName
            : DcpConnectionHealthCheck.EphemeralCertificateCheckName;
    }
 
    private sealed class DcpConnectionTestSession : IAsyncDisposable
    {
        private readonly IProcessExecution _process;
        private readonly string _sessionDirectory;
        private readonly string _kubeconfigPath;
        private readonly OutputCollector _output;
        private readonly ILogger _logger;
        private bool _stopRequested;
 
        private DcpConnectionTestSession(IProcessExecution process, string sessionDirectory, string kubeconfigPath, OutputCollector output, ILogger logger)
        {
            _process = process;
            _sessionDirectory = sessionDirectory;
            _kubeconfigPath = kubeconfigPath;
            _output = output;
            _logger = logger;
        }
 
        public static async Task<DcpConnectionTestSession> StartAsync(
            string dcpDirectory,
            bool useDeveloperCertificate,
            CertificateManager certificateManager,
            IProcessExecutionFactory processExecutionFactory,
            CliExecutionContext executionContext,
            IEnvironment environment,
            ILogger logger,
            CancellationToken cancellationToken)
        {
            var sessionDirectory = Directory.CreateTempSubdirectory("aspire-dcp-doctor-").FullName;
            var kubeconfigPath = Path.Combine(sessionDirectory, "kubeconfig");
            var dcpExecutablePath = BundleDiscovery.GetDcpExecutablePath(dcpDirectory);
            var output = new OutputCollector();
            IProcessExecution? process = null;
            var processStarted = false;
 
            try
            {
                var arguments = new List<string>
                {
                    "start-apiserver",
                    "--kubeconfig",
                    kubeconfigPath
                };
 
                if (useDeveloperCertificate)
                {
                    AddDeveloperCertificateArguments(arguments, certificateManager, environment);
                }
 
                var environmentVariables = new Dictionary<string, string>();
                var extensionsPath = Path.Combine(dcpDirectory, "ext");
                if (Directory.Exists(extensionsPath))
                {
                    environmentVariables["DCP_EXTENSIONS_PATH"] = extensionsPath;
                }
 
                // DCP uses this folder for process-scoped state such as the generated kubeconfig.
                // Keeping it under the doctor-owned temp directory prevents overlap with AppHost sessions.
                environmentVariables["DCP_SESSION_FOLDER"] = sessionDirectory;
 
                var options = new ProcessInvocationOptions
                {
                    StandardOutputCallback = output.AppendOutput,
                    StandardErrorCallback = output.AppendError
                };
 
                process = processExecutionFactory.CreateExecution(
                    dcpExecutablePath,
                    arguments.ToArray(),
                    environmentVariables,
                    executionContext.WorkingDirectory,
                    options);
 
                if (!await process.StartAsync(cancellationToken).ConfigureAwait(false))
                {
                    throw new InvalidOperationException(DoctorCommandStrings.DcpStartFailedMessage);
                }
 
                processStarted = true;
                var session = new DcpConnectionTestSession(process, sessionDirectory, kubeconfigPath, output, logger);
                await session.WaitForKubeconfigFileAsync(cancellationToken).ConfigureAwait(false);
                return session;
            }
            catch
            {
                if (process is not null)
                {
                    try
                    {
                        if (processStarted && !process.HasExited)
                        {
                            process.Kill(entireProcessTree: true);
                        }
                    }
                    catch (InvalidOperationException) when (process.HasExited)
                    {
                    }
                    finally
                    {
                        await process.DisposeAsync().ConfigureAwait(false);
                    }
                }
 
                try
                {
                    Directory.Delete(sessionDirectory, recursive: true);
                }
                catch (Exception ex)
                {
                    logger.LogDebug(ex, "Failed to delete DCP doctor session directory '{SessionDirectory}'.", sessionDirectory);
                }
 
                throw;
            }
        }
 
        public async Task<DcpKubeconfig> ReadKubeconfigAsync(CancellationToken cancellationToken)
        {
            await WaitForKubeconfigFileAsync(cancellationToken).ConfigureAwait(false);
 
            return await DcpKubeconfig.ReadFileWithRetryAsync(_kubeconfigPath, cancellationToken: cancellationToken).ConfigureAwait(false);
        }
 
        public async Task StopDcpAsync(HttpClient client, DcpKubeconfig kubeconfig, CancellationToken cancellationToken)
        {
            if (_stopRequested)
            {
                return;
            }
 
            _stopRequested = true;
 
            try
            {
                using var request = new HttpRequestMessage(HttpMethod.Patch, new Uri(kubeconfig.Server, "/admin/execution"))
                {
                    Content = new StringContent("""{"status":"Stopping","shutdownResourceCleanup":"None"}""", Encoding.UTF8, "application/merge-patch+json")
                };
 
                using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
                if (!response.IsSuccessStatusCode)
                {
                    _logger.LogDebug("DCP doctor stop request returned HTTP {StatusCode}.", response.StatusCode);
                }
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                _logger.LogDebug(ex, "Failed to request DCP shutdown for doctor connection check.");
            }
        }
 
        public async ValueTask DisposeAsync()
        {
            try
            {
                if (!_process.HasExited)
                {
                    _process.Kill(entireProcessTree: true);
                    using var exitCts = new CancellationTokenSource(s_processExitTimeout);
                    await _process.WaitForExitAsync(exitCts.Token).ConfigureAwait(false);
                }
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex, "Failed to stop DCP doctor process.");
            }
            finally
            {
                await _process.DisposeAsync().ConfigureAwait(false);
 
                try
                {
                    Directory.Delete(_sessionDirectory, recursive: true);
                }
                catch (Exception ex)
                {
                    _logger.LogDebug(ex, "Failed to delete DCP doctor session directory '{SessionDirectory}'.", _sessionDirectory);
                }
            }
        }
 
        private async Task WaitForKubeconfigFileAsync(CancellationToken cancellationToken)
        {
            while (!File.Exists(_kubeconfigPath))
            {
                if (_process.HasExited)
                {
                    throw new InvalidOperationException(
                        string.Format(
                            CultureInfo.CurrentCulture,
                            DoctorCommandStrings.DcpExitedBeforeKubeconfigDetailsFormat,
                            _process.ExitCode,
                            Environment.NewLine,
                            GetRecentOutput(_output)));
                }
 
                await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken).ConfigureAwait(false);
            }
        }
 
        private static string GetRecentOutput(OutputCollector output)
        {
            var lines = output.GetLines()
                .TakeLast(40)
                .Select(line => $"{line.Stream}: {line.Line}")
                .ToArray();
 
            return lines.Length == 0 ? DoctorCommandStrings.DcpNoOutputDetails : string.Join(Environment.NewLine, lines);
        }
 
        private static void AddDeveloperCertificateArguments(List<string> arguments, CertificateManager certificateManager, IEnvironment environment)
        {
            var certificates = certificateManager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true);
            try
            {
                var certificate = certificates.FirstOrDefault(c =>
                    c.HasPrivateKey &&
                    certificateManager.GetTrustLevel(c) == CertificateManager.TrustLevel.Full);
 
                if (certificate is null)
                {
                    throw new DcpDeveloperCertificateUnavailableException(DoctorCommandStrings.DcpDeveloperCertificateNoTrustedExportableDetails);
                }
 
                if (string.IsNullOrWhiteSpace(certificate.Thumbprint))
                {
                    throw new DcpDeveloperCertificateUnavailableException(DoctorCommandStrings.DcpDeveloperCertificateMissingThumbprintDetails);
                }
 
                arguments.Add("--tls-cert-thumbprint");
                arguments.Add(certificate.Thumbprint);
 
                if (environment.IsWindows())
                {
                    return;
                }
 
                var certificatePath = DcpDeveloperCertificateCache.EnsureDeveloperCertificateCache(certificateManager, certificate);
                var keyPath = Path.ChangeExtension(certificatePath, ".key");
 
                arguments.Add("--tls-cert-file");
                arguments.Add(certificatePath);
                arguments.Add("--tls-key-file");
                arguments.Add(keyPath);
            }
            finally
            {
                CertificateManager.DisposeCertificates(certificates);
            }
        }
 
    }
}
 
internal sealed class DcpDeveloperCertificateUnavailableException(string message) : Exception(message);