From 364ac9958b692f64df91082855e0dc04e5c1fc8d Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 15 Jan 2026 13:39:30 -0800 Subject: [PATCH 01/15] Add logging if we detect the app host is running with an untrusted dev cert --- .../DeveloperCertificateService.cs | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index bb8a570272a..7ecba54c6b4 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -33,19 +33,19 @@ public DeveloperCertificateService(ILogger logger, // so we want to ensure the certificate that will be used by ASP.NET Core is the first one in the bundle. // Match the ordering logic ASP.NET Core uses, including DateTimeOffset.Now for current time: https://github.com/dotnet/aspnetcore/blob/0aefdae365ff9b73b52961acafd227309524ce3c/src/Shared/CertificateGeneration/CertificateManager.cs#L122 var now = DateTimeOffset.Now; - + // Get all valid ASP.NET Core development certificates var validCerts = store.Certificates .Where(c => c.IsAspNetCoreDevelopmentCertificate()) .Where(c => c.NotBefore <= now && now <= c.NotAfter) .ToList(); - + // If any certificate has a Subject Key Identifier extension, exclude certificates without it if (validCerts.Any(c => c.HasSubjectKeyIdentifier())) { validCerts = validCerts.Where(c => c.HasSubjectKeyIdentifier()).ToList(); } - + // Take the highest version valid certificate for each unique SKI devCerts.AddRange( validCerts @@ -91,6 +91,25 @@ public DeveloperCertificateService(ILogger logger, UseForHttps = (configuration.GetBool(KnownConfigNames.DeveloperCertificateDefaultHttpsTermination) ?? options.DeveloperCertificateDefaultHttpsTerminationEnabled ?? true ) && TrustCertificate && _supportsTlsTermination.Value; + + if (TrustCertificate && Certificates.Count > 0 && !IsCertificateTrustedInCurrentUserRoot(Certificates[0])) + { + var trustLocation = "your project folder"; + try + { + var appHostPath = options.AppHostFilePath; + var appHostDirectory = !string.IsNullOrWhiteSpace(appHostPath) ? Path.GetDirectoryName(appHostPath) : null; + if (!string.IsNullOrWhiteSpace(appHostDirectory)) + { + trustLocation = $"'{appHostDirectory}'"; + } + } + catch + { + // If path resolution fails, fall back to the default trust location. + } + logger.LogError("The most recent ASP.NET Core Development Certificate isn't fully trusted. Run 'dotnet dev-certs https --trust' from {TrustLocation} to trust the certificate.", trustLocation); + } } /// @@ -104,4 +123,25 @@ public DeveloperCertificateService(ILogger logger, /// public bool UseForHttps { get; } + + private static bool IsCertificateTrustedInCurrentUserRoot(X509Certificate2 certificate) + { + ArgumentNullException.ThrowIfNull(certificate); + + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadOnly); + + var matches = store.Certificates.Find(X509FindType.FindByThumbprint, certificate.Thumbprint, validOnly: false); + try + { + return matches.Count > 0; + } + finally + { + foreach (var cert in matches) + { + cert.Dispose(); + } + } + } } From 2191b7dbe7b5459388f54739b526cd5110022054 Mon Sep 17 00:00:00 2001 From: David Negstad <50252651+danegsta@users.noreply.github.com> Date: Thu, 15 Jan 2026 13:58:28 -0800 Subject: [PATCH 02/15] Update src/Aspire.Hosting/DeveloperCertificateService.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../DeveloperCertificateService.cs | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index 7ecba54c6b4..4ba2ecd5cea 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -128,20 +128,27 @@ private static bool IsCertificateTrustedInCurrentUserRoot(X509Certificate2 certi { ArgumentNullException.ThrowIfNull(certificate); - using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); - store.Open(OpenFlags.ReadOnly); - - var matches = store.Certificates.Find(X509FindType.FindByThumbprint, certificate.Thumbprint, validOnly: false); try { - return matches.Count > 0; - } - finally - { - foreach (var cert in matches) + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadOnly); + + var matches = store.Certificates.Find(X509FindType.FindByThumbprint, certificate.Thumbprint, validOnly: false); + try { - cert.Dispose(); + return matches.Count > 0; } + finally + { + foreach (var cert in matches) + { + cert.Dispose(); + } + } + } + catch (System.Exception) + { + return false; } } } From 22ff768676bab4834e0eebf094c9f7ac561ff6de Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 15 Jan 2026 15:51:46 -0800 Subject: [PATCH 03/15] Allow overriding the dev cert used by the dashboard --- .../Dashboard/DashboardEventHandlers.cs | 37 +++++++++++++++++++ .../Dashboard/DashboardResourceTests.cs | 29 +++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index b278b44a308..5ac91e69a6e 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. #pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only +#pragma warning disable ASPIRECERTIFICATES001 // Type is for evaluation purposes only using System.Collections.Concurrent; using System.Diagnostics; @@ -375,6 +376,9 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) var otlpHttpEndpointUrl = options.OtlpHttpEndpointUrl; var mcpEndpointUrl = options.McpEndpointUrl; + // Track whether any endpoint uses HTTPS + var hasHttpsEndpoint = false; + eventing.Subscribe(dashboardResource, (context, resource) => { var browserToken = options.DashboardToken; @@ -399,6 +403,7 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) foreach (var d in dashboardUrls?.Split(';', StringSplitOptions.RemoveEmptyEntries) ?? []) { var address = BindingAddress.Parse(d); + hasHttpsEndpoint |= address.Scheme is "https"; dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, uriScheme: address.Scheme, port: address.Port, isProxied: true) { @@ -409,6 +414,8 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) if (otlpGrpcEndpointUrl != null) { var address = BindingAddress.Parse(otlpGrpcEndpointUrl); + hasHttpsEndpoint |= address.Scheme is "https"; + dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: OtlpGrpcEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true, transport: "http2") { TargetHost = address.Host @@ -418,6 +425,8 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) if (otlpHttpEndpointUrl != null) { var address = BindingAddress.Parse(otlpHttpEndpointUrl); + hasHttpsEndpoint |= address.Scheme is "https"; + dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: OtlpHttpEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true) { TargetHost = address.Host @@ -427,12 +436,40 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) if (mcpEndpointUrl != null) { var address = BindingAddress.Parse(mcpEndpointUrl); + hasHttpsEndpoint |= address.Scheme is "https"; + dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: McpEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true) { TargetHost = address.Host }); } + if (hasHttpsEndpoint && + !dashboardResource.HasAnnotationOfType()) + { + var developerCertificateService = executionContext.ServiceProvider.GetRequiredService(); + var trustDeveloperCertificate = developerCertificateService.TrustCertificate; + if (dashboardResource.TryGetLastAnnotation(out var certificateAuthorityAnnotation)) + { + trustDeveloperCertificate = certificateAuthorityAnnotation.TrustDeveloperCertificates.GetValueOrDefault(trustDeveloperCertificate); + } + + if (trustDeveloperCertificate) + { + dashboardResource.Annotations.Add(new HttpsCertificateConfigurationCallbackAnnotation(ctx => + { + ctx.EnvironmentVariables["Kestrel__Certificates__Default__Path"] = ctx.CertificatePath; + ctx.EnvironmentVariables["Kestrel__Certificates__Default__KeyPath"] = ctx.KeyPath; + if (ctx.Password is not null) + { + ctx.EnvironmentVariables["Kestrel__Certificates__Default__Password"] = ctx.Password; + } + + return Task.CompletedTask; + })); + } + } + dashboardResource.Annotations.Add(new ResourceUrlsCallbackAnnotation(c => { foreach (var url in c.Urls) diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs index e9f599fbdd4..7ec6e2b082f 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -14,6 +14,8 @@ namespace Aspire.Hosting.Tests.Dashboard; +#pragma warning disable ASPIRECERTIFICATES001 // Type is for evaluation purposes only + public class DashboardResourceTests(ITestOutputHelper testOutputHelper) { [Theory] @@ -457,6 +459,33 @@ public async Task DashboardResource_OtlpGrpcEndpoint_CorsEnvVarNotSet(string? ex Assert.DoesNotContain(config, e => e.Key == DashboardConfigNames.DashboardOtlpCorsAllowedHeadersKeyName.EnvVarName); } + [Fact] + public async Task DashboardResource_HttpsEndpoint_ConfiguresKestrelCertificateCallback() + { + using var builder = TestDistributedApplicationBuilder.Create(options => + { + options.DisableDashboard = false; + options.TrustDeveloperCertificate = true; + }, testOutputHelper: testOutputHelper); + + builder.Configuration.Sources.Clear(); + + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [KnownConfigNames.AspNetCoreUrls] = "https://localhost", + [KnownConfigNames.DashboardOtlpGrpcEndpointUrl] = "http://localhost" + }); + + using var app = builder.Build(); + + await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout(); + + var model = app.Services.GetRequiredService(); + var dashboard = Assert.Single(model.Resources, r => r.Name == "aspire-dashboard"); + + Assert.True(dashboard.HasAnnotationOfType()); + } + [Fact] public async Task DashboardIsNotAddedInPublishMode() { From 27c4ee49409d8187edf962181e157ef5b65dd91f Mon Sep 17 00:00:00 2001 From: David Negstad Date: Thu, 15 Jan 2026 16:19:54 -0800 Subject: [PATCH 04/15] Also notify the dashboard if the certificate isn't trusted --- .../DeveloperCertificateService.cs | 65 +++++++++++-------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index 7ecba54c6b4..52ca4056367 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -1,4 +1,5 @@ #pragma warning disable ASPIRECERTIFICATES001 +#pragma warning disable ASPIREINTERACTION001 // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. @@ -17,8 +18,13 @@ internal class DeveloperCertificateService : IDeveloperCertificateService private readonly Lazy _supportsContainerTrust; private readonly Lazy _supportsTlsTermination; - public DeveloperCertificateService(ILogger logger, IConfiguration configuration, DistributedApplicationOptions options) + public DeveloperCertificateService(ILogger logger, IConfiguration configuration, DistributedApplicationOptions options, IInteractionService interactionService) { + // Environment variable config > DistributedApplicationOptions > default true + TrustCertificate = configuration.GetBool(KnownConfigNames.DeveloperCertificateDefaultTrust) ?? + options.TrustDeveloperCertificate ?? + true; + _certificates = new Lazy>(() => { try @@ -53,6 +59,37 @@ public DeveloperCertificateService(ILogger logger, .SelectMany(g => g.OrderByDescending(c => c.GetCertificateVersion()).ThenByDescending(c => c.NotAfter).Take(1)) .OrderByDescending(c => c.GetCertificateVersion()).ThenByDescending(c => c.NotAfter)); + if (TrustCertificate && devCerts.Count > 0 && !IsCertificateTrustedInCurrentUserRoot(devCerts[0])) + { + var trustLocation = "your project folder"; + var appHostDirectory = configuration["AppHost:Directory"]; + if (!string.IsNullOrWhiteSpace(appHostDirectory)) + { + trustLocation = $"'{appHostDirectory}'"; + } + + var message = $"The most recent ASP.NET Core Development Certificate isn't fully trusted. Run 'dotnet dev-certs https --trust' from {trustLocation} to trust the certificate."; + + logger.LogError("{Message}", message); + + // Send notification to the dashboard + _ = interactionService.PromptNotificationAsync( + title: "Developer Certificate Not Trusted", + message: message, + options: new NotificationInteractionOptions + { + Intent = MessageIntent.Error, + LinkText = "Learn more", + LinkUrl = "https://aka.ms/dotnet/aspire/dev-certs" + }); + } + + // Release the unused certificates + foreach (var unusedCert in validCerts.Except(devCerts)) + { + unusedCert.Dispose(); + } + if (devCerts.Count == 0) { logger.LogInformation("No ASP.NET Core developer certificates found in the CurrentUser/My certificate store."); @@ -82,34 +119,10 @@ public DeveloperCertificateService(ILogger logger, return supportsTlsTermination; }); - // Environment variable config > DistributedApplicationOptions > default true - TrustCertificate = configuration.GetBool(KnownConfigNames.DeveloperCertificateDefaultTrust) ?? - options.TrustDeveloperCertificate ?? - true; - // By default, only use for server authentication if trust is also enabled (and a developer certificate with a private key is available) UseForHttps = (configuration.GetBool(KnownConfigNames.DeveloperCertificateDefaultHttpsTermination) ?? options.DeveloperCertificateDefaultHttpsTerminationEnabled ?? - true ) && TrustCertificate && _supportsTlsTermination.Value; - - if (TrustCertificate && Certificates.Count > 0 && !IsCertificateTrustedInCurrentUserRoot(Certificates[0])) - { - var trustLocation = "your project folder"; - try - { - var appHostPath = options.AppHostFilePath; - var appHostDirectory = !string.IsNullOrWhiteSpace(appHostPath) ? Path.GetDirectoryName(appHostPath) : null; - if (!string.IsNullOrWhiteSpace(appHostDirectory)) - { - trustLocation = $"'{appHostDirectory}'"; - } - } - catch - { - // If path resolution fails, fall back to the default trust location. - } - logger.LogError("The most recent ASP.NET Core Development Certificate isn't fully trusted. Run 'dotnet dev-certs https --trust' from {TrustLocation} to trust the certificate.", trustLocation); - } + true) && TrustCertificate && _supportsTlsTermination.Value; } /// From 0d30edf037f1000a800d249618dc81872c7b427b Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 16 Jan 2026 13:16:40 -0800 Subject: [PATCH 05/15] Update trust check to handle MacOS --- .../DeveloperCertificateService.cs | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index f0471511ad0..8a66d700c1d 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Collections.Immutable; +using System.Diagnostics; using System.Security.Cryptography.X509Certificates; namespace Aspire.Hosting; @@ -59,7 +60,7 @@ public DeveloperCertificateService(ILogger logger, .SelectMany(g => g.OrderByDescending(c => c.GetCertificateVersion()).ThenByDescending(c => c.NotAfter).Take(1)) .OrderByDescending(c => c.GetCertificateVersion()).ThenByDescending(c => c.NotAfter)); - if (TrustCertificate && devCerts.Count > 0 && !IsCertificateTrustedInCurrentUserRoot(devCerts[0])) + if (TrustCertificate && devCerts.Count > 0 && !IsCertificateTrusted(devCerts[0])) { var trustLocation = "your project folder"; var appHostDirectory = configuration["AppHost:Directory"]; @@ -137,10 +138,15 @@ public DeveloperCertificateService(ILogger logger, /// public bool UseForHttps { get; } - private static bool IsCertificateTrustedInCurrentUserRoot(X509Certificate2 certificate) + private static bool IsCertificateTrusted(X509Certificate2 certificate) { ArgumentNullException.ThrowIfNull(certificate); + if (OperatingSystem.IsMacOS()) + { + return IsCertificateTrustedInMacOsKeychain(certificate); + } + try { using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); @@ -164,4 +170,52 @@ private static bool IsCertificateTrustedInCurrentUserRoot(X509Certificate2 certi return false; } } + + // Use the same approach as `dotnet dev-certs` to check if the certificate is trusted in the macOS keychain + private static bool IsCertificateTrustedInMacOsKeychain(X509Certificate2 certificate) + { + try + { + var certPath = Path.Combine(Path.GetTempPath(), $"aspire-devcert-{certificate.Thumbprint}.cer"); + File.WriteAllBytes(certPath, certificate.Export(X509ContentType.Cert)); + + try + { + var startInfo = new ProcessStartInfo + { + FileName = "security", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + startInfo.ArgumentList.Add("verify-cert"); + startInfo.ArgumentList.Add("-p"); + startInfo.ArgumentList.Add("basic"); + startInfo.ArgumentList.Add("-p"); + startInfo.ArgumentList.Add("ssl"); + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add(certPath); + + using var process = Process.Start(startInfo); + if (process is null) + { + return false; + } + + process.WaitForExit(); + return process.ExitCode == 0; + } + finally + { + File.Delete(certPath); + } + } + catch + { + // Ignore errors and assume not trusted + return false; + } + } } From 19cb55166d5c8c84fa49e9b346b9a849f65eb3ed Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 16 Jan 2026 13:25:50 -0800 Subject: [PATCH 06/15] Add link to dev-certs code --- src/Aspire.Hosting/DeveloperCertificateService.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index 8a66d700c1d..1c6734861cb 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -172,6 +172,7 @@ private static bool IsCertificateTrusted(X509Certificate2 certificate) } // Use the same approach as `dotnet dev-certs` to check if the certificate is trusted in the macOS keychain + // See: https://github.com/dotnet/aspnetcore/blob/2a88012113497bac5056548f16d810738b069198/src/Shared/CertificateGeneration/MacOSCertificateManager.cs#L36-L37 private static bool IsCertificateTrustedInMacOsKeychain(X509Certificate2 certificate) { try From 45ea6a3b77cc81baed66ed67a27919ca98708efb Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 16 Jan 2026 13:50:16 -0800 Subject: [PATCH 07/15] Cleanup some of the exceptions --- src/Aspire.Hosting/DeveloperCertificateService.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index 1c6734861cb..c11b6d39981 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -144,11 +144,13 @@ private static bool IsCertificateTrusted(X509Certificate2 certificate) if (OperatingSystem.IsMacOS()) { + // On MacOS we have to verify against the Keychain return IsCertificateTrustedInMacOsKeychain(certificate); } try { + // On Linux and Windows, we need to check if the certificate is in the Root store using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); store.Open(OpenFlags.ReadOnly); @@ -165,8 +167,9 @@ private static bool IsCertificateTrusted(X509Certificate2 certificate) } } } - catch (System.Exception) + catch { + // Ignore errors and assume not trusted return false; } } From f7b3ade10d586780fa5249c0cd1a9b489ce7b457 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 16 Jan 2026 15:05:42 -0800 Subject: [PATCH 08/15] Move forward unlocking the macos keychain and trust the cert --- .github/workflows/run-tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5114af9d611..b6db686110b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -106,8 +106,12 @@ jobs: env: DOTNET_INSTALL_DIR: ${{ env.DOTNET_ROOT }} + - name: Unlock macOS keychain for certificate tests + if: runner.os == 'macOS' + uses: ./.github/actions/unlock-macos-keychain + - name: Trust HTTPS development certificate (Linux) - if: inputs.os == 'ubuntu-latest' + if: ${{ inputs.os == 'ubuntu-latest' || inputs.os == 'macos-latest' }} run: ${{ env.DOTNET_SCRIPT }} dev-certs https --trust - name: Verify Docker is running @@ -271,10 +275,6 @@ jobs: Write-Output "Unzipped $zipFilePath to $destinationPath" - - name: Unlock macOS keychain for certificate tests - if: runner.os == 'macOS' - uses: ./.github/actions/unlock-macos-keychain - - name: Run nuget dependent tests (Linux/macOS) if: ${{ inputs.requiresNugets && (inputs.os == 'ubuntu-latest' || inputs.os == 'macos-latest') }} working-directory: ${{ github.workspace }}/run-tests/ From d6f780d21e514c00fa369bef68163d75951afb59 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 16 Jan 2026 17:10:58 -0800 Subject: [PATCH 09/15] Trusting on Mac isn't going to be an option; switch to a warning in logs --- .github/workflows/run-tests.yml | 10 +++++----- src/Aspire.Hosting/DeveloperCertificateService.cs | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b6db686110b..5114af9d611 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -106,12 +106,8 @@ jobs: env: DOTNET_INSTALL_DIR: ${{ env.DOTNET_ROOT }} - - name: Unlock macOS keychain for certificate tests - if: runner.os == 'macOS' - uses: ./.github/actions/unlock-macos-keychain - - name: Trust HTTPS development certificate (Linux) - if: ${{ inputs.os == 'ubuntu-latest' || inputs.os == 'macos-latest' }} + if: inputs.os == 'ubuntu-latest' run: ${{ env.DOTNET_SCRIPT }} dev-certs https --trust - name: Verify Docker is running @@ -275,6 +271,10 @@ jobs: Write-Output "Unzipped $zipFilePath to $destinationPath" + - name: Unlock macOS keychain for certificate tests + if: runner.os == 'macOS' + uses: ./.github/actions/unlock-macos-keychain + - name: Run nuget dependent tests (Linux/macOS) if: ${{ inputs.requiresNugets && (inputs.os == 'ubuntu-latest' || inputs.os == 'macos-latest') }} working-directory: ${{ github.workspace }}/run-tests/ diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index c11b6d39981..b038e8ce415 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -71,7 +71,7 @@ public DeveloperCertificateService(ILogger logger, var message = $"The most recent ASP.NET Core Development Certificate isn't fully trusted. Run 'dotnet dev-certs https --trust' from {trustLocation} to trust the certificate."; - logger.LogError("{Message}", message); + logger.LogWarning("{Message}", message); // Send notification to the dashboard _ = interactionService.PromptNotificationAsync( From 360c705e23b7b257e45add0cdb278700d2222c88 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 23 Jan 2026 17:32:47 -0800 Subject: [PATCH 10/15] Use resource strings, move check and logging to DcpHost setup --- src/Aspire.Hosting/AspireEventSource.cs | 18 ++++ src/Aspire.Hosting/Dcp/DcpHost.cs | 60 +++++++++++-- .../DeveloperCertificateService.cs | 30 +------ .../Resources/InteractionStrings.Designer.cs | 90 +++++++++++-------- .../Resources/InteractionStrings.resx | 60 +++++++------ 5 files changed, 161 insertions(+), 97 deletions(-) diff --git a/src/Aspire.Hosting/AspireEventSource.cs b/src/Aspire.Hosting/AspireEventSource.cs index cdea12bded7..9ff1b6a6c3e 100644 --- a/src/Aspire.Hosting/AspireEventSource.cs +++ b/src/Aspire.Hosting/AspireEventSource.cs @@ -370,4 +370,22 @@ public void StartResourceStop(string kind, string resourceName) WriteEvent(42, kind, resourceName); } } + + [Event(43, Level = EventLevel.Informational, Message = "Development certificate trust check is starting...")] + public void DevelopmentCertificateTrustCheckStart() + { + if (IsEnabled()) + { + WriteEvent(43); + } + } + + [Event(44, Level = EventLevel.Informational, Message = "Development certificate trust check completed")] + public void DevelopmentCertificateTrustCheckStop() + { + if (IsEnabled()) + { + WriteEvent(44); + } + } } diff --git a/src/Aspire.Hosting/Dcp/DcpHost.cs b/src/Aspire.Hosting/Dcp/DcpHost.cs index bb8ca80ce29..44307a4022f 100644 --- a/src/Aspire.Hosting/Dcp/DcpHost.cs +++ b/src/Aspire.Hosting/Dcp/DcpHost.cs @@ -3,6 +3,7 @@ using System.Buffers; using System.Collections; +using System.Globalization; using System.IO.Pipelines; using System.Net.Sockets; using System.Text; @@ -10,12 +11,14 @@ using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Resources; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Aspire.Hosting.Dcp; #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. internal sealed class DcpHost { @@ -29,6 +32,8 @@ internal sealed class DcpHost private readonly IInteractionService _interactionService; private readonly Locations _locations; private readonly TimeProvider _timeProvider; + private readonly IDeveloperCertificateService _developerCertificateService; + private readonly IConfiguration _configuration; private readonly CancellationTokenSource _shutdownCts = new(); private Task? _logProcessorTask; @@ -48,7 +53,9 @@ public DcpHost( IInteractionService interactionService, Locations locations, DistributedApplicationModel applicationModel, - TimeProvider timeProvider) + TimeProvider timeProvider, + IDeveloperCertificateService developerCertificateService, + IConfiguration configuration) { _loggerFactory = loggerFactory; _logger = loggerFactory.CreateLogger(); @@ -58,11 +65,14 @@ public DcpHost( _locations = locations; _applicationModel = applicationModel; _timeProvider = timeProvider; + _developerCertificateService = developerCertificateService; + _configuration = configuration; } public async Task StartAsync(CancellationToken cancellationToken) { await EnsureDcpContainerRuntimeAsync(cancellationToken).ConfigureAwait(false); + await EnsureDevelopmentCertificateTrustAsync(cancellationToken).ConfigureAwait(false); EnsureDcpHostRunning(); } @@ -111,7 +121,7 @@ internal async Task EnsureDcpContainerRuntimeAsync(CancellationToken cancellatio if (dcpInfo is not null) { DcpDependencyCheck.CheckDcpInfoAndLogErrors(_logger, _dcpOptions, dcpInfo, throwIfUnhealthy: requireContainerRuntimeInitialization); - + // Show UI notification if container runtime is unhealthy TryShowContainerRuntimeNotification(dcpInfo, cancellationToken); } @@ -122,6 +132,44 @@ internal async Task EnsureDcpContainerRuntimeAsync(CancellationToken cancellatio } } + internal async Task EnsureDevelopmentCertificateTrustAsync(CancellationToken cancellationToken) + { + AspireEventSource.Instance.DevelopmentCertificateTrustCheckStart(); + + try + { + // Check and warn if the developer certificate is not trusted + if (_developerCertificateService.TrustCertificate && _developerCertificateService.Certificates.Count > 0 && !DeveloperCertificateService.IsCertificateTrusted(_developerCertificateService.Certificates.First())) + { + var trustLocation = "your project folder"; + var appHostDirectory = _configuration["AppHost:Directory"]; + if (!string.IsNullOrWhiteSpace(appHostDirectory)) + { + trustLocation = $"'{appHostDirectory}'"; + } + + var title = InteractionStrings.DeveloperCertificateNotFullyTrustedTitle; + var message = string.Format(CultureInfo.CurrentCulture, InteractionStrings.DeveloperCertificateNotFullyTrustedMessage, trustLocation); + + _logger.LogWarning("{Message}", message); + + // Send notification to the dashboard + _ = _interactionService.PromptNotificationAsync( + title: title, + message: message, + options: new NotificationInteractionOptions + { + Intent = MessageIntent.Error, + }, + cancellationToken: cancellationToken); + } + } + finally + { + AspireEventSource.Instance.DevelopmentCertificateTrustCheckStop(); + } + } + public async Task StopAsync() { _shutdownCts.Cancel(); @@ -408,7 +456,7 @@ private void TryShowContainerRuntimeNotification(DcpInfo dcpInfo, CancellationTo // Create a cancellation token source that can be cancelled when runtime becomes healthy var notificationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _shutdownCts.Token); - + // Single background task to show notification and poll for health updates _ = Task.Run(async () => { @@ -424,7 +472,7 @@ private void TryShowContainerRuntimeNotification(DcpInfo dcpInfo, CancellationTo try { var dcpInfo = await _dependencyCheckService.GetDcpInfoAsync(force: true, cancellationToken: notificationCts.Token).ConfigureAwait(false); - + if (dcpInfo is not null && IsContainerRuntimeHealthy(dcpInfo)) { // Container runtime is now healthy, exit the polling loop @@ -442,10 +490,10 @@ private void TryShowContainerRuntimeNotification(DcpInfo dcpInfo, CancellationTo _logger.LogDebug(ex, "Error while polling container runtime health for notification"); } } - + // Cancel the notification at the end of the loop notificationCts.Cancel(); - + // Wait for notification task to complete try { diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index b038e8ce415..000d1bf22a1 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -1,5 +1,4 @@ #pragma warning disable ASPIRECERTIFICATES001 -#pragma warning disable ASPIREINTERACTION001 // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. @@ -19,7 +18,7 @@ internal class DeveloperCertificateService : IDeveloperCertificateService private readonly Lazy _supportsContainerTrust; private readonly Lazy _supportsTlsTermination; - public DeveloperCertificateService(ILogger logger, IConfiguration configuration, DistributedApplicationOptions options, IInteractionService interactionService) + public DeveloperCertificateService(ILogger logger, IConfiguration configuration, DistributedApplicationOptions options) { // Environment variable config > DistributedApplicationOptions > default true TrustCertificate = configuration.GetBool(KnownConfigNames.DeveloperCertificateDefaultTrust) ?? @@ -60,31 +59,6 @@ public DeveloperCertificateService(ILogger logger, .SelectMany(g => g.OrderByDescending(c => c.GetCertificateVersion()).ThenByDescending(c => c.NotAfter).Take(1)) .OrderByDescending(c => c.GetCertificateVersion()).ThenByDescending(c => c.NotAfter)); - if (TrustCertificate && devCerts.Count > 0 && !IsCertificateTrusted(devCerts[0])) - { - var trustLocation = "your project folder"; - var appHostDirectory = configuration["AppHost:Directory"]; - if (!string.IsNullOrWhiteSpace(appHostDirectory)) - { - trustLocation = $"'{appHostDirectory}'"; - } - - var message = $"The most recent ASP.NET Core Development Certificate isn't fully trusted. Run 'dotnet dev-certs https --trust' from {trustLocation} to trust the certificate."; - - logger.LogWarning("{Message}", message); - - // Send notification to the dashboard - _ = interactionService.PromptNotificationAsync( - title: "Developer Certificate Not Trusted", - message: message, - options: new NotificationInteractionOptions - { - Intent = MessageIntent.Error, - LinkText = "Learn more", - LinkUrl = "https://aka.ms/dotnet/aspire/dev-certs" - }); - } - // Release the unused certificates foreach (var unusedCert in validCerts.Except(devCerts)) { @@ -138,7 +112,7 @@ public DeveloperCertificateService(ILogger logger, /// public bool UseForHttps { get; } - private static bool IsCertificateTrusted(X509Certificate2 certificate) + internal static bool IsCertificateTrusted(X509Certificate2 certificate) { ArgumentNullException.ThrowIfNull(certificate); diff --git a/src/Aspire.Hosting/Resources/InteractionStrings.Designer.cs b/src/Aspire.Hosting/Resources/InteractionStrings.Designer.cs index b04ef4bab94..4c733eba634 100644 --- a/src/Aspire.Hosting/Resources/InteractionStrings.Designer.cs +++ b/src/Aspire.Hosting/Resources/InteractionStrings.Designer.cs @@ -10,8 +10,8 @@ namespace Aspire.Hosting.Resources { using System; - - + + /// /// A strongly-typed resource class, for looking up localized strings, etc. /// @@ -23,15 +23,15 @@ namespace Aspire.Hosting.Resources { [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class InteractionStrings { - + private static global::System.Resources.ResourceManager resourceMan; - + private static global::System.Globalization.CultureInfo resourceCulture; - + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] internal InteractionStrings() { } - + /// /// Returns the cached ResourceManager instance used by this class. /// @@ -45,7 +45,7 @@ internal InteractionStrings() { return resourceMan; } } - + /// /// Overrides the current thread's CurrentUICulture property for all /// resource lookups using this strongly typed resource class. @@ -59,7 +59,7 @@ internal InteractionStrings() { resourceCulture = value; } } - + /// /// Looks up a localized string similar to Ensure that Docker is running and that the Docker daemon is accessible. If Resource Saver mode is enabled, containers may not run.. /// @@ -68,7 +68,7 @@ internal static string ContainerRuntimeDockerAdvice { return ResourceManager.GetString("ContainerRuntimeDockerAdvice", resourceCulture); } } - + /// /// Looks up a localized string similar to Ensure that the container runtime is running.. /// @@ -77,7 +77,7 @@ internal static string ContainerRuntimeGenericAdvice { return ResourceManager.GetString("ContainerRuntimeGenericAdvice", resourceCulture); } } - + /// /// Looks up a localized string similar to Learn more. /// @@ -86,7 +86,7 @@ internal static string ContainerRuntimeLinkText { return ResourceManager.GetString("ContainerRuntimeLinkText", resourceCulture); } } - + /// /// Looks up a localized string similar to Container runtime could not be found. See https://aka.ms/dotnet/aspire/containers for more details on supported container runtimes.. /// @@ -95,7 +95,7 @@ internal static string ContainerRuntimeNotInstalledMessage { return ResourceManager.GetString("ContainerRuntimeNotInstalledMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Container runtime not installed. /// @@ -104,7 +104,25 @@ internal static string ContainerRuntimeNotInstalledTitle { return ResourceManager.GetString("ContainerRuntimeNotInstalledTitle", resourceCulture); } } - + + /// + /// Looks up a localized string similar to Development certificate not fully trusted. + /// + internal static string DeveloperCertificateNotFullyTrustedTitle { + get { + return ResourceManager.GetString("DeveloperCertificateNotFullyTrustedTitle", resourceCulture); + } + } + + /// + /// The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate.. + /// + internal static string DeveloperCertificateNotFullyTrustedMessage { + get { + return ResourceManager.GetString("DeveloperCertificateNotFullyTrustedMessage", resourceCulture); + } + } + /// /// Looks up a localized string similar to Ensure that Podman is running.. /// @@ -113,7 +131,7 @@ internal static string ContainerRuntimePodmanAdvice { return ResourceManager.GetString("ContainerRuntimePodmanAdvice", resourceCulture); } } - + /// /// Looks up a localized string similar to Container runtime '{0}' was found but appears to be unhealthy. . /// @@ -122,7 +140,7 @@ internal static string ContainerRuntimeUnhealthyMessage { return ResourceManager.GetString("ContainerRuntimeUnhealthyMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Container runtime unhealthy. /// @@ -131,7 +149,7 @@ internal static string ContainerRuntimeUnhealthyTitle { return ResourceManager.GetString("ContainerRuntimeUnhealthyTitle", resourceCulture); } } - + /// /// Looks up a localized string similar to Are you sure you want to delete the value for parameter `{0}`? . /// @@ -140,7 +158,7 @@ internal static string DeleteParameterMessage { return ResourceManager.GetString("DeleteParameterMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Are you sure you want to delete the value for parameter `{0}`? /// @@ -151,7 +169,7 @@ internal static string DeleteParameterMessageWithUserSecrets { return ResourceManager.GetString("DeleteParameterMessageWithUserSecrets", resourceCulture); } } - + /// /// Looks up a localized string similar to Delete. /// @@ -160,7 +178,7 @@ internal static string DeleteParameterPrimaryButtonText { return ResourceManager.GetString("DeleteParameterPrimaryButtonText", resourceCulture); } } - + /// /// Looks up a localized string similar to Delete parameter value. /// @@ -169,7 +187,7 @@ internal static string DeleteParameterTitle { return ResourceManager.GetString("DeleteParameterTitle", resourceCulture); } } - + /// /// Looks up a localized string similar to There are unresolved parameters that need to be set. Please provide values for them.. /// @@ -178,7 +196,7 @@ internal static string ParametersBarMessage { return ResourceManager.GetString("ParametersBarMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Enter values. /// @@ -187,7 +205,7 @@ internal static string ParametersBarPrimaryButtonText { return ResourceManager.GetString("ParametersBarPrimaryButtonText", resourceCulture); } } - + /// /// Looks up a localized string similar to Unresolved parameters. /// @@ -196,7 +214,7 @@ internal static string ParametersBarTitle { return ResourceManager.GetString("ParametersBarTitle", resourceCulture); } } - + /// /// Looks up a localized string similar to Delete from user secrets. /// @@ -205,7 +223,7 @@ internal static string ParametersInputsDeleteLabel { return ResourceManager.GetString("ParametersInputsDeleteLabel", resourceCulture); } } - + /// /// Looks up a localized string similar to Please provide values for the unresolved parameters. Parameters can be saved to [user secrets](https://aka.ms/aspire/user-secrets) for future use.. /// @@ -214,7 +232,7 @@ internal static string ParametersInputsMessage { return ResourceManager.GetString("ParametersInputsMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Please provide values for the unresolved parameters.. /// @@ -223,7 +241,7 @@ internal static string ParametersInputsMessagePublishMode { return ResourceManager.GetString("ParametersInputsMessagePublishMode", resourceCulture); } } - + /// /// Looks up a localized string similar to Enter value for {0}. /// @@ -232,7 +250,7 @@ internal static string ParametersInputsParameterPlaceholder { return ResourceManager.GetString("ParametersInputsParameterPlaceholder", resourceCulture); } } - + /// /// Looks up a localized string similar to Save. /// @@ -241,7 +259,7 @@ internal static string ParametersInputsPrimaryButtonText { return ResourceManager.GetString("ParametersInputsPrimaryButtonText", resourceCulture); } } - + /// /// Looks up a localized string similar to Save to user secrets. /// @@ -250,7 +268,7 @@ internal static string ParametersInputsRememberLabel { return ResourceManager.GetString("ParametersInputsRememberLabel", resourceCulture); } } - + /// /// Looks up a localized string similar to Requires a `UserSecretsId` to be configured in the AppHost project file. Run `dotnet user-secrets init` in the AppHost directory to configure.. /// @@ -259,7 +277,7 @@ internal static string ParametersInputsRememberDescriptionNotConfigured { return ResourceManager.GetString("ParametersInputsRememberDescriptionNotConfigured", resourceCulture); } } - + /// /// Looks up a localized string similar to Set unresolved parameters. /// @@ -268,7 +286,7 @@ internal static string ParametersInputsTitle { return ResourceManager.GetString("ParametersInputsTitle", resourceCulture); } } - + /// /// Looks up a localized string similar to Please provide a value for the parameter. The parameter can be saved to [user secrets](https://learn.microsoft.com/aspnet/core/security/app-secrets) for future use. /// @@ -279,7 +297,7 @@ internal static string SetParameterMessage { return ResourceManager.GetString("SetParameterMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Set parameter. /// @@ -288,7 +306,7 @@ internal static string SetParameterTitle { return ResourceManager.GetString("SetParameterTitle", resourceCulture); } } - + /// /// Looks up a localized string similar to Upgrade instructions. /// @@ -297,7 +315,7 @@ internal static string VersionCheckLinkText { return ResourceManager.GetString("VersionCheckLinkText", resourceCulture); } } - + /// /// Looks up a localized string similar to Aspire {0} is available.. /// @@ -306,7 +324,7 @@ internal static string VersionCheckMessage { return ResourceManager.GetString("VersionCheckMessage", resourceCulture); } } - + /// /// Looks up a localized string similar to Ignore. /// @@ -315,7 +333,7 @@ internal static string VersionCheckPrimaryButtonText { return ResourceManager.GetString("VersionCheckPrimaryButtonText", resourceCulture); } } - + /// /// Looks up a localized string similar to Update now. /// diff --git a/src/Aspire.Hosting/Resources/InteractionStrings.resx b/src/Aspire.Hosting/Resources/InteractionStrings.resx index 1c945400c4a..a06681257f4 100644 --- a/src/Aspire.Hosting/Resources/InteractionStrings.resx +++ b/src/Aspire.Hosting/Resources/InteractionStrings.resx @@ -1,17 +1,17 @@ - @@ -184,6 +184,12 @@ Container runtime could not be found. See https://aka.ms/dotnet/aspire/containers for more details on supported container runtimes. + + Development certificate not fully trusted + + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + Set parameter From 9165cfde984e7f58cd76854c8c8c0cc560c04c67 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 23 Jan 2026 17:41:01 -0800 Subject: [PATCH 11/15] Update how HTTPS check is determined --- .../Dashboard/DashboardEventHandlers.cs | 14 +++++--------- .../Resources/xlf/InteractionStrings.cs.xlf | 10 ++++++++++ .../Resources/xlf/InteractionStrings.de.xlf | 10 ++++++++++ .../Resources/xlf/InteractionStrings.es.xlf | 10 ++++++++++ .../Resources/xlf/InteractionStrings.fr.xlf | 10 ++++++++++ .../Resources/xlf/InteractionStrings.it.xlf | 10 ++++++++++ .../Resources/xlf/InteractionStrings.ja.xlf | 10 ++++++++++ .../Resources/xlf/InteractionStrings.ko.xlf | 10 ++++++++++ .../Resources/xlf/InteractionStrings.pl.xlf | 10 ++++++++++ .../Resources/xlf/InteractionStrings.pt-BR.xlf | 10 ++++++++++ .../Resources/xlf/InteractionStrings.ru.xlf | 10 ++++++++++ .../Resources/xlf/InteractionStrings.tr.xlf | 10 ++++++++++ .../Resources/xlf/InteractionStrings.zh-Hans.xlf | 10 ++++++++++ .../Resources/xlf/InteractionStrings.zh-Hant.xlf | 10 ++++++++++ 14 files changed, 135 insertions(+), 9 deletions(-) diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index f4737fe2905..929144e9fd8 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -379,9 +379,6 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) var otlpHttpEndpointUrl = options.OtlpHttpEndpointUrl; var mcpEndpointUrl = options.McpEndpointUrl; - // Track whether any endpoint uses HTTPS - var hasHttpsEndpoint = false; - eventing.Subscribe(dashboardResource, (context, resource) => { var browserToken = options.DashboardToken; @@ -406,7 +403,6 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) foreach (var d in dashboardUrls?.Split(';', StringSplitOptions.RemoveEmptyEntries) ?? []) { var address = BindingAddress.Parse(d); - hasHttpsEndpoint |= address.Scheme is "https"; dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, uriScheme: address.Scheme, port: address.Port, isProxied: true) { @@ -417,7 +413,6 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) if (otlpGrpcEndpointUrl != null) { var address = BindingAddress.Parse(otlpGrpcEndpointUrl); - hasHttpsEndpoint |= address.Scheme is "https"; dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: OtlpGrpcEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true, transport: "http2") { @@ -428,7 +423,6 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) if (otlpHttpEndpointUrl != null) { var address = BindingAddress.Parse(otlpHttpEndpointUrl); - hasHttpsEndpoint |= address.Scheme is "https"; dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: OtlpHttpEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true) { @@ -439,7 +433,6 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) if (mcpEndpointUrl != null) { var address = BindingAddress.Parse(mcpEndpointUrl); - hasHttpsEndpoint |= address.Scheme is "https"; dashboardResource.Annotations.Add(new EndpointAnnotation(ProtocolType.Tcp, name: McpEndpointName, uriScheme: address.Scheme, port: address.Port, isProxied: true) { @@ -447,6 +440,9 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) }); } + // Determine whether any HTTPS endpoints are configured + var hasHttpsEndpoint = dashboardResource.TryGetAnnotationsOfType(out var endpoints) && endpoints.Any(e => e.UriScheme is "https"); + if (hasHttpsEndpoint && !dashboardResource.HasAnnotationOfType()) { @@ -476,7 +472,7 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) dashboardResource.Annotations.Add(new ResourceUrlsCallbackAnnotation(c => { var browserToken = options.DashboardToken; - + foreach (var url in c.Urls) { if (url.Endpoint is { } endpoint) @@ -487,7 +483,7 @@ private void ConfigureAspireDashboardResource(IResource dashboardResource) // Order these before non-browser usable endpoints. url.DisplayText = $"Dashboard ({endpoint.EndpointName})"; url.DisplayOrder = 1; - + // Append the browser token to the URL as a query string parameter if token is configured if (!string.IsNullOrEmpty(browserToken)) { diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.cs.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.cs.xlf index 2fe268b765f..1975a22a6ca 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.cs.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.cs.xlf @@ -66,6 +66,16 @@ Hodnota je aktuálně uložena v [tajných klíčích uživatele](https://learn. Odstranit hodnotu parametru + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. Existují nevyřešené parametry, které je třeba nastavit. Zadejte pro ně hodnoty. diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.de.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.de.xlf index e7ffd85fa8a..b0f00d9504d 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.de.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.de.xlf @@ -66,6 +66,16 @@ Der Wert wird derzeit in [Benutzergeheimnissen](https://learn.microsoft.com/aspn Parameterwert löschen + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. Es gibt nicht aufgelöste Parameter, die festgelegt werden müssen. Geben Sie Werte für diese an. diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.es.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.es.xlf index d2f8914a374..cd31e0d2d44 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.es.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.es.xlf @@ -66,6 +66,16 @@ El valor se guarda actualmente en [secretos de usuario](https://learn.microsoft. Eliminar valor de parámetro + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. Hay parámetros sin resolver que deben configurarse. Proporcione valores para ellos. diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.fr.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.fr.xlf index cc0ef874bc5..30d4bf04304 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.fr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.fr.xlf @@ -66,6 +66,16 @@ La valeur est actuellement enregistrée dans [secrets utilisateur](https://learn Supprimer la valeur du paramètre + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. Vous devez définir des paramètres non résolus. Veuillez leur fournir des valeurs. diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.it.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.it.xlf index 69f9697134e..527b6c3d3dd 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.it.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.it.xlf @@ -66,6 +66,16 @@ Il valore è attualmente salvato nei [segreti utente](https://learn.microsoft.co Elimina valore parametro + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. Sono presenti parametri non risolti che devono essere impostati. Specifica i valori per questi parametri. diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.ja.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.ja.xlf index 3b12632817e..c0ecbcb6db0 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.ja.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.ja.xlf @@ -66,6 +66,16 @@ The value is currently saved in [user secrets](https://learn.microsoft.com/aspne パラメーター値の削除 + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. 設定する必要のある未解決のパラメーターがあります。それらの値を指定してください。 diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.ko.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.ko.xlf index 77659df1398..82b6f4abf46 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.ko.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.ko.xlf @@ -66,6 +66,16 @@ The value is currently saved in [user secrets](https://learn.microsoft.com/aspne 매개 변수 값 삭제 + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. 설정해야 하는 해결되지 않은 매개 변수가 있습니다. 값을 입력하세요. diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.pl.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.pl.xlf index 5faf8c7b62b..0467fd9d266 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.pl.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.pl.xlf @@ -66,6 +66,16 @@ Wartość jest obecnie zapisana we [wpisach tajnych użytkownika](https://learn. Usuń wartość parametru + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. Istnieją nierozpoznane parametry, które należy ustawić. Podaj dla nich wartości. diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.pt-BR.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.pt-BR.xlf index e9429a32641..000ef320731 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.pt-BR.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.pt-BR.xlf @@ -66,6 +66,16 @@ O valor está atualmente salvo em [segredos do usuário](https://learn.microsof Excluir valor do parâmetro + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. Há parâmetros não resolvidos que precisam ser definidos. Insira valores para eles. diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.ru.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.ru.xlf index ece4ec93fd1..5ef4bc73c77 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.ru.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.ru.xlf @@ -66,6 +66,16 @@ The value is currently saved in [user secrets](https://learn.microsoft.com/aspne Удалить значение параметра + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. Существуют неразрешенные параметры, которые необходимо настроить. Укажите значения для них. diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.tr.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.tr.xlf index 1b2aa148f7f..505ae762387 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.tr.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.tr.xlf @@ -66,6 +66,16 @@ Değer şu anda [kullanıcı gizli dizinleri](https://learn.microsoft.com/aspnet Parametre değerini sil + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. Ayarlanması gereken çözülmemiş parametreler var. Lütfen bunlara değerler girin. diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.zh-Hans.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.zh-Hans.xlf index 53a42de7440..d0199fbb761 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.zh-Hans.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.zh-Hans.xlf @@ -66,6 +66,16 @@ The value is currently saved in [user secrets](https://learn.microsoft.com/aspne 删除参数值 + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. 有未解析的参数需要设置。请为这些参数提供值。 diff --git a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.zh-Hant.xlf b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.zh-Hant.xlf index 8fe6f5ce074..41bb80cbfa1 100644 --- a/src/Aspire.Hosting/Resources/xlf/InteractionStrings.zh-Hant.xlf +++ b/src/Aspire.Hosting/Resources/xlf/InteractionStrings.zh-Hant.xlf @@ -66,6 +66,16 @@ The value is currently saved in [user secrets](https://learn.microsoft.com/aspne 刪除參數值 + + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + The most recent ASP.NET Core Development Certificate isn't fully trusted. Run `dotnet dev-certs https --trust` from {0} to trust the certificate. + + + + Development certificate not fully trusted + Development certificate not fully trusted + + There are unresolved parameters that need to be set. Please provide values for them. 有些未解析的參數需要設定。請提供它們的值。 From 1a45c81a6b0c3e06256b7ee3a24d6112f2c2b961 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Fri, 23 Jan 2026 17:53:32 -0800 Subject: [PATCH 12/15] Use the file system service to get a temp folder --- src/Aspire.Hosting/Dcp/DcpHost.cs | 6 +- .../DeveloperCertificateService.cs | 63 +++++++++---------- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/DcpHost.cs b/src/Aspire.Hosting/Dcp/DcpHost.cs index 44307a4022f..4a0176ddfe6 100644 --- a/src/Aspire.Hosting/Dcp/DcpHost.cs +++ b/src/Aspire.Hosting/Dcp/DcpHost.cs @@ -19,6 +19,7 @@ namespace Aspire.Hosting.Dcp; #pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. #pragma warning disable ASPIRECERTIFICATES001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. +#pragma warning disable ASPIREFILESYSTEM001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. internal sealed class DcpHost { @@ -33,6 +34,7 @@ internal sealed class DcpHost private readonly Locations _locations; private readonly TimeProvider _timeProvider; private readonly IDeveloperCertificateService _developerCertificateService; + private readonly IFileSystemService _fileSystemService; private readonly IConfiguration _configuration; private readonly CancellationTokenSource _shutdownCts = new(); private Task? _logProcessorTask; @@ -55,6 +57,7 @@ public DcpHost( DistributedApplicationModel applicationModel, TimeProvider timeProvider, IDeveloperCertificateService developerCertificateService, + IFileSystemService fileSystemService, IConfiguration configuration) { _loggerFactory = loggerFactory; @@ -66,6 +69,7 @@ public DcpHost( _applicationModel = applicationModel; _timeProvider = timeProvider; _developerCertificateService = developerCertificateService; + _fileSystemService = fileSystemService; _configuration = configuration; } @@ -139,7 +143,7 @@ internal async Task EnsureDevelopmentCertificateTrustAsync(CancellationToken can try { // Check and warn if the developer certificate is not trusted - if (_developerCertificateService.TrustCertificate && _developerCertificateService.Certificates.Count > 0 && !DeveloperCertificateService.IsCertificateTrusted(_developerCertificateService.Certificates.First())) + if (_developerCertificateService.TrustCertificate && _developerCertificateService.Certificates.Count > 0 && !DeveloperCertificateService.IsCertificateTrusted(_fileSystemService, _developerCertificateService.Certificates.First())) { var trustLocation = "your project folder"; var appHostDirectory = _configuration["AppHost:Directory"]; diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index 000d1bf22a1..f30a30637ed 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -1,4 +1,5 @@ #pragma warning disable ASPIRECERTIFICATES001 +#pragma warning disable ASPIREFILESYSTEM001 // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. @@ -20,7 +21,6 @@ internal class DeveloperCertificateService : IDeveloperCertificateService public DeveloperCertificateService(ILogger logger, IConfiguration configuration, DistributedApplicationOptions options) { - // Environment variable config > DistributedApplicationOptions > default true TrustCertificate = configuration.GetBool(KnownConfigNames.DeveloperCertificateDefaultTrust) ?? options.TrustDeveloperCertificate ?? true; @@ -112,14 +112,14 @@ public DeveloperCertificateService(ILogger logger, /// public bool UseForHttps { get; } - internal static bool IsCertificateTrusted(X509Certificate2 certificate) + internal static bool IsCertificateTrusted(IFileSystemService fileSystemService, X509Certificate2 certificate) { ArgumentNullException.ThrowIfNull(certificate); if (OperatingSystem.IsMacOS()) { // On MacOS we have to verify against the Keychain - return IsCertificateTrustedInMacOsKeychain(certificate); + return IsCertificateTrustedInMacOsKeychain(fileSystemService, certificate); } try @@ -150,45 +150,40 @@ internal static bool IsCertificateTrusted(X509Certificate2 certificate) // Use the same approach as `dotnet dev-certs` to check if the certificate is trusted in the macOS keychain // See: https://github.com/dotnet/aspnetcore/blob/2a88012113497bac5056548f16d810738b069198/src/Shared/CertificateGeneration/MacOSCertificateManager.cs#L36-L37 - private static bool IsCertificateTrustedInMacOsKeychain(X509Certificate2 certificate) + private static bool IsCertificateTrustedInMacOsKeychain(IFileSystemService fileSystemService, X509Certificate2 certificate) { try { - var certPath = Path.Combine(Path.GetTempPath(), $"aspire-devcert-{certificate.Thumbprint}.cer"); + using var tempDirectory = fileSystemService.TempDirectory.CreateTempSubdirectory("aspire-devcert-check"); + var certPath = Path.Combine(tempDirectory.Path, $"aspire-devcert-{certificate.Thumbprint}.cer"); + File.WriteAllBytes(certPath, certificate.Export(X509ContentType.Cert)); - try + var startInfo = new ProcessStartInfo { - var startInfo = new ProcessStartInfo - { - FileName = "security", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true - }; - - startInfo.ArgumentList.Add("verify-cert"); - startInfo.ArgumentList.Add("-p"); - startInfo.ArgumentList.Add("basic"); - startInfo.ArgumentList.Add("-p"); - startInfo.ArgumentList.Add("ssl"); - startInfo.ArgumentList.Add("-c"); - startInfo.ArgumentList.Add(certPath); - - using var process = Process.Start(startInfo); - if (process is null) - { - return false; - } - - process.WaitForExit(); - return process.ExitCode == 0; - } - finally + FileName = "security", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + startInfo.ArgumentList.Add("verify-cert"); + startInfo.ArgumentList.Add("-p"); + startInfo.ArgumentList.Add("basic"); + startInfo.ArgumentList.Add("-p"); + startInfo.ArgumentList.Add("ssl"); + startInfo.ArgumentList.Add("-c"); + startInfo.ArgumentList.Add(certPath); + + using var process = Process.Start(startInfo); + if (process is null) { - File.Delete(certPath); + return false; } + + process.WaitForExit(); + return process.ExitCode == 0; } catch { From c08965a80f8c9afe727c99ac53572c8daa726e33 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Jan 2026 14:03:23 -0800 Subject: [PATCH 13/15] Refactor DeveloperCertificateService to use ProcessSpec and ProcessUtil APIs with async/await (#14110) --- src/Aspire.Hosting/Dcp/DcpHost.cs | 2 +- .../DeveloperCertificateService.cs | 35 ++++++------------- 2 files changed, 12 insertions(+), 25 deletions(-) diff --git a/src/Aspire.Hosting/Dcp/DcpHost.cs b/src/Aspire.Hosting/Dcp/DcpHost.cs index 4a0176ddfe6..c654b785833 100644 --- a/src/Aspire.Hosting/Dcp/DcpHost.cs +++ b/src/Aspire.Hosting/Dcp/DcpHost.cs @@ -143,7 +143,7 @@ internal async Task EnsureDevelopmentCertificateTrustAsync(CancellationToken can try { // Check and warn if the developer certificate is not trusted - if (_developerCertificateService.TrustCertificate && _developerCertificateService.Certificates.Count > 0 && !DeveloperCertificateService.IsCertificateTrusted(_fileSystemService, _developerCertificateService.Certificates.First())) + if (_developerCertificateService.TrustCertificate && _developerCertificateService.Certificates.Count > 0 && !await DeveloperCertificateService.IsCertificateTrustedAsync(_fileSystemService, _developerCertificateService.Certificates.First(), cancellationToken).ConfigureAwait(false)) { var trustLocation = "your project folder"; var appHostDirectory = _configuration["AppHost:Directory"]; diff --git a/src/Aspire.Hosting/DeveloperCertificateService.cs b/src/Aspire.Hosting/DeveloperCertificateService.cs index f30a30637ed..b9c2078e2bd 100644 --- a/src/Aspire.Hosting/DeveloperCertificateService.cs +++ b/src/Aspire.Hosting/DeveloperCertificateService.cs @@ -4,11 +4,11 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using Aspire.Hosting.Dcp.Process; using Aspire.Hosting.Utils; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Collections.Immutable; -using System.Diagnostics; using System.Security.Cryptography.X509Certificates; namespace Aspire.Hosting; @@ -112,14 +112,14 @@ public DeveloperCertificateService(ILogger logger, /// public bool UseForHttps { get; } - internal static bool IsCertificateTrusted(IFileSystemService fileSystemService, X509Certificate2 certificate) + internal static async Task IsCertificateTrustedAsync(IFileSystemService fileSystemService, X509Certificate2 certificate, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(certificate); if (OperatingSystem.IsMacOS()) { // On MacOS we have to verify against the Keychain - return IsCertificateTrustedInMacOsKeychain(fileSystemService, certificate); + return await IsCertificateTrustedInMacOsKeychainAsync(fileSystemService, certificate, cancellationToken).ConfigureAwait(false); } try @@ -150,7 +150,7 @@ internal static bool IsCertificateTrusted(IFileSystemService fileSystemService, // Use the same approach as `dotnet dev-certs` to check if the certificate is trusted in the macOS keychain // See: https://github.com/dotnet/aspnetcore/blob/2a88012113497bac5056548f16d810738b069198/src/Shared/CertificateGeneration/MacOSCertificateManager.cs#L36-L37 - private static bool IsCertificateTrustedInMacOsKeychain(IFileSystemService fileSystemService, X509Certificate2 certificate) + private static async Task IsCertificateTrustedInMacOsKeychainAsync(IFileSystemService fileSystemService, X509Certificate2 certificate, CancellationToken cancellationToken) { try { @@ -159,31 +159,18 @@ private static bool IsCertificateTrustedInMacOsKeychain(IFileSystemService fileS File.WriteAllBytes(certPath, certificate.Export(X509ContentType.Cert)); - var startInfo = new ProcessStartInfo + var processSpec = new ProcessSpec("security") { - FileName = "security", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - CreateNoWindow = true + Arguments = $"verify-cert -p basic -p ssl -c {certPath}", + ThrowOnNonZeroReturnCode = false }; - startInfo.ArgumentList.Add("verify-cert"); - startInfo.ArgumentList.Add("-p"); - startInfo.ArgumentList.Add("basic"); - startInfo.ArgumentList.Add("-p"); - startInfo.ArgumentList.Add("ssl"); - startInfo.ArgumentList.Add("-c"); - startInfo.ArgumentList.Add(certPath); - - using var process = Process.Start(startInfo); - if (process is null) + var (task, processDisposable) = ProcessUtil.Run(processSpec); + await using (processDisposable.ConfigureAwait(false)) { - return false; + var result = await task.WaitAsync(cancellationToken).ConfigureAwait(false); + return result.ExitCode == 0; } - - process.WaitForExit(); - return process.ExitCode == 0; } catch { From b0816e08b27b7fb46e738cdcabc4d1cd5ffdca2c Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 26 Jan 2026 11:10:11 -0800 Subject: [PATCH 14/15] Update test service mocks --- .../Dcp/DcpHostNotificationTests.cs | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs b/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs index e03ef806cd1..6fde98c70ba 100644 --- a/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs +++ b/tests/Aspire.Hosting.Tests/Dcp/DcpHostNotificationTests.cs @@ -4,6 +4,7 @@ using System.Globalization; using Aspire.Hosting.Dcp; using Aspire.Hosting.Resources; +using Aspire.Hosting.Tests.Utils; using Microsoft.AspNetCore.InternalTesting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -35,6 +36,10 @@ public void DcpHost_WithIInteractionService_CanBeConstructed() var applicationModel = new DistributedApplicationModel(new ResourceCollection()); var timeProvider = new FakeTimeProvider(); + var developerCertificateService = new TestDeveloperCertificateService([], false, false, false); + var fileSystemService = new FileSystemService(new ConfigurationBuilder().Build()); + var configuration = new ConfigurationBuilder().Build(); + // Act & Assert - should not throw var dcpHost = new DcpHost( loggerFactory, @@ -43,7 +48,10 @@ public void DcpHost_WithIInteractionService_CanBeConstructed() interactionService, locations, applicationModel, - timeProvider); + timeProvider, + developerCertificateService, + fileSystemService, + configuration); Assert.NotNull(dcpHost); } @@ -73,6 +81,9 @@ public async Task DcpHost_WithUnhealthyContainerRuntime_ShowsNotification() var interactionService = new TestInteractionService { IsAvailable = true }; var locations = CreateTestLocations(); var timeProvider = new FakeTimeProvider(); + var developerCertificateService = new TestDeveloperCertificateService([], false, false, false); + var fileSystemService = new FileSystemService(new ConfigurationBuilder().Build()); + var configuration = new ConfigurationBuilder().Build(); var dcpHost = new DcpHost( loggerFactory, @@ -81,7 +92,10 @@ public async Task DcpHost_WithUnhealthyContainerRuntime_ShowsNotification() interactionService, locations, applicationModel, - timeProvider); + timeProvider, + developerCertificateService, + fileSystemService, + configuration); // Act await dcpHost.EnsureDcpContainerRuntimeAsync(CancellationToken.None).DefaultTimeout(); @@ -122,6 +136,9 @@ public async Task DcpHost_WithHealthyContainerRuntime_DoesNotShowNotification() var interactionService = new TestInteractionService { IsAvailable = true }; var locations = CreateTestLocations(); var timeProvider = new FakeTimeProvider(); + var developerCertificateService = new TestDeveloperCertificateService([], false, false, false); + var fileSystemService = new FileSystemService(new ConfigurationBuilder().Build()); + var configuration = new ConfigurationBuilder().Build(); var dcpHost = new DcpHost( loggerFactory, @@ -130,7 +147,10 @@ public async Task DcpHost_WithHealthyContainerRuntime_DoesNotShowNotification() interactionService, locations, applicationModel, - timeProvider); + timeProvider, + developerCertificateService, + fileSystemService, + configuration); // Act await dcpHost.EnsureDcpContainerRuntimeAsync(CancellationToken.None).DefaultTimeout(); @@ -177,6 +197,9 @@ public async Task DcpHost_WithDashboardDisabled_DoesNotShowNotification() var interactionService = new TestInteractionService { IsAvailable = false }; // Dashboard disabled var locations = CreateTestLocations(); var timeProvider = new FakeTimeProvider(); + var developerCertificateService = new TestDeveloperCertificateService([], false, false, false); + var fileSystemService = new FileSystemService(new ConfigurationBuilder().Build()); + var configuration = new ConfigurationBuilder().Build(); var dcpHost = new DcpHost( loggerFactory, @@ -185,7 +208,10 @@ public async Task DcpHost_WithDashboardDisabled_DoesNotShowNotification() interactionService, locations, applicationModel, - timeProvider); + timeProvider, + developerCertificateService, + fileSystemService, + configuration); // Act await dcpHost.EnsureDcpContainerRuntimeAsync(CancellationToken.None).DefaultTimeout(); @@ -232,6 +258,9 @@ public async Task DcpHost_WithPodmanUnhealthy_ShowsCorrectMessage() var interactionService = new TestInteractionService { IsAvailable = true }; var locations = CreateTestLocations(); var timeProvider = new FakeTimeProvider(); + var developerCertificateService = new TestDeveloperCertificateService([], false, false, false); + var fileSystemService = new FileSystemService(new ConfigurationBuilder().Build()); + var configuration = new ConfigurationBuilder().Build(); var dcpHost = new DcpHost( loggerFactory, @@ -240,7 +269,10 @@ public async Task DcpHost_WithPodmanUnhealthy_ShowsCorrectMessage() interactionService, locations, applicationModel, - timeProvider); + timeProvider, + developerCertificateService, + fileSystemService, + configuration); // Act await dcpHost.EnsureDcpContainerRuntimeAsync(CancellationToken.None).DefaultTimeout(); @@ -282,6 +314,9 @@ public async Task DcpHost_WithUnhealthyContainerRuntime_NotificationCancelledWhe var interactionService = new TestInteractionService { IsAvailable = true }; var locations = CreateTestLocations(); var timeProvider = new FakeTimeProvider(); + var developerCertificateService = new TestDeveloperCertificateService([], false, false, false); + var fileSystemService = new FileSystemService(new ConfigurationBuilder().Build()); + var configuration = new ConfigurationBuilder().Build(); var dcpHost = new DcpHost( loggerFactory, @@ -290,7 +325,10 @@ public async Task DcpHost_WithUnhealthyContainerRuntime_NotificationCancelledWhe interactionService, locations, applicationModel, - timeProvider); + timeProvider, + developerCertificateService, + fileSystemService, + configuration); // Act await dcpHost.EnsureDcpContainerRuntimeAsync(CancellationToken.None).DefaultTimeout(); @@ -350,6 +388,9 @@ public async Task DcpHost_WithContainerRuntimeNotInstalled_ShowsNotificationWith var interactionService = new TestInteractionService { IsAvailable = true }; var locations = CreateTestLocations(); var timeProvider = new FakeTimeProvider(); + var developerCertificateService = new TestDeveloperCertificateService([], false, false, false); + var fileSystemService = new FileSystemService(new ConfigurationBuilder().Build()); + var configuration = new ConfigurationBuilder().Build(); var dcpHost = new DcpHost( loggerFactory, @@ -358,7 +399,10 @@ public async Task DcpHost_WithContainerRuntimeNotInstalled_ShowsNotificationWith interactionService, locations, applicationModel, - timeProvider); + timeProvider, + developerCertificateService, + fileSystemService, + configuration); // Act await dcpHost.EnsureDcpContainerRuntimeAsync(CancellationToken.None).DefaultTimeout(); From 157a72cc734739e9bd3613abb5193cb646f64545 Mon Sep 17 00:00:00 2001 From: David Negstad Date: Mon, 26 Jan 2026 16:11:25 -0800 Subject: [PATCH 15/15] Guard against the interaction service not being available --- src/Aspire.Hosting/Dcp/DcpHost.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Aspire.Hosting/Dcp/DcpHost.cs b/src/Aspire.Hosting/Dcp/DcpHost.cs index c654b785833..ecbd9af7c2d 100644 --- a/src/Aspire.Hosting/Dcp/DcpHost.cs +++ b/src/Aspire.Hosting/Dcp/DcpHost.cs @@ -142,6 +142,12 @@ internal async Task EnsureDevelopmentCertificateTrustAsync(CancellationToken can try { + // Check if the interaction service is available (dashboard enabled) + if (!_interactionService.IsAvailable) + { + return; + } + // Check and warn if the developer certificate is not trusted if (_developerCertificateService.TrustCertificate && _developerCertificateService.Certificates.Count > 0 && !await DeveloperCertificateService.IsCertificateTrustedAsync(_fileSystemService, _developerCertificateService.Certificates.First(), cancellationToken).ConfigureAwait(false)) {