Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
106 changes: 94 additions & 12 deletions src/Aspire.Hosting/Dcp/DcpHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
using System.Collections;
using System.IO.Pipelines;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
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 +40,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 +180,24 @@ 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))
{
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.
Resolved
}

// Use the first (latest/best) certificate that has a private key
Expand All @@ -212,20 +213,77 @@ 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 (keyPem, _) = await DeveloperCertificateService.GetKeyMaterialAsync(
certificate,
password: null,
needKeyPem: true,
needPfx: false,
cancellationToken).ConfigureAwait(false);

if (keyPem is null || keyPem.Length == 0)
{
_logger.LogWarning("Failed to export the developer certificate private key. DCP will use its default certificate.");
_dcpTlsCertThumbprint = null;
activity.SetDcpTlsCertificateResult(ProfilingTelemetry.Values.DcpTlsCertificateResultKeyExportFailed);
return;
}

try
{
var certificateDirectory = Path.Combine(_locations.DcpSessionDir, "tls");
Directory.CreateDirectory(certificateDirectory, UnixFileMode.UserExecute | UnixFileMode.UserWrite | UnixFileMode.UserRead);

var certificatePath = Path.Combine(certificateDirectory, "dcp-tls.crt");
var keyPath = Path.Combine(certificateDirectory, "dcp-tls.key");

await WriteFileWithUserOnlyPermissionsAsync(certificatePath, Encoding.UTF8.GetBytes(certificate.ExportCertificatePem()), cancellationToken).ConfigureAwait(false);

var keyBytes = Encoding.UTF8.GetBytes(keyPem);
try
{
await WriteFileWithUserOnlyPermissionsAsync(keyPath, keyBytes, cancellationToken).ConfigureAwait(false);
}
finally
{
CryptographicOperations.ZeroMemory(keyBytes);
}

_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);
}
finally
{
Array.Clear(keyPem);
}
}

public async Task StopAsync()
Expand Down Expand Up @@ -280,6 +338,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 Expand Up @@ -336,6 +399,25 @@ public ProcessSpec CreateDcpProcessSpec(Locations locations)
return dcpProcessSpec;
}

private static async Task WriteFileWithUserOnlyPermissionsAsync(string path, ReadOnlyMemory<byte> contents, CancellationToken cancellationToken)

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.

Is there a single place we write the dev cert so that its accessible by multiple consumers?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

There is, and I'm folding this into it (DeveloperCertificateService).

{
var options = new FileStreamOptions
{
Mode = FileMode.Create,
Access = FileAccess.Write,
Share = FileShare.Read
};

if (!OperatingSystem.IsWindows())
{
options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite;
}

using var stream = new FileStream(path, options);
Comment thread
danegsta marked this conversation as resolved.
Resolved

await stream.WriteAsync(contents, cancellationToken).ConfigureAwait(false);
}

private void SetDcpProfilingEnvironment(IDictionary<string, string> environmentVariables)
{
if (_configuration.GetBool(KnownConfigNames.ProfilingEnabled, KnownConfigNames.Legacy.StartupProfilingEnabled) is { } profilingEnabled)
Expand Down
32 changes: 32 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,13 @@ 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 DcpTlsCertificateResultKeyExportFailed = "key_export_failed";
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 +266,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 +905,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
78 changes: 74 additions & 4 deletions tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Aspire.Hosting.Dcp;
using Aspire.Hosting.Diagnostics;
using Aspire.Hosting.Resources;
using Aspire.Hosting.Tests.Utils;
using Microsoft.AspNetCore.InternalTesting;
Expand Down Expand Up @@ -474,19 +478,22 @@ public void CreateDcpProcessSpec_WithoutTlsCertThumbprint_DoesNotIncludeThumbpri

// Assert
Assert.DoesNotContain("--tls-cert-thumbprint", processSpec.Arguments);
Assert.DoesNotContain("--tls-cert-file", processSpec.Arguments);
Assert.DoesNotContain("--tls-key-file", processSpec.Arguments);
}

[Fact]
public async Task CreateDcpProcessSpec_WithTlsCertThumbprint_IncludesThumbprintArgument()
{
Assert.SkipUnless(OperatingSystem.IsWindows(), "Developer certificate thumbprint is only supported on Windows.");

// Arrange
using var certificate = CreateUntrustedCertificate();
var activities = new ConcurrentBag<Activity>();
using var listener = CreateActivityListener(ProfilingTelemetry.ActivitySourceName, activities.Add);
using var certificate = CreateExportableCertificate();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[KnownConfigNames.DcpDeveloperCertificate] = "true"
[KnownConfigNames.DcpDeveloperCertificate] = "true",
[KnownConfigNames.ProfilingEnabled] = "true"
})
.Build();
var dcpHost = CreateDcpHostForProcessSpecTests(
Expand All @@ -501,6 +508,26 @@ public async Task CreateDcpProcessSpec_WithTlsCertThumbprint_IncludesThumbprintA

// Assert
Assert.Contains($"--tls-cert-thumbprint \"{certificate.Thumbprint}\"", processSpec.Arguments);
var certificateActivity = Assert.Single(activities, activity => activity.OperationName == ProfilingTelemetry.Activities.DcpPrepareTlsCertificate);
Assert.Equal(true, certificateActivity.GetTagItem(ProfilingTelemetry.Tags.DcpTlsDeveloperCertificateEnabled));
Assert.Equal(ProfilingTelemetry.Values.DcpTlsCertificateResultPrepared, certificateActivity.GetTagItem(ProfilingTelemetry.Tags.DcpTlsCertificateResult));
Assert.Equal(true, certificateActivity.GetTagItem(ProfilingTelemetry.Tags.DcpTlsCertificatePrepared));

if (OperatingSystem.IsWindows())
{
Assert.DoesNotContain("--tls-cert-file", processSpec.Arguments);
Assert.DoesNotContain("--tls-key-file", processSpec.Arguments);
Assert.Equal(ProfilingTelemetry.Values.DcpTlsCertificateModeThumbprint, certificateActivity.GetTagItem(ProfilingTelemetry.Tags.DcpTlsCertificateMode));
}
else
{
var certificatePath = GetQuotedArgumentValue(processSpec.Arguments, "--tls-cert-file");
var keyPath = GetQuotedArgumentValue(processSpec.Arguments, "--tls-key-file");

Assert.Equal(certificate.ExportCertificatePem(), File.ReadAllText(certificatePath));
Assert.Contains("PRIVATE KEY", File.ReadAllText(keyPath));
Assert.Equal(ProfilingTelemetry.Values.DcpTlsCertificateModeFiles, certificateActivity.GetTagItem(ProfilingTelemetry.Tags.DcpTlsCertificateMode));
}
}

[Fact]
Expand All @@ -519,6 +546,8 @@ public async Task CreateDcpProcessSpec_WithDcpDeveloperCertificateDisabled_DoesN

// Assert - thumbprint should not appear because config is not enabled
Assert.DoesNotContain("--tls-cert-thumbprint", processSpec.Arguments);
Assert.DoesNotContain("--tls-cert-file", processSpec.Arguments);
Assert.DoesNotContain("--tls-key-file", processSpec.Arguments);
}

[Fact]
Expand Down Expand Up @@ -685,6 +714,47 @@ private static X509Certificate2 CreateUntrustedCertificate()
throw new FileNotFoundException("Could not locate test certificate file 'testCert.pfx' in expected locations.");
}

private static X509Certificate2 CreateExportableCertificate()
{
var subject = new X500DistinguishedName($"CN=aspire-test-{Guid.NewGuid():N}");

using var rsa = RSA.Create(2048);
var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(30));

return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), password: null, X509KeyStorageFlags.Exportable);
}

private static string GetQuotedArgumentValue(string? arguments, string option)
{
if (arguments is null)
{
throw new InvalidOperationException("Expected process arguments to be set.");
}

var prefix = $"{option} \"";
var start = arguments.IndexOf(prefix, StringComparison.Ordinal);
Assert.NotEqual(-1, start);
start += prefix.Length;

var end = arguments.IndexOf('"', start);
Assert.NotEqual(-1, end);

return arguments[start..end];
}

private static ActivityListener CreateActivityListener(string sourceName, Action<Activity> activityStopped)
{
var listener = new ActivityListener
{
ShouldListenTo = source => source.Name == sourceName,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = activityStopped
};
ActivitySource.AddActivityListener(listener);
return listener;
}

[Fact]
public async Task DcpHost_WithNoHttpsResources_DoesNotShowCertificateWarning()
{
Expand Down
Loading