| File: Certificates\CertificateService.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.Diagnostics; using System.Globalization; using Aspire.Cli.Interaction; using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Aspire.Cli.Utils; using Aspire.Hosting; using Microsoft.AspNetCore.Certificates.Generation; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Certificates; /// <summary> /// The result of ensuring certificates are trusted. /// </summary> internal sealed class EnsureCertificatesTrustedResult { /// <summary> /// Gets the environment variables that should be set for the AppHost process /// to ensure certificates are properly trusted. /// </summary> public required IDictionary<string, string> EnvironmentVariables { get; init; } /// <summary> /// Gets whether the trust operation completed successfully. /// </summary> public required bool Success { get; init; } /// <summary> /// Gets whether the operation was cancelled by the user. /// </summary> public bool WasCancelled { get; init; } /// <summary> /// Gets the underlying result code from the certificate manager. /// </summary> public EnsureCertificateResult? ResultCode { get; init; } } internal interface ICertificateService { Task<EnsureCertificatesTrustedResult> EnsureCertificatesTrustedAsync(CancellationToken cancellationToken); string? ExportDevCertificatePem(CancellationToken cancellationToken); } internal sealed class CertificateService( ICertificateToolRunner certificateToolRunner, IInteractionService interactionService, AspireCliTelemetry telemetry, ICliHostEnvironment hostEnvironment, IEnvironment environment, CliExecutionContext executionContext, ILogger<CertificateService> logger) : ICertificateService { private const string SslCertDirEnvVar = "SSL_CERT_DIR"; internal string DevCertDirectory => Path.Combine( executionContext.AspireHomeDirectory.FullName, "dev-certs"); public async Task<EnsureCertificatesTrustedResult> EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) { using var activity = telemetry.StartDiagnosticActivity(kind: ActivityKind.Client); var environmentVariables = new Dictionary<string, string>(); var isLinux = environment.IsLinux(); // In non-interactive environments on macOS and Windows we can't successfully // prompt for trust (macOS Keychain password, Windows trust dialog). // Skip the trust attempt but still check the current state so we can warn when // the environment does not already have a trusted certificate. Linux trust is // non-interactive so it's safe to run the full flow there. var canPerformTrust = hostEnvironment.SupportsInteractiveInput || isLinux; if (!canPerformTrust) { var preCheck = certificateToolRunner.CheckHttpCertificate(cancellationToken); if (!preCheck.HasCertificates && ShouldGenerateHttpsCertificate()) { // No certificate exists yet. Generate one without trusting it so that // Kestrel's UseHttps() can load the cert from the personal store. // Trust requires user interaction (Windows dialog / macOS Keychain) which // is not possible here, but generation is non-interactive and safe. // // The .NET SDK's first-run experience normally handles this: the first // invocation of any `dotnet` command calls EnsureAspNetCoreHttpsDevelopmentCertificate // (trust: false) and writes a sentinel to ~/.dotnet/ so it only runs once per // SDK version. For C# AppHosts this happens implicitly via `dotnet run`, but // non-.NET AppHost languages (TypeScript, Python, etc.) launch a prebuilt // native binary and never invoke `dotnet`, so the first-run cert generation // never triggers. This call ensures consistent behavior across all languages. var generateResult = certificateToolRunner.EnsureHttpCertificateExists(); if (generateResult is EnsureCertificateResult.Succeeded or EnsureCertificateResult.ValidCertificatePresent) { // Refresh the check so subsequent trust-level logic reflects the newly created cert. preCheck = certificateToolRunner.CheckHttpCertificate(cancellationToken); } else { interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.CertificateGenerationFailed, generateResult)); } } if (preCheck.IsPartiallyTrusted) { interactionService.DisplayMessage(KnownEmojis.Warning, ErrorStrings.CertificatesPartiallyTrustedNonInteractive); } else if (!preCheck.IsFullyTrusted) { interactionService.DisplayMessage(KnownEmojis.Warning, ErrorStrings.CertificatesNotTrustedNonInteractive); } if (preCheck.IsPartiallyTrusted && isLinux) { ConfigureSslCertDir(environmentVariables); } return new EnsureCertificatesTrustedResult { EnvironmentVariables = environmentVariables, Success = true }; } // Always run trust so the Aspire cache stays populated even when the certificate // is already trusted. Each platform's TrustCertificateCore short-circuits without // prompting when the certificate is already in the trust store. var trustResultCode = await interactionService.ShowStatusAsync( InteractionServiceStrings.TrustingCertificates, () => Task.FromResult(certificateToolRunner.TrustHttpCertificate()), emoji: KnownEmojis.LockedWithKey); if (trustResultCode == EnsureCertificateResult.UserCancelledTrustStep) { interactionService.DisplayMessage(KnownEmojis.Warning, CertificatesCommandStrings.TrustCancelled); } else if (!CertificateHelpers.IsSuccessfulTrustResult(trustResultCode)) { interactionService.DisplayMessage(KnownEmojis.Warning, string.Format(CultureInfo.CurrentCulture, ErrorStrings.CertificatesMayNotBeFullyTrusted, trustResultCode)); } var postTrustCheck = certificateToolRunner.CheckHttpCertificate(cancellationToken); if (postTrustCheck.IsPartiallyTrusted && isLinux) { ConfigureSslCertDir(environmentVariables); } var partialTrustAccepted = !hostEnvironment.SupportsInteractiveInput && isLinux && trustResultCode == EnsureCertificateResult.PartiallyFailedToTrustTheCertificate && postTrustCheck.IsPartiallyTrusted; return new EnsureCertificatesTrustedResult { EnvironmentVariables = environmentVariables, Success = CertificateHelpers.IsSuccessfulTrustResult(trustResultCode) || partialTrustAccepted, WasCancelled = trustResultCode == EnsureCertificateResult.UserCancelledTrustStep, ResultCode = trustResultCode }; } /// <summary> /// Checks whether automatic HTTPS certificate generation is enabled. /// Set ASPIRE_CLI_GENERATE_HTTPS_CERTIFICATE=false to suppress generation, /// mirroring the .NET SDK's DOTNET_GENERATE_ASPNET_CERTIFICATE opt-out. /// </summary> private bool ShouldGenerateHttpsCertificate() { var value = environment.GetEnvironmentVariable(KnownConfigNames.CliGenerateHttpsCertificate); return !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase); } private void ConfigureSslCertDir(Dictionary<string, string> environmentVariables) { // Get the dev-certs trust path (respects DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY override) var devCertsTrustPath = CertificateHelpers.GetDevCertsTrustPath(environment); // Get the current SSL_CERT_DIR value (if any) var currentSslCertDir = environment.GetEnvironmentVariable(SslCertDirEnvVar); // Check if the dev-certs trust path is already included if (!string.IsNullOrEmpty(currentSslCertDir)) { var paths = currentSslCertDir.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries); if (paths.Any(p => string.Equals(p.TrimEnd(Path.DirectorySeparatorChar), devCertsTrustPath.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase))) { // Already included, nothing to do return; } // Append the dev-certs trust path to the existing value environmentVariables[SslCertDirEnvVar] = $"{currentSslCertDir}{Path.PathSeparator}{devCertsTrustPath}"; } else { // Set the dev-certs trust path combined with the system certificate directory. var systemCertDirs = CertificateHelpers.GetSystemCertificateDirectories(); systemCertDirs.Add(devCertsTrustPath); environmentVariables[SslCertDirEnvVar] = string.Join(Path.PathSeparator, systemCertDirs); } } public string? ExportDevCertificatePem(CancellationToken cancellationToken) { try { var result = certificateToolRunner.ExportDevCertificatePublicPem(DevCertDirectory, cancellationToken); if (result is not null) { logger.LogDebug("Exported dev certificate public PEM to {Path}", result); } else { logger.LogDebug("No valid dev certificate found to export as PEM"); } return result; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { logger.LogDebug(ex, "Failed to export dev certificate as PEM"); return null; } } }