diff --git a/src/Aspire.Cli/Certificates/CertificateService.cs b/src/Aspire.Cli/Certificates/CertificateService.cs index ca7d627d6b0..2ed2c9e01cc 100644 --- a/src/Aspire.Cli/Certificates/CertificateService.cs +++ b/src/Aspire.Cli/Certificates/CertificateService.cs @@ -8,6 +8,7 @@ using Aspire.Cli.Resources; using Aspire.Cli.Telemetry; using Microsoft.AspNetCore.Certificates.Generation; +using Microsoft.Extensions.Logging; namespace Aspire.Cli.Certificates; @@ -21,6 +22,11 @@ internal sealed class EnsureCertificatesTrustedResult /// to ensure certificates are properly trusted. /// public required IDictionary EnvironmentVariables { get; init; } + + /// + /// Gets the path to the exported PEM certificate file, if one was exported. + /// + public string? DevCertPemPath { get; init; } } internal interface ICertificateService @@ -31,9 +37,15 @@ internal interface ICertificateService internal sealed class CertificateService( ICertificateToolRunner certificateToolRunner, IInteractionService interactionService, - AspireCliTelemetry telemetry) : ICertificateService + AspireCliTelemetry telemetry, + CliExecutionContext executionContext, + ILogger logger) : ICertificateService { private const string SslCertDirEnvVar = "SSL_CERT_DIR"; + private const string DevCertPemFileName = "aspire-dev-cert.pem"; + + internal string DevCertPemPath => Path.Combine( + executionContext.HomeDirectory.FullName, ".aspire", "dev-certs", DevCertPemFileName); public async Task EnsureCertificatesTrustedAsync(CancellationToken cancellationToken) { @@ -45,9 +57,12 @@ public async Task EnsureCertificatesTrustedAsyn var trustResult = await CheckMachineReadableAsync(); await HandleMachineReadableTrustAsync(trustResult, environmentVariables); + var devCertPemPath = ExportDevCertificatePem(); + return new EnsureCertificatesTrustedResult { - EnvironmentVariables = environmentVariables + EnvironmentVariables = environmentVariables, + DevCertPemPath = devCertPemPath }; } @@ -130,6 +145,29 @@ private static void ConfigureSslCertDir(Dictionary environmentVa } } + private string? ExportDevCertificatePem() + { + try + { + var result = certificateToolRunner.ExportDevCertificatePublicPem(DevCertPemPath); + if (result is not null) + { + logger.LogDebug("Exported dev certificate public PEM to {Path}", result); + } + else + { + logger.LogDebug("No valid dev certificate found to export as PEM"); + } + + return result; + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to export dev certificate as PEM"); + return null; + } + } + } internal sealed class CertificateServiceException(string message) : Exception(message) diff --git a/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs index f800927769e..d67af14e17b 100644 --- a/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/ICertificateToolRunner.cs @@ -24,4 +24,12 @@ internal interface ICertificateToolRunner /// Removes all HTTPS development certificates. /// CertificateCleanResult CleanHttpCertificate(); + + /// + /// Exports the public key of the highest-versioned ASP.NET Core HTTPS development certificate + /// as a PEM file at the specified path. + /// + /// The file path to write the PEM certificate to. + /// The output path if a certificate was exported; if no valid certificate was found. + string? ExportDevCertificatePublicPem(string outputPath); } diff --git a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs index ca9726743c3..2b4ebabb22e 100644 --- a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs @@ -173,6 +173,43 @@ public CertificateCleanResult CleanHttpCertificate() } } + public string? ExportDevCertificatePublicPem(string outputPath) + { + var availableCertificates = certificateManager.ListCertificates( + StoreName.My, StoreLocation.CurrentUser, isValid: true); + + try + { + var now = DateTimeOffset.Now; + var certificate = availableCertificates + .Where(c => CertificateManager.IsHttpsDevelopmentCertificate(c) + && c.NotBefore <= now && now <= c.NotAfter) + .OrderByDescending(CertificateManager.GetCertificateVersion) + .ThenByDescending(c => c.NotAfter) + .FirstOrDefault(); + + if (certificate is null) + { + return null; + } + + var directory = Path.GetDirectoryName(outputPath); + if (!string.IsNullOrEmpty(directory)) + { + Directory.CreateDirectory(directory); + } + + var pem = certificate.ExportCertificatePem(); + File.WriteAllText(outputPath, pem); + + return outputPath; + } + finally + { + CertificateManager.DisposeCertificates(availableCertificates); + } + } + private static string[]? GetSanExtension(X509Certificate2 cert) { var dnsNames = new List(); diff --git a/src/Aspire.Cli/Projects/GuestAppHostProject.cs b/src/Aspire.Cli/Projects/GuestAppHostProject.cs index 778a20ba35d..343f84159a5 100644 --- a/src/Aspire.Cli/Projects/GuestAppHostProject.cs +++ b/src/Aspire.Cli/Projects/GuestAppHostProject.cs @@ -345,10 +345,12 @@ public async Task RunAsync(AppHostProjectContext context, CancellationToken { // Step 1: Ensure certificates are trusted Dictionary certEnvVars; + string? devCertPemPath; try { var certResult = await _certificateService.EnsureCertificatesTrustedAsync(cancellationToken); certEnvVars = new Dictionary(certResult.EnvironmentVariables); + devCertPemPath = certResult.DevCertPemPath; } catch { @@ -491,6 +493,10 @@ await GenerateCodeViaRpcAsync( ["ASPIRE_APPHOST_FILEPATH"] = appHostFile.FullName }; + // Set NODE_EXTRA_CA_CERTS for Node.js-based runtimes so the TypeScript app host + // trusts the ASP.NET Core development certificate when connecting over HTTPS + ConfigureNodeCertificateEnvironment(environmentVariables, context.EnvironmentVariables, devCertPemPath); + // Pass debug flag to the guest process if (context.Debug) { @@ -1404,4 +1410,33 @@ private async Task InstallDependenciesAsync( var id = UserSecretsPathHelper.ComputeSyntheticUserSecretsId(appHostFile.FullName); return Task.FromResult(id); } + + /// + /// Configures NODE_EXTRA_CA_CERTS for Node.js-based runtimes to trust the ASP.NET Core + /// development certificate. Skips configuration and warns if the variable is already set. + /// + internal void ConfigureNodeCertificateEnvironment( + IDictionary environmentVariables, + IDictionary contextEnvironmentVariables, + string? devCertPemPath) + { + if (devCertPemPath is null || !LanguageId.Contains("nodejs", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var existingNodeExtraCaCerts = Environment.GetEnvironmentVariable("NODE_EXTRA_CA_CERTS") + ?? (contextEnvironmentVariables.TryGetValue("NODE_EXTRA_CA_CERTS", out var ctxValue) ? ctxValue : null); + + if (existingNodeExtraCaCerts is not null) + { + _interactionService.DisplayMessage( + KnownEmojis.Warning, + $"NODE_EXTRA_CA_CERTS is already set to '{existingNodeExtraCaCerts}'. Skipping Aspire dev certificate configuration."); + } + else + { + environmentVariables["NODE_EXTRA_CA_CERTS"] = devCertPemPath; + } + } } diff --git a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs index cfbe6abec09..fc8014fb1ec 100644 --- a/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs +++ b/src/Aspire.Hosting.CodeGeneration.TypeScript/TypeScriptLanguageSupport.cs @@ -192,7 +192,7 @@ public RuntimeSpec GetRuntimeSpec() Execute = new CommandSpec { Command = "npx", - Args = ["tsx", "{appHostFile}"] + Args = ["tsx", "{appHostFile}"], }, WatchExecute = new CommandSpec { @@ -205,7 +205,7 @@ public RuntimeSpec GetRuntimeSpec() "--ignore", "node_modules/", "--ignore", ".modules/", "--exec", "npx tsx {appHostFile}" - ] + ], } }; } diff --git a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs index d6677f2a846..576d6f46441 100644 --- a/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Certificates/CertificateServiceTests.cs @@ -221,10 +221,84 @@ public async Task EnsureCertificatesTrustedAsync_TrustOperationFails_DisplaysWar Assert.NotNull(result); } + [Fact] + public async Task EnsureCertificatesTrustedAsync_ExportsPemCertificate() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + var exportCalled = false; + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CertificateToolRunnerFactory = sp => + { + return new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => + { + return new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = [new DevCertInfo { Version = 5, TrustLevel = CertificateManager.TrustLevel.Full, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] + }; + }, + ExportDevCertificatePublicPemCallback = (path) => + { + exportCalled = true; + return path; + } + }; + }; + }); + + var sp = services.BuildServiceProvider(); + var cs = sp.GetRequiredService(); + + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + + Assert.True(exportCalled); + Assert.NotNull(result.DevCertPemPath); + } + + [Fact] + public async Task EnsureCertificatesTrustedAsync_PemExportFailure_DoesNotThrow() + { + using var workspace = TemporaryWorkspace.Create(outputHelper); + + var services = CliTestHelper.CreateServiceCollection(workspace, outputHelper, options => + { + options.CertificateToolRunnerFactory = sp => + { + return new TestCertificateToolRunner + { + CheckHttpCertificateCallback = () => + { + return new CertificateTrustResult + { + HasCertificates = true, + TrustLevel = CertificateManager.TrustLevel.Full, + Certificates = [new DevCertInfo { Version = 5, TrustLevel = CertificateManager.TrustLevel.Full, IsHttpsDevelopmentCertificate = true, ValidityNotBefore = DateTimeOffset.Now.AddDays(-1), ValidityNotAfter = DateTimeOffset.Now.AddDays(365) }] + }; + }, + ExportDevCertificatePublicPemCallback = (_) => throw new IOException("Disk full") + }; + }; + }); + + var sp = services.BuildServiceProvider(); + var cs = sp.GetRequiredService(); + + // Should not throw even when PEM export fails + var result = await cs.EnsureCertificatesTrustedAsync(TestContext.Current.CancellationToken).DefaultTimeout(); + Assert.NotNull(result); + Assert.Null(result.DevCertPemPath); + } + private sealed class TestCertificateToolRunner : ICertificateToolRunner { public Func? CheckHttpCertificateCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } + public Func? ExportDevCertificatePublicPemCallback { get; set; } public CertificateTrustResult CheckHttpCertificate() { @@ -251,5 +325,12 @@ public EnsureCertificateResult TrustHttpCertificate() public CertificateCleanResult CleanHttpCertificate() => new CertificateCleanResult { Success = true }; + + public string? ExportDevCertificatePublicPem(string outputPath) + { + return ExportDevCertificatePublicPemCallback is not null + ? ExportDevCertificatePublicPemCallback(outputPath) + : null; + } } } diff --git a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs index 20f5f6f9765..c70a9fd255a 100644 --- a/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs +++ b/tests/Aspire.Cli.Tests/Projects/GuestAppHostProjectTests.cs @@ -334,9 +334,19 @@ public void GetServerEnvironmentVariables_ParsesLaunchSettingsWithComments() } private static GuestAppHostProject CreateGuestAppHostProject(DirectoryInfo workspaceRoot) + { + return CreateGuestAppHostProject(workspaceRoot, out _); + } + + private static GuestAppHostProject CreateGuestAppHostProject(DirectoryInfo workspaceRoot, out TestInteractionService interactionService) + { + return CreateGuestAppHostProject(workspaceRoot, "typescript/nodejs", out interactionService); + } + + private static GuestAppHostProject CreateGuestAppHostProject(DirectoryInfo workspaceRoot, string languageId, out TestInteractionService interactionService) { var language = new LanguageInfo( - LanguageId: "typescript/nodejs", + LanguageId: languageId, DisplayName: "TypeScript (Node.js)", PackageName: "Aspire.Hosting.CodeGeneration.TypeScript", DetectionPatterns: ["apphost.ts"], @@ -353,9 +363,11 @@ private static GuestAppHostProject CreateGuestAppHostProject(DirectoryInfo works var logFilePath = Path.Combine(Path.GetTempPath(), $"test-guest-{Guid.NewGuid()}.log"); + interactionService = new TestInteractionService(); + return new GuestAppHostProject( language: language, - interactionService: new TestInteractionService(), + interactionService: interactionService, backchannel: new TestAppHostBackchannel(), appHostServerProjectFactory: new TestAppHostServerProjectFactory(), certificateService: new TestCertificateService(), @@ -368,4 +380,57 @@ private static GuestAppHostProject CreateGuestAppHostProject(DirectoryInfo works logger: NullLogger.Instance, fileLoggerProvider: new FileLoggerProvider(logFilePath, new TestStartupErrorWriter())); } + + [Fact] + public void ConfigureNodeCertificateEnvironment_SetsNodeExtraCaCerts_WhenPemPathProvided() + { + var project = CreateGuestAppHostProject(_workspace.WorkspaceRoot); + var envVars = new Dictionary(); + var contextEnvVars = new Dictionary(); + + project.ConfigureNodeCertificateEnvironment(envVars, contextEnvVars, "/path/to/cert.pem"); + + Assert.Equal("/path/to/cert.pem", envVars["NODE_EXTRA_CA_CERTS"]); + } + + [Fact] + public void ConfigureNodeCertificateEnvironment_DoesNotSet_WhenPemPathIsNull() + { + var project = CreateGuestAppHostProject(_workspace.WorkspaceRoot); + var envVars = new Dictionary(); + var contextEnvVars = new Dictionary(); + + project.ConfigureNodeCertificateEnvironment(envVars, contextEnvVars, devCertPemPath: null); + + Assert.False(envVars.ContainsKey("NODE_EXTRA_CA_CERTS")); + } + + [Fact] + public void ConfigureNodeCertificateEnvironment_DoesNotSet_WhenLanguageIsNotNodeJs() + { + var project = CreateGuestAppHostProject(_workspace.WorkspaceRoot, "python", out _); + var envVars = new Dictionary(); + var contextEnvVars = new Dictionary(); + + project.ConfigureNodeCertificateEnvironment(envVars, contextEnvVars, "/path/to/cert.pem"); + + Assert.False(envVars.ContainsKey("NODE_EXTRA_CA_CERTS")); + } + + [Fact] + public void ConfigureNodeCertificateEnvironment_WarnsAndSkips_WhenAlreadySetInContext() + { + var project = CreateGuestAppHostProject(_workspace.WorkspaceRoot, out var interactionService); + var envVars = new Dictionary(); + var contextEnvVars = new Dictionary + { + ["NODE_EXTRA_CA_CERTS"] = "/existing/ca-certs.pem" + }; + + project.ConfigureNodeCertificateEnvironment(envVars, contextEnvVars, "/path/to/cert.pem"); + + Assert.False(envVars.ContainsKey("NODE_EXTRA_CA_CERTS")); + Assert.Single(interactionService.DisplayedMessages); + Assert.Contains("NODE_EXTRA_CA_CERTS is already set", interactionService.DisplayedMessages[0].Message); + } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs b/tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs index fbc98351ea3..043efde595f 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestCertificateService.cs @@ -11,7 +11,8 @@ public Task EnsureCertificatesTrustedAsync(Canc { return Task.FromResult(new EnsureCertificatesTrustedResult { - EnvironmentVariables = new Dictionary() + EnvironmentVariables = new Dictionary(), + DevCertPemPath = null }); } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs index 14939e7f043..9dcfca69f7a 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestCertificateToolRunner.cs @@ -15,6 +15,7 @@ internal sealed class TestCertificateToolRunner : ICertificateToolRunner public Func? CheckHttpCertificateCallback { get; set; } public Func? TrustHttpCertificateCallback { get; set; } public Func? CleanHttpCertificateCallback { get; set; } + public Func? ExportDevCertificatePublicPemCallback { get; set; } public CertificateTrustResult CheckHttpCertificate() { @@ -45,4 +46,11 @@ public CertificateCleanResult CleanHttpCertificate() ? CleanHttpCertificateCallback() : new CertificateCleanResult { Success = true }; } + + public string? ExportDevCertificatePublicPem(string outputPath) + { + return ExportDevCertificatePublicPemCallback is not null + ? ExportDevCertificatePublicPemCallback(outputPath) + : null; + } } diff --git a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs index befce116ddf..e3eaac1526a 100644 --- a/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs +++ b/tests/Aspire.Cli.Tests/TestServices/TestInteractionService.cs @@ -146,8 +146,11 @@ public void DisplayError(string errorMessage) DisplayedErrors.Add(errorMessage); } + public List<(KnownEmoji Emoji, string Message)> DisplayedMessages { get; } = []; + public void DisplayMessage(KnownEmoji emoji, string message, bool allowMarkup = false) { + DisplayedMessages.Add((emoji, message)); } public void DisplaySuccess(string message, bool allowMarkup = false) diff --git a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs index e89b6fdca5c..62fb408bff4 100644 --- a/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs +++ b/tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs @@ -408,7 +408,9 @@ public ISolutionLocator CreateDefaultSolutionLocatorFactory(IServiceProvider ser var certificateToolRunner = serviceProvider.GetRequiredService(); var interactiveService = serviceProvider.GetRequiredService(); var telemetry = serviceProvider.GetRequiredService(); - return new CertificateService(certificateToolRunner, interactiveService, telemetry); + var executionContext = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + return new CertificateService(certificateToolRunner, interactiveService, telemetry, executionContext, logger); }; public Func DotNetCliExecutionFactoryFactory { get; set; } = (IServiceProvider serviceProvider) =>