|
// 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.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
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; }
}
internal interface ICertificateService
{
Task<EnsureCertificatesTrustedResult> EnsureCertificatesTrustedAsync(IDotNetCliRunner runner, CancellationToken cancellationToken);
}
internal sealed partial class CertificateService(IInteractionService interactionService, AspireCliTelemetry telemetry) : ICertificateService
{
private const string SslCertDirEnvVar = "SSL_CERT_DIR";
private const string DevCertsOpenSslCertDirEnvVar = "DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY";
private static readonly string s_defaultDevCertsTrustPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".aspnet",
"dev-certs",
"trust");
/// <summary>
/// Gets the dev-certs trust path, respecting the DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY override.
/// </summary>
private static string GetDevCertsTrustPath()
{
var overridePath = Environment.GetEnvironmentVariable(DevCertsOpenSslCertDirEnvVar);
return !string.IsNullOrEmpty(overridePath) ? overridePath : s_defaultDevCertsTrustPath;
}
public async Task<EnsureCertificatesTrustedResult> EnsureCertificatesTrustedAsync(IDotNetCliRunner runner, CancellationToken cancellationToken)
{
using var activity = telemetry.ActivitySource.StartActivity(nameof(EnsureCertificatesTrustedAsync), ActivityKind.Client);
var environmentVariables = new Dictionary<string, string>();
var ensureCertificateCollector = new OutputCollector();
// Use the machine-readable check (available in .NET 10 SDK which is the minimum required)
var trustResult = await CheckMachineReadableAsync(runner, ensureCertificateCollector, cancellationToken);
await HandleMachineReadableTrustAsync(runner, trustResult, ensureCertificateCollector, environmentVariables, cancellationToken);
return new EnsureCertificatesTrustedResult
{
EnvironmentVariables = environmentVariables
};
}
private async Task<CertificateTrustResult> CheckMachineReadableAsync(
IDotNetCliRunner runner,
OutputCollector collector,
CancellationToken cancellationToken)
{
var options = new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = collector.AppendOutput,
StandardErrorCallback = collector.AppendError,
};
var (_, result) = await interactionService.ShowStatusAsync(
$":locked_with_key: {InteractionServiceStrings.CheckingCertificates}",
async () => await runner.CheckHttpCertificateMachineReadableAsync(options, cancellationToken));
// Return the result or a default "no certificates" result
return result ?? new CertificateTrustResult
{
HasCertificates = false,
TrustLevel = null,
Certificates = []
};
}
private async Task HandleMachineReadableTrustAsync(
IDotNetCliRunner runner,
CertificateTrustResult trustResult,
OutputCollector collector,
Dictionary<string, string> environmentVariables,
CancellationToken cancellationToken)
{
// If fully trusted, nothing more to do
if (trustResult.IsFullyTrusted)
{
return;
}
// If not trusted at all, run the trust operation
if (trustResult.IsNotTrusted)
{
var options = new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = collector.AppendOutput,
StandardErrorCallback = collector.AppendError,
};
var trustExitCode = await interactionService.ShowStatusAsync(
$":locked_with_key: {InteractionServiceStrings.TrustingCertificates}",
() => runner.TrustHttpCertificateAsync(options, cancellationToken));
if (trustExitCode != 0)
{
interactionService.DisplayLines(collector.GetLines());
interactionService.DisplayMessage("warning", string.Format(CultureInfo.CurrentCulture, ErrorStrings.CertificatesMayNotBeFullyTrusted, trustExitCode));
}
// Re-check trust status after trust operation
var recheckOptions = new DotNetCliRunnerInvocationOptions
{
StandardOutputCallback = collector.AppendOutput,
StandardErrorCallback = collector.AppendError,
};
var (_, recheckResult) = await runner.CheckHttpCertificateMachineReadableAsync(recheckOptions, cancellationToken);
if (recheckResult is not null)
{
trustResult = recheckResult;
}
}
// If partially trusted (either initially or after trust), configure SSL_CERT_DIR on Linux
if (trustResult.IsPartiallyTrusted && RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
ConfigureSslCertDir(environmentVariables);
}
}
private static void ConfigureSslCertDir(Dictionary<string, string> environmentVariables)
{
// Get the dev-certs trust path (respects DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY override)
var devCertsTrustPath = GetDevCertsTrustPath();
// 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.
// Query OpenSSL to get its configured certificate directory.
var systemCertDirs = new List<string>();
if (TryGetOpenSslCertsDirectory(out var openSslCertsDir))
{
systemCertDirs.Add(openSslCertsDir);
}
else
{
// Fallback to common locations if OpenSSL is not available or fails
if (Directory.Exists("/etc/ssl/certs"))
{
systemCertDirs.Add("/etc/ssl/certs");
}
if (Directory.Exists("/etc/pki/tls/certs"))
{
systemCertDirs.Add("/etc/pki/tls/certs");
}
}
systemCertDirs.Add(devCertsTrustPath);
environmentVariables[SslCertDirEnvVar] = string.Join(Path.PathSeparator, systemCertDirs);
}
}
/// <summary>
/// Attempts to get the OpenSSL certificates directory by running 'openssl version -d'.
/// This is the same approach used by ASP.NET Core's certificate manager.
/// </summary>
/// <param name="certsDir">The path to the OpenSSL certificates directory if found.</param>
/// <returns>True if the OpenSSL certs directory was found, false otherwise.</returns>
private static bool TryGetOpenSslCertsDirectory([NotNullWhen(true)] out string? certsDir)
{
certsDir = null;
try
{
var processInfo = new ProcessStartInfo("openssl", "version -d")
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = Process.Start(processInfo);
if (process is null)
{
return false;
}
var stdout = process.StandardOutput.ReadToEnd();
process.WaitForExit(TimeSpan.FromSeconds(5));
if (process.ExitCode != 0)
{
return false;
}
// Parse output like: OPENSSLDIR: "/usr/lib/ssl"
var match = OpenSslVersionRegex().Match(stdout);
if (!match.Success)
{
return false;
}
var openSslDir = match.Groups[1].Value;
certsDir = Path.Combine(openSslDir, "certs");
// Verify the directory exists
if (!Directory.Exists(certsDir))
{
certsDir = null;
return false;
}
return true;
}
catch
{
return false;
}
}
[GeneratedRegex("OPENSSLDIR:\\s*\"([^\"]+)\"")]
private static partial Regex OpenSslVersionRegex();
}
public sealed class CertificateServiceException(string message) : Exception(message)
{
}
|