Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 51 additions & 14 deletions src/Aspire.Hosting/Dcp/DcpHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using System.Text;
using Aspire.Dashboard.Utils;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Diagnostics;
using Aspire.Hosting.Dcp.Process;
using Aspire.Hosting.Resources;
using Aspire.Shared;
Expand Down Expand Up @@ -38,6 +39,8 @@ internal sealed class DcpHost
private readonly IConfiguration _configuration;
private readonly CancellationTokenSource _shutdownCts = new();
private string? _dcpTlsCertThumbprint;
private string? _dcpTlsCertFile;
private string? _dcpTlsKeyFile;
private Task? _logProcessorTask;

// These environment variables should never be inherited by DCP from the app host.
Expand Down Expand Up @@ -176,27 +179,25 @@ internal async Task EnsureDevelopmentCertificateTrustAsync(CancellationToken can
}
}

internal Task PrepareDcpTlsCertificateAsync(CancellationToken cancellationToken)
internal async Task PrepareDcpTlsCertificateAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

// Using the ASP.NET dev cert for DCP TLS is opt-in; by default DCP uses its own ephemeral certificate.
if (!_configuration.GetBool(KnownConfigNames.DcpDeveloperCertificate, defaultValue: false))
// DCP uses the ASP.NET dev cert for TLS by default. The environment variable remains
// available as an opt-out if users need DCP's ephemeral certificate behavior.
if (!_configuration.GetBool(KnownConfigNames.DcpDeveloperCertificate, defaultValue: true))
{
return Task.CompletedTask;
return;
}

if (!OperatingSystem.IsWindows())
{
_logger.LogWarning("Developer certificate thumbprint configuration is only supported on Windows. DCP will use its default certificate.");
return Task.CompletedTask;
}
using var activity = ProfilingTelemetry.StartDcpPrepareTlsCertificate(_configuration);

// Check if we have a trusted developer certificate with a private key available
var certificates = _developerCertificateService.Certificates;
if (certificates.Count == 0)
{
return Task.CompletedTask;
activity.SetDcpTlsCertificateResult(ProfilingTelemetry.Values.DcpTlsCertificateResultNoCertificate);
return;
Comment thread
danegsta marked this conversation as resolved.
}

// Use the first (latest/best) certificate that has a private key
Expand All @@ -212,20 +213,51 @@ internal Task PrepareDcpTlsCertificateAsync(CancellationToken cancellationToken)

if (certificate is null)
{
return Task.CompletedTask;
activity.SetDcpTlsCertificateResult(ProfilingTelemetry.Values.DcpTlsCertificateResultNoPrivateKeyCertificate);
return;
}

var thumbprint = certificate.Thumbprint;
if (string.IsNullOrWhiteSpace(thumbprint))
{
_logger.LogWarning("Failed to read the developer certificate thumbprint. DCP will use its default certificate.");
return Task.CompletedTask;
activity.SetDcpTlsCertificateResult(ProfilingTelemetry.Values.DcpTlsCertificateResultMissingThumbprint);
return;
}

_dcpTlsCertThumbprint = thumbprint;
_logger.LogDebug("Prepared DCP TLS certificate thumbprint {Thumbprint}.", thumbprint);

return Task.CompletedTask;
if (OperatingSystem.IsWindows())
{
activity.SetDcpTlsCertificateResult(
ProfilingTelemetry.Values.DcpTlsCertificateResultPrepared,
ProfilingTelemetry.Values.DcpTlsCertificateModeThumbprint,
prepared: true);
_logger.LogDebug("Prepared DCP TLS certificate thumbprint {Thumbprint}.", thumbprint);
return;
}

var (certificatePath, keyPath, cachedThumbprint) = await DeveloperCertificateService.GetCachedCertificateFilePathsAsync(
certificate,
password: null,
cancellationToken).ConfigureAwait(false);

if (certificatePath is null || keyPath is null || cachedThumbprint is null)
{
_logger.LogWarning("Failed to cache the developer certificate files. DCP will use its default certificate.");
_dcpTlsCertThumbprint = null;
activity.SetDcpTlsCertificateResult(ProfilingTelemetry.Values.DcpTlsCertificateResultNoCertificate);
return;
}

_dcpTlsCertThumbprint = cachedThumbprint;
_dcpTlsCertFile = certificatePath;
_dcpTlsKeyFile = keyPath;
activity.SetDcpTlsCertificateResult(
ProfilingTelemetry.Values.DcpTlsCertificateResultPrepared,
ProfilingTelemetry.Values.DcpTlsCertificateModeFiles,
prepared: true);
_logger.LogDebug("Prepared DCP TLS certificate files for thumbprint {Thumbprint}.", thumbprint);
}

public async Task StopAsync()
Expand Down Expand Up @@ -280,6 +312,11 @@ public ProcessSpec CreateDcpProcessSpec(Locations locations)
arguments += $" --tls-cert-thumbprint \"{_dcpTlsCertThumbprint}\"";
}

if (!string.IsNullOrWhiteSpace(_dcpTlsCertFile) && !string.IsNullOrWhiteSpace(_dcpTlsKeyFile))
{
arguments += $" --tls-cert-file \"{_dcpTlsCertFile}\" --tls-key-file \"{_dcpTlsKeyFile}\"";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not for this PR but we should update this to use arguments list in the future. I added that to process spec.

}

var dcpProcessSpec = new ProcessSpec(dcpExePath)
{
WorkingDirectory = Directory.GetCurrentDirectory(),
Expand Down
140 changes: 111 additions & 29 deletions src/Aspire.Hosting/DeveloperCertificateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,48 +229,120 @@ private static IEnumerable<X509Certificate2> FindDevCertificates(X509Store store
return ExportFromPrivateKey(certificate, password, needKeyPem, needPfx);
}

// For dev certs we prefer reading from cache to avoid repeated keychain access prompts
var lookup = certificate.Thumbprint;
if (password is not null)
// For dev certs we prefer reading from cache to avoid repeated keychain access prompts.
// Ensure only one thread at a time is resolving certificates to avoid concurrent cache misses
// all trying to update the cache at the same time.
await s_certificateCacheSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
lookup += $"-{password}";
var cached = EnsureCachedKeyMaterial(certificate, password);
return (
needKeyPem ? Encoding.UTF8.GetString(cached.keyBytes).ToCharArray() : null,
needPfx ? cached.pfxBytes : null);
}
finally
{
s_certificateCacheSemaphore.Release();
}
}

lookup = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(lookup)));
/// <summary>
/// Ensures the public certificate (.crt), PFX (.pfx) and PEM private key (.key) cache files
/// exist for the specified ASP.NET Core developer certificate and returns the paths along with
/// the certificate thumbprint. Returns <c>(null, null, null)</c> if the supplied certificate is
/// not a developer certificate, has no thumbprint, or the cache files could not be produced.
/// </summary>
internal static async Task<(string? certificateFilePath, string? keyFilePath, string? thumbprint)> GetCachedCertificateFilePathsAsync(
X509Certificate2 certificate,
string? password,
CancellationToken cancellationToken)
{
if (!certificate.IsAspNetCoreDevelopmentCertificate() || string.IsNullOrWhiteSpace(certificate.Thumbprint))
{
return (null, null, null);
}

string certificateFileName;
string keyFileName;

// Ensure only one thread at a time is resolving certificates to avoid concurrent cache misses
// all trying to update the cache at the same time.
await s_certificateCacheSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var pfxFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.pfx");
var keyFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.key");
var cached = EnsureCachedKeyMaterial(certificate, password);
certificateFileName = cached.certFileName;
keyFileName = cached.keyFileName;
}
Comment thread
danegsta marked this conversation as resolved.
finally
{
s_certificateCacheSemaphore.Release();
}

// Try to read cached files. On cache hit, return the raw bytes directly
// without loading them into X509Certificate2 (which would import the key into
// the macOS keychain on net8.0).
var cachedPfx = TryReadCacheFile(pfxFileName);
var cachedKey = TryReadCacheFile(keyFileName);
return File.Exists(certificateFileName) && File.Exists(keyFileName)
? (certificateFileName, keyFileName, certificate.Thumbprint)
: (null, null, null);
}

if (cachedPfx is not null && cachedKey is not null)
{
return (
needKeyPem ? Encoding.UTF8.GetString(cachedKey).ToCharArray() : null,
needPfx ? cachedPfx : null);
}
/// <summary>
/// Ensures the public certificate (.crt), PFX (.pfx) and PEM private key (.key) cache files
/// exist for the specified certificate, returning their paths along with the cached PFX/key
/// byte contents. On cache miss the private key is accessed once (which may trigger a keychain
/// prompt on macOS) to export both private-key formats; on cache hit the bytes are read
/// directly from disk to avoid importing the PFX into the macOS keychain via
/// <see cref="X509Certificate2"/>. The public .crt file is written whenever it is missing,
/// since it can be produced without accessing the private key.
/// </summary>
/// <remarks>The caller must hold <see cref="s_certificateCacheSemaphore"/>.</remarks>
private static (string certFileName, string pfxFileName, string keyFileName, byte[] pfxBytes, byte[] keyBytes) EnsureCachedKeyMaterial(
X509Certificate2 certificate, string? password)
{
var lookup = GetKeyMaterialCacheLookup(certificate, password);
var certFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.crt");
var pfxFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.pfx");
var keyFileName = Path.Join(s_userDevCertificateLocation, $"{lookup}.key");

var cachedPfx = TryReadCacheFile(pfxFileName);
var cachedKey = TryReadCacheFile(keyFileName);

byte[] pfxBytes;
byte[] keyBytes;

if (cachedPfx is not null && cachedKey is not null)
{
pfxBytes = cachedPfx;
keyBytes = cachedKey;
}
else
{
// Fall back to accessing the private key directly (triggers a keychain prompt on macOS).
// Always produce both formats for caching, even if the caller only needs one.
var result = ExportFromPrivateKey(certificate, password, needKeyPem: true, needPfx: true);
pfxBytes = result.pfxBytes!;
keyBytes = Encoding.UTF8.GetBytes(result.keyPem!);
Array.Clear(result.keyPem!);

WriteCacheFiles(pfxFileName, result.pfxBytes, keyFileName, result.keyPem);
WriteCacheFiles(certFileName, certificate, pfxFileName, pfxBytes, keyFileName, keyBytes);
}

return (needKeyPem ? result.keyPem : null, needPfx ? result.pfxBytes : null);
// The public certificate cache file can be produced without touching the private key,
// so refresh it whenever it is missing (including on cache hits from older caches that
// pre-date this file).
if (!File.Exists(certFileName))
{
WriteCacheFiles(certFileName, certificate, pfxFileName: null, pfxBytes: null, keyFileName: null, keyBytes: null);
}
finally

return (certFileName, pfxFileName, keyFileName, pfxBytes, keyBytes);
}

private static string GetKeyMaterialCacheLookup(X509Certificate2 certificate, string? password)
{
var lookup = certificate.Thumbprint;
if (password is not null)
{
s_certificateCacheSemaphore.Release();
lookup += $"-{password}";
}

return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(lookup)));
}

/// <summary>
Expand Down Expand Up @@ -353,9 +425,17 @@ private static char[] ExportKeyPem(AsymmetricAlgorithm privateKey, string? passw
}

/// <summary>
/// Writes PFX and PEM key cache files. Best-effort; failures are silently ignored.
/// Writes the public certificate (.crt), PFX (.pfx) and PEM private key (.key) cache files.
/// Any of the file-name / payload pairs may be null to skip writing that file. Best-effort;
/// failures are silently ignored.
/// </summary>
private static void WriteCacheFiles(string pfxFileName, byte[]? pfxBytes, string keyFileName, char[]? keyPem)
private static void WriteCacheFiles(
string certFileName,
X509Certificate2 certificate,
string? pfxFileName,
byte[]? pfxBytes,
string? keyFileName,
byte[]? keyBytes)
{
try
{
Expand All @@ -368,14 +448,16 @@ private static void WriteCacheFiles(string pfxFileName, byte[]? pfxBytes, string
Directory.CreateDirectory(s_userDevCertificateLocation, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead);
}

if (pfxBytes is not null)
File.WriteAllText(certFileName, certificate.ExportCertificatePem());

if (pfxFileName is not null && pfxBytes is not null)
{
File.WriteAllBytes(pfxFileName, pfxBytes);
}

if (keyPem is not null)
if (keyFileName is not null && keyBytes is not null)
{
File.WriteAllBytes(keyFileName, Encoding.UTF8.GetBytes(keyPem));
File.WriteAllBytes(keyFileName, keyBytes);
}
}
catch
Expand Down
31 changes: 31 additions & 0 deletions src/Aspire.Hosting/Diagnostics/ProfilingTelemetry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ internal static class Activities
// Activity names describe AppHost/DCP orchestration work. Keep names stable
// because profiling exports are queried across CLI and AppHost versions.
public const string DcpRunApplication = "aspire.hosting.dcp.run_application";
public const string DcpPrepareTlsCertificate = "aspire.hosting.dcp.prepare_tls_certificate";
public const string AppHostProcessStartup = "aspire.hosting.apphost.process_startup";
public const string AppHostStart = "aspire.hosting.apphost.start";
public const string AppHostBeforeStart = "aspire.hosting.apphost.before_start";
Expand Down Expand Up @@ -110,6 +111,10 @@ internal static class Tags
public const string DcpKubernetesClientWaitMilliseconds = "aspire.dcp.kubernetes_client.wait_ms";
public const string DcpKubernetesClientAlreadyInitialized = "aspire.dcp.kubernetes_client_already_initialized";
public const string DcpKubernetesClientInitialized = "aspire.dcp.kubernetes_client.initialized";
public const string DcpTlsDeveloperCertificateEnabled = "aspire.dcp.tls.developer_certificate.enabled";
public const string DcpTlsCertificateMode = "aspire.dcp.tls.certificate.mode";
public const string DcpTlsCertificatePrepared = "aspire.dcp.tls.certificate.prepared";
public const string DcpTlsCertificateResult = "aspire.dcp.tls.certificate.result";
public const string BackchannelSocketPath = "aspire.hosting.backchannel.socket.path";
public const string PreviousResourceState = "aspire.resource.previous_state";
public const string PreviousResourceHealthStatus = "aspire.resource.previous_health_status";
Expand Down Expand Up @@ -170,6 +175,12 @@ internal static class Values
public const string DashboardUrlSourceNone = "none";
public const string DashboardUrlSourceResource = "resource";
public const string DashboardUrlSourceConfiguration = "configuration";
public const string DcpTlsCertificateModeFiles = "files";
public const string DcpTlsCertificateModeThumbprint = "thumbprint";
public const string DcpTlsCertificateResultMissingThumbprint = "missing_thumbprint";
public const string DcpTlsCertificateResultNoCertificate = "no_certificate";
public const string DcpTlsCertificateResultNoPrivateKeyCertificate = "no_private_key_certificate";
public const string DcpTlsCertificateResultPrepared = "prepared";
}

internal static class Annotations
Expand Down Expand Up @@ -254,6 +265,13 @@ public static ActivityScope StartDcpRunApplication(IConfiguration? configuration
return activity;
}

public static ActivityScope StartDcpPrepareTlsCertificate(IConfiguration? configuration)
{
var activity = StartActivity(configuration, Activities.DcpPrepareTlsCertificate);
activity.SetDcpTlsDeveloperCertificateEnabled(true);
return activity;
}

public static ActivityScope StartAppHostStart(IConfiguration? configuration, string entryPoint)
{
var activity = StartActivity(configuration, Activities.AppHostStart);
Expand Down Expand Up @@ -886,6 +904,19 @@ public void SetDcpKubernetesApi(DcpApiOperationType operationType, string resour
SetTag(Tags.DcpResourceKind, resourceType);
}

public void SetDcpTlsDeveloperCertificateEnabled(bool enabled) => SetTag(Tags.DcpTlsDeveloperCertificateEnabled, enabled);

public void SetDcpTlsCertificateResult(string result, string? mode = null, bool prepared = false)
{
SetTag(Tags.DcpTlsCertificateResult, result);
SetTag(Tags.DcpTlsCertificatePrepared, prepared);

if (!string.IsNullOrEmpty(mode))
{
SetTag(Tags.DcpTlsCertificateMode, mode);
}
}

public void SetAppHostEntryPoint(string entryPoint) => SetTag(Tags.AppHostEntryPoint, entryPoint);

public void SetAppHostEventSubscriberCount(int subscriberCount) => SetTag(Tags.AppHostEventSubscriberCount, subscriberCount);
Expand Down
Loading
Loading