From 7b40ceae31e5240d018cad6247ecbf355744a03c Mon Sep 17 00:00:00 2001 From: David Fowler Date: Tue, 10 Feb 2026 22:22:14 -0800 Subject: [PATCH 01/14] Add aspire-managed: unified self-contained binary replacing separate dashboard, server, nuget, and runtime components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 3 separate framework-dependent publishes (Dashboard, RemoteHost, NuGetHelper) with single self-contained Aspire.Managed binary - Remove bundled .NET runtime download — aspire-managed embeds the runtime - Remove dev-certs DLL bundling — fall back to global dotnet dev-certs - Simplify CreateLayout: single CopyManagedAsync replaces 4 copy methods + runtime download - Simplify bundle layout: managed/ + dcp/ only (no runtime/, dashboard/, aspire-server/, tools/) - Update all CLI process launching to use aspire-managed with subcommands (server, nuget, dashboard) - Update DashboardEventHandlers to detect aspire-managed and inject dashboard subcommand - Update localhive scripts with --skip-bundle and --native-aot support --- eng/Bundle.proj | 57 +- eng/Versions.props | 3 +- eng/build.ps1 | 5 - eng/build.sh | 5 - localhive.ps1 | 106 +++- localhive.sh | 116 +++- src/Aspire.Cli/Bundles/BundleService.cs | 7 +- .../BundleCertificateToolRunner.cs | 55 +- src/Aspire.Cli/Layout/LayoutConfiguration.cs | 108 +--- src/Aspire.Cli/Layout/LayoutDiscovery.cs | 74 +-- src/Aspire.Cli/Layout/LayoutProcessRunner.cs | 45 +- .../NuGet/BundleNuGetPackageCache.cs | 18 +- src/Aspire.Cli/NuGet/BundleNuGetService.cs | 22 +- src/Aspire.Cli/Program.cs | 1 - .../Projects/AppHostServerProject.cs | 2 +- .../Projects/PrebuiltAppHostServer.cs | 41 +- .../Dashboard/DashboardEventHandlers.cs | 43 +- src/Aspire.Managed/Aspire.Managed.csproj | 44 ++ src/Aspire.Managed/Program.cs | 41 ++ src/Shared/BundleDiscovery.cs | 214 ++------ tools/CreateLayout/Program.cs | 508 ++---------------- 21 files changed, 475 insertions(+), 1040 deletions(-) create mode 100644 src/Aspire.Managed/Aspire.Managed.csproj create mode 100644 src/Aspire.Managed/Program.cs diff --git a/eng/Bundle.proj b/eng/Bundle.proj index e671888fea6..4875e9ae878 100644 --- a/eng/Bundle.proj +++ b/eng/Bundle.proj @@ -1,20 +1,19 @@ @@ -23,32 +22,34 @@ Debug - + $(TargetRids) win-x64 osx-arm64 linux-x64 - + $([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..')) $(RepoRoot)artifacts\ $(ArtifactsDir)log\$(Configuration)\ $(ArtifactsDir)bundle\$(TargetRid)\ - + $(RepoRoot)src\Aspire.Cli\Aspire.Cli.csproj - $(RepoRoot)src\Aspire.Cli.NuGetHelper\Aspire.Cli.NuGetHelper.csproj - $(RepoRoot)src\Aspire.Hosting.RemoteHost\Aspire.Hosting.RemoteHost.csproj - $(RepoRoot)src\Aspire.Dashboard\Aspire.Dashboard.csproj + $(RepoRoot)src\Aspire.Managed\Aspire.Managed.csproj $(RepoRoot)src\Aspire.Hosting.AppHost\Aspire.Hosting.AppHost.csproj $(RepoRoot)tools\CreateLayout\CreateLayout.csproj - + $(VersionPrefix)-$(VersionSuffix) $(VersionPrefix)-dev - + + + <_VersionSuffixArg Condition="'$(VersionSuffix)' != ''">/p:VersionSuffix=$(VersionSuffix) + <_BinlogArg Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir) @@ -67,7 +68,7 @@ - + @@ -89,10 +90,6 @@ <_CliBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishCli.binlog <_BundleArchivePath>$(ArtifactsDir)bundle\aspire-$(BundleVersion)-$(TargetRid).tar.gz - - <_VersionSuffixArg Condition="'$(VersionSuffix)' != ''">/p:VersionSuffix=$(VersionSuffix) @@ -100,15 +97,11 @@ - <_NuGetHelperBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishNuGetHelper.binlog - <_AppHostServerBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishAppHostServer.binlog - <_DashboardBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishDashboard.binlog + <_ManagedBinlog Condition="'$(ContinuousIntegrationBuild)' == 'true'">-bl:$(ArtifactsLogDir)PublishManaged.binlog - - - - - + + + @@ -124,16 +117,10 @@ <_BundleOutputDirArg>$(BundleOutputDir.TrimEnd('\').TrimEnd('/')) <_ArtifactsDirArg>$(ArtifactsDir.TrimEnd('\').TrimEnd('/')) - - - <_RuntimeArgs Condition="'$(BundleRuntimePath)' != ''">--runtime "$(BundleRuntimePath)" - <_RuntimeArgs Condition="'$(BundleRuntimePath)' == ''">--download-runtime - - <_CreateLayoutArgs>--output "$(_BundleOutputDirArg)" --artifacts "$(_ArtifactsDirArg)" --rid $(TargetRid) --bundle-version $(BundleVersion) --runtime-version $(BundleRuntimeVersion) --verbose $(_RuntimeArgs) --archive + + <_CreateLayoutArgs>--output "$(_BundleOutputDirArg)" --artifacts "$(_ArtifactsDirArg)" --rid $(TargetRid) --bundle-version $(BundleVersion) --verbose --archive - + diff --git a/eng/Versions.props b/eng/Versions.props index a2d25ba628a..048cd2e58ef 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -15,8 +15,7 @@ 8.0.415 9.0.306 - - 10.0.102 + 3.1.0 1.21.0 3.1.0 diff --git a/eng/build.ps1 b/eng/build.ps1 index 9fab54f5769..611fc8cf22a 100644 --- a/eng/build.ps1 +++ b/eng/build.ps1 @@ -160,11 +160,6 @@ if ($bundle) { $bundleArgs += "/p:SkipNativeBuild=true" } - # Pass through runtime version if set - if ($runtimeVersion) { - $bundleArgs += "/p:BundleRuntimeVersion=$runtimeVersion" - } - # CI flag is passed to Bundle.proj which handles version computation via Versions.props if ($ci) { $bundleArgs += "/p:ContinuousIntegrationBuild=true" diff --git a/eng/build.sh b/eng/build.sh index c80b2c68aba..862bd0abce3 100755 --- a/eng/build.sh +++ b/eng/build.sh @@ -254,11 +254,6 @@ if [ "$build_bundle" = true ]; then fi done - # Pass through runtime version if set - if [ -n "$runtime_version" ]; then - bundle_args+=("/p:BundleRuntimeVersion=$runtime_version") - fi - # CI flag is passed to Bundle.proj which handles version computation via Versions.props if [ "${CI:-}" = "true" ]; then bundle_args+=("/p:ContinuousIntegrationBuild=true") diff --git a/localhive.ps1 b/localhive.ps1 index 0cce5d158af..14eaf3de1df 100644 --- a/localhive.ps1 +++ b/localhive.ps1 @@ -2,12 +2,13 @@ <#! .SYNOPSIS - Build local NuGet packages and Aspire CLI, then create/update a hive and install the CLI (Windows/PowerShell). + Build local NuGet packages, Aspire CLI, and bundle, then create/update a hive and install everything (Windows/PowerShell). .DESCRIPTION Mirrors localhive.sh behavior on Windows. Packs the repo, creates a symlink from $HOME/.aspire/hives/ to artifacts/packages//Shipping (or copies .nupkg files), - and installs the locally-built Aspire CLI to $HOME/.aspire/bin. + installs the locally-built Aspire CLI to $HOME/.aspire/bin, and builds/installs the bundle + (aspire-managed + DCP) to $HOME/.aspire so the CLI can auto-discover it. .PARAMETER Configuration Build configuration: Release or Debug (positional parameter 0). If omitted, the script tries Release then falls back to Debug. @@ -24,6 +25,12 @@ .PARAMETER SkipCli Skip installing the locally-built CLI to $HOME/.aspire/bin. +.PARAMETER SkipBundle + Skip building and installing the bundle (aspire-managed + DCP) to $HOME/.aspire. + +.PARAMETER NativeAot + Build and install the native AOT CLI (self-extracting binary with embedded bundle) instead of the dotnet tool version. + .PARAMETER Help Show help and exit. @@ -58,6 +65,10 @@ param( [switch] $SkipCli, + [switch] $SkipBundle, + + [switch] $NativeAot, + [Alias('h')] [switch] $Help ) @@ -80,6 +91,8 @@ Options: -VersionSuffix (-v) Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) -Copy Copy .nupkg files instead of creating a symlink -SkipCli Skip installing the locally-built CLI to $HOME\.aspire\bin + -SkipBundle Skip building and installing the bundle (aspire-managed + DCP) + -NativeAot Build native AOT CLI (self-extracting with embedded bundle) -Help (-h) Show this help and exit Examples: @@ -155,9 +168,12 @@ function Get-PackagesPath { $effectiveConfig = if ($Configuration) { $Configuration } else { 'Release' } +# Skip native AOT during pack unless user will build it separately via -NativeAot + Bundle.proj +$aotArg = if (-not $NativeAot) { "/p:PublishAot=false" } else { "" } + if ($Configuration) { Write-Log "Building and packing NuGet packages [-c $Configuration] with versionsuffix '$VersionSuffix'" - & $buildScript -restore -build -pack -c $Configuration "/p:VersionSuffix=$VersionSuffix" "/p:SkipTestProjects=true" "/p:SkipPlaygroundProjects=true" + & $buildScript -restore -build -pack -c $Configuration "/p:VersionSuffix=$VersionSuffix" "/p:SkipTestProjects=true" "/p:SkipPlaygroundProjects=true" $aotArg if ($LASTEXITCODE -ne 0) { Write-Err "Build failed for configuration $Configuration." exit 1 @@ -170,7 +186,7 @@ if ($Configuration) { } else { Write-Log "Building and packing NuGet packages [-c Release] with versionsuffix '$VersionSuffix'" - & $buildScript -restore -build -pack -c Release "/p:VersionSuffix=$VersionSuffix" "/p:SkipTestProjects=true" "/p:SkipPlaygroundProjects=true" + & $buildScript -restore -build -pack -c Release "/p:VersionSuffix=$VersionSuffix" "/p:SkipTestProjects=true" "/p:SkipPlaygroundProjects=true" $aotArg if ($LASTEXITCODE -ne 0) { Write-Err "Build failed for configuration Release." exit 1 @@ -236,22 +252,81 @@ else { } } -# Install the locally-built CLI to $HOME/.aspire/bin -if (-not $SkipCli) { - $cliBinDir = Join-Path (Join-Path $HOME '.aspire') 'bin' - # The CLI is built as part of the pack target in artifacts/bin/Aspire.Cli.Tool//net10.0/publish - $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" "publish" +# Determine the RID for the current platform +if ($IsWindows) { + $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'win-arm64' } else { 'win-x64' } +} elseif ($IsMacOS) { + $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'osx-arm64' } else { 'osx-x64' } +} else { + $bundleRid = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { 'linux-arm64' } else { 'linux-x64' } +} + +$aspireRoot = Join-Path $HOME '.aspire' +$cliBinDir = Join-Path $aspireRoot 'bin' + +# Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) +if (-not $SkipBundle) { + $bundleProjPath = Join-Path $RepoRoot "eng" "Bundle.proj" + $skipNativeArg = if ($NativeAot) { '' } else { '/p:SkipNativeBuild=true' } + + Write-Log "Building bundle (aspire-managed + DCP$(if ($NativeAot) { ' + native AOT CLI' }))..." + $buildArgs = @($bundleProjPath, '-c', $effectiveConfig, "/p:VersionSuffix=$VersionSuffix") + if (-not $NativeAot) { + $buildArgs += '/p:SkipNativeBuild=true' + } + & dotnet build @buildArgs + if ($LASTEXITCODE -ne 0) { + Write-Err "Bundle build failed." + exit 1 + } - if (-not (Test-Path -LiteralPath $cliPublishDir)) { - # Fallback: try the non-publish directory - $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" + $bundleLayoutDir = Join-Path $RepoRoot "artifacts" "bundle" $bundleRid + + if (-not (Test-Path -LiteralPath $bundleLayoutDir)) { + Write-Err "Bundle layout not found at $bundleLayoutDir" + exit 1 + } + + # Copy managed/ and dcp/ to $HOME/.aspire so the CLI auto-discovers them + foreach ($component in @('managed', 'dcp')) { + $sourceDir = Join-Path $bundleLayoutDir $component + $destDir = Join-Path $aspireRoot $component + if (Test-Path -LiteralPath $sourceDir) { + if (Test-Path -LiteralPath $destDir) { + Remove-Item -LiteralPath $destDir -Force -Recurse + } + Write-Log "Copying $component/ to $destDir" + Copy-Item -LiteralPath $sourceDir -Destination $destDir -Recurse -Force + } else { + Write-Warn "$component/ not found in bundle layout at $sourceDir" + } } + Write-Log "Bundle installed to $aspireRoot (managed/ + dcp/)" +} + +# Install the CLI to $HOME/.aspire/bin +if (-not $SkipCli) { $cliExeName = if ($IsWindows) { 'aspire.exe' } else { 'aspire' } + + if ($NativeAot) { + # Native AOT CLI is produced by Bundle.proj's _PublishNativeCli target + $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli" $effectiveConfig "net10.0" $bundleRid "native" + if (-not (Test-Path -LiteralPath $cliPublishDir)) { + $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli" $effectiveConfig "net10.0" $bundleRid "publish" + } + } else { + # Framework-dependent CLI from dotnet tool build + $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" "publish" + if (-not (Test-Path -LiteralPath $cliPublishDir)) { + $cliPublishDir = Join-Path $RepoRoot "artifacts" "bin" "Aspire.Cli.Tool" $effectiveConfig "net10.0" + } + } + $cliSourcePath = Join-Path $cliPublishDir $cliExeName if (Test-Path -LiteralPath $cliSourcePath) { - Write-Log "Installing Aspire CLI to $cliBinDir" + Write-Log "Installing Aspire CLI$(if ($NativeAot) { ' (native AOT)' }) to $cliBinDir" New-Item -ItemType Directory -Path $cliBinDir -Force | Out-Null # Copy all files from the publish directory (CLI and its dependencies) @@ -286,4 +361,9 @@ if (-not $SkipCli) { Write-Log "The locally-built CLI was installed to: $(Join-Path (Join-Path $HOME '.aspire') 'bin')" Write-Host } +if (-not $SkipBundle) { + Write-Log "Bundle (aspire-managed + DCP) installed to: $(Join-Path $HOME '.aspire')" + Write-Log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + Write-Host +} Write-Log 'The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required.' diff --git a/localhive.sh b/localhive.sh index 40c08fcc6e3..ef16006d044 100755 --- a/localhive.sh +++ b/localhive.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Build local NuGet packages and Aspire CLI, then create/update a hive and install the CLI. +# Build local NuGet packages, Aspire CLI, and bundle, then create/update a hive and install everything. # # Usage: # ./localhive.sh [options] @@ -12,6 +12,8 @@ # -v, --versionsuffix Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) # --copy Copy .nupkg files instead of creating a symlink # --skip-cli Skip installing the locally-built CLI to $HOME/.aspire/bin +# --skip-bundle Skip building and installing the bundle (aspire-managed + DCP) +# --native-aot Build native AOT CLI (self-extracting with embedded bundle) # -h, --help Show this help and exit # # Notes: @@ -33,6 +35,8 @@ Options: -v, --versionsuffix Prerelease version suffix (default: auto-generates local.YYYYMMDD.tHHmmss) --copy Copy .nupkg files instead of creating a symlink --skip-cli Skip installing the locally-built CLI to \$HOME/.aspire/bin + --skip-bundle Skip building and installing the bundle (aspire-managed + DCP) + --native-aot Build native AOT CLI (self-extracting with embedded bundle) -h, --help Show this help and exit Examples: @@ -71,6 +75,8 @@ CONFIG="" HIVE_NAME="local" USE_COPY=0 SKIP_CLI=0 +SKIP_BUNDLE=0 +NATIVE_AOT=0 VERSION_SUFFIX="" is_valid_versionsuffix() { local s="$1" @@ -109,6 +115,10 @@ while [[ $# -gt 0 ]]; do USE_COPY=1; shift ;; --skip-cli) SKIP_CLI=1; shift ;; + --skip-bundle) + SKIP_BUNDLE=1; shift ;; + --native-aot) + NATIVE_AOT=1; shift ;; --) shift; break ;; Release|Debug|release|debug) @@ -146,10 +156,16 @@ log "Using prerelease version suffix: $VERSION_SUFFIX" # Track effective configuration EFFECTIVE_CONFIG="${CONFIG:-Release}" +# Skip native AOT during pack unless user will build it separately via --native-aot + Bundle.proj +AOT_ARG="" +if [[ $NATIVE_AOT -eq 0 ]]; then + AOT_ARG="/p:PublishAot=false" +fi + if [ -n "$CONFIG" ]; then log "Building and packing NuGet packages [-c $CONFIG] with versionsuffix '$VERSION_SUFFIX'" # Single invocation: restore + build + pack to ensure all Build-triggered targets run and packages are produced. - "$REPO_ROOT/build.sh" --restore --build --pack -c "$CONFIG" /p:VersionSuffix="$VERSION_SUFFIX" /p:SkipTestProjects=true /p:SkipPlaygroundProjects=true + "$REPO_ROOT/build.sh" --restore --build --pack -c "$CONFIG" /p:VersionSuffix="$VERSION_SUFFIX" /p:SkipTestProjects=true /p:SkipPlaygroundProjects=true $AOT_ARG PKG_DIR="$REPO_ROOT/artifacts/packages/$CONFIG/Shipping" if [ ! -d "$PKG_DIR" ]; then error "Could not find packages path $PKG_DIR for CONFIG=$CONFIG" @@ -157,7 +173,7 @@ if [ -n "$CONFIG" ]; then fi else log "Building and packing NuGet packages [-c Release] with versionsuffix '$VERSION_SUFFIX'" - "$REPO_ROOT/build.sh" --restore --build --pack -c Release /p:VersionSuffix="$VERSION_SUFFIX" /p:SkipTestProjects=true /p:SkipPlaygroundProjects=true + "$REPO_ROOT/build.sh" --restore --build --pack -c Release /p:VersionSuffix="$VERSION_SUFFIX" /p:SkipTestProjects=true /p:SkipPlaygroundProjects=true $AOT_ARG PKG_DIR="$REPO_ROOT/artifacts/packages/Release/Shipping" if [ ! -d "$PKG_DIR" ]; then error "Could not find packages path $PKG_DIR for CONFIG=Release" @@ -207,21 +223,92 @@ else fi fi -# Install the locally-built CLI to $HOME/.aspire/bin -if [[ $SKIP_CLI -eq 0 ]]; then - CLI_BIN_DIR="$HOME/.aspire/bin" - # The CLI is built as part of the pack target in artifacts/bin/Aspire.Cli.Tool//net10.0/publish - CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0/publish" +# Determine the RID for the current platform +ARCH=$(uname -m) +case "$(uname -s)" in + Darwin) + if [[ "$ARCH" == "arm64" ]]; then BUNDLE_RID="osx-arm64"; else BUNDLE_RID="osx-x64"; fi + ;; + Linux) + if [[ "$ARCH" == "aarch64" ]]; then BUNDLE_RID="linux-arm64"; else BUNDLE_RID="linux-x64"; fi + ;; + *) + BUNDLE_RID="linux-x64" + ;; +esac + +ASPIRE_ROOT="$HOME/.aspire" +CLI_BIN_DIR="$ASPIRE_ROOT/bin" + +# Build the bundle (aspire-managed + DCP, and optionally native AOT CLI) +if [[ $SKIP_BUNDLE -eq 0 ]]; then + BUNDLE_PROJ="$REPO_ROOT/eng/Bundle.proj" + + if [[ $NATIVE_AOT -eq 1 ]]; then + log "Building bundle (aspire-managed + DCP + native AOT CLI)..." + dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" "/p:VersionSuffix=$VERSION_SUFFIX" + else + log "Building bundle (aspire-managed + DCP)..." + dotnet build "$BUNDLE_PROJ" -c "$EFFECTIVE_CONFIG" /p:SkipNativeBuild=true "/p:VersionSuffix=$VERSION_SUFFIX" + fi + if [[ $? -ne 0 ]]; then + error "Bundle build failed." + exit 1 + fi + + BUNDLE_LAYOUT_DIR="$REPO_ROOT/artifacts/bundle/$BUNDLE_RID" + + if [[ ! -d "$BUNDLE_LAYOUT_DIR" ]]; then + error "Bundle layout not found at $BUNDLE_LAYOUT_DIR" + exit 1 + fi + + # Copy managed/ and dcp/ to $HOME/.aspire so the CLI auto-discovers them + for component in managed dcp; do + SOURCE_DIR="$BUNDLE_LAYOUT_DIR/$component" + DEST_DIR="$ASPIRE_ROOT/$component" + if [[ -d "$SOURCE_DIR" ]]; then + rm -rf "$DEST_DIR" + log "Copying $component/ to $DEST_DIR" + cp -r "$SOURCE_DIR" "$DEST_DIR" + # Ensure executables are executable + if [[ "$component" == "managed" ]]; then + chmod +x "$DEST_DIR/aspire-managed" 2>/dev/null || true + elif [[ "$component" == "dcp" ]]; then + find "$DEST_DIR" -type f -name "dcp" -exec chmod +x {} \; 2>/dev/null || true + fi + else + warn "$component/ not found in bundle layout at $SOURCE_DIR" + fi + done - if [ ! -d "$CLI_PUBLISH_DIR" ]; then - # Fallback: try the non-publish directory - CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0" + log "Bundle installed to $ASPIRE_ROOT (managed/ + dcp/)" +fi + +# Install the CLI to $HOME/.aspire/bin +if [[ $SKIP_CLI -eq 0 ]]; then + if [[ $NATIVE_AOT -eq 1 ]]; then + # Native AOT CLI from Bundle.proj publish + CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli/$EFFECTIVE_CONFIG/net10.0/$BUNDLE_RID/native" + if [[ ! -d "$CLI_PUBLISH_DIR" ]]; then + CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli/$EFFECTIVE_CONFIG/net10.0/$BUNDLE_RID/publish" + fi + else + # Framework-dependent CLI from dotnet tool build + CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0/publish" + if [[ ! -d "$CLI_PUBLISH_DIR" ]]; then + CLI_PUBLISH_DIR="$REPO_ROOT/artifacts/bin/Aspire.Cli.Tool/$EFFECTIVE_CONFIG/net10.0" + fi fi CLI_SOURCE_PATH="$CLI_PUBLISH_DIR/aspire" if [ -f "$CLI_SOURCE_PATH" ]; then - log "Installing Aspire CLI to $CLI_BIN_DIR" + if [[ $NATIVE_AOT -eq 1 ]]; then + log "Installing Aspire CLI (native AOT) to $CLI_BIN_DIR" + else + log "Installing Aspire CLI to $CLI_BIN_DIR" + fi mkdir -p "$CLI_BIN_DIR" # Copy all files from the publish directory (CLI and its dependencies) @@ -255,4 +342,9 @@ if [[ $SKIP_CLI -eq 0 ]]; then log "The locally-built CLI was installed to: $HOME/.aspire/bin" echo fi +if [[ $SKIP_BUNDLE -eq 0 ]]; then + log "Bundle (aspire-managed + DCP) installed to: $HOME/.aspire" + log " The CLI at ~/.aspire/bin/ will auto-discover managed/ and dcp/ in the parent directory." + echo +fi log "The Aspire CLI discovers channels automatically from the hives directory; no extra flags are required." diff --git a/src/Aspire.Cli/Bundles/BundleService.cs b/src/Aspire.Cli/Bundles/BundleService.cs index a69179b0254..f356fe97c92 100644 --- a/src/Aspire.Cli/Bundles/BundleService.cs +++ b/src/Aspire.Cli/Bundles/BundleService.cs @@ -41,11 +41,8 @@ internal sealed class BundleService(ILayoutDiscovery layoutDiscovery, ILogger internal static readonly string[] s_layoutDirectories = [ - BundleDiscovery.RuntimeDirectoryName, - BundleDiscovery.DashboardDirectoryName, - BundleDiscovery.DcpDirectoryName, - BundleDiscovery.AppHostServerDirectoryName, - "tools" + BundleDiscovery.ManagedDirectoryName, + BundleDiscovery.DcpDirectoryName ]; /// diff --git a/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs index bf0469f8364..fbd5e6f0495 100644 --- a/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs @@ -4,47 +4,26 @@ using System.Diagnostics; using System.Text; using System.Text.Json; -using Aspire.Cli.Bundles; using Aspire.Cli.DotNet; -using Aspire.Cli.Layout; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Certificates; /// -/// Certificate tool runner that uses the bundled dev-certs DLL with the bundled runtime. +/// Certificate tool runner for bundle mode. +/// Falls back to the global dotnet SDK's dev-certs command since the bundle +/// no longer includes a separate muxer/dev-certs DLL. /// internal sealed class BundleCertificateToolRunner( - IBundleService bundleService, ILogger logger) : ICertificateToolRunner { - private async Task GetLayoutAsync(CancellationToken cancellationToken) - { - return await bundleService.EnsureExtractedAndGetLayoutAsync(cancellationToken).ConfigureAwait(false) - ?? throw new InvalidOperationException("Bundle layout not found after extraction."); - } - public async Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { - var layout = await GetLayoutAsync(cancellationToken); - var muxerPath = layout.GetMuxerPath(); - var devCertsPath = layout.GetDevCertsPath(); - - if (muxerPath is null) - { - throw new InvalidOperationException("Bundle runtime not found. The bundle may be corrupt."); - } - - if (devCertsPath is null || !File.Exists(devCertsPath)) - { - throw new InvalidOperationException("dev-certs tool not found in bundle. The bundle may be corrupt or incomplete."); - } - var outputBuilder = new StringBuilder(); - var startInfo = new ProcessStartInfo(muxerPath) + var startInfo = new ProcessStartInfo("dotnet") { WorkingDirectory = Environment.CurrentDirectory, UseShellExecute = false, @@ -53,8 +32,8 @@ private async Task GetLayoutAsync(CancellationToken cancell RedirectStandardError = true }; - // Use ArgumentList to prevent command injection - startInfo.ArgumentList.Add(devCertsPath); + // Use global dotnet dev-certs + startInfo.ArgumentList.Add("dev-certs"); startInfo.ArgumentList.Add("https"); startInfo.ArgumentList.Add("--check-trust-machine-readable"); @@ -138,21 +117,7 @@ public async Task TrustHttpCertificateAsync( DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { - var layout = await GetLayoutAsync(cancellationToken); - var muxerPath = layout.GetMuxerPath(); - var devCertsPath = layout.GetDevCertsPath(); - - if (muxerPath is null) - { - throw new InvalidOperationException("Bundle runtime not found. The bundle may be corrupt."); - } - - if (devCertsPath is null || !File.Exists(devCertsPath)) - { - throw new InvalidOperationException("dev-certs tool not found in bundle. The bundle may be corrupt or incomplete."); - } - - var startInfo = new ProcessStartInfo(muxerPath) + var startInfo = new ProcessStartInfo("dotnet") { WorkingDirectory = Environment.CurrentDirectory, UseShellExecute = false, @@ -161,8 +126,8 @@ public async Task TrustHttpCertificateAsync( RedirectStandardError = true }; - // Use ArgumentList to prevent command injection - startInfo.ArgumentList.Add(devCertsPath); + // Use global dotnet dev-certs + startInfo.ArgumentList.Add("dev-certs"); startInfo.ArgumentList.Add("https"); startInfo.ArgumentList.Add("--trust"); @@ -192,4 +157,4 @@ public async Task TrustHttpCertificateAsync( return process.ExitCode; } -} \ No newline at end of file +} diff --git a/src/Aspire.Cli/Layout/LayoutConfiguration.cs b/src/Aspire.Cli/Layout/LayoutConfiguration.cs index 1fc3e313000..08139cb02b0 100644 --- a/src/Aspire.Cli/Layout/LayoutConfiguration.cs +++ b/src/Aspire.Cli/Layout/LayoutConfiguration.cs @@ -12,18 +12,10 @@ public enum LayoutComponent { /// CLI executable. Cli, - /// .NET runtime. - Runtime, - /// Pre-built AppHost Server. - AppHostServer, - /// Aspire Dashboard. - Dashboard, /// Developer Control Plane. Dcp, - /// NuGet Helper tool. - NuGetHelper, - /// Dev-certs tool. - DevCerts + /// Unified managed binary (dashboard, server, nuget). + Managed } /// @@ -42,11 +34,6 @@ public sealed class LayoutConfiguration /// public string? Platform { get; set; } - /// - /// .NET runtime version included in the bundle (e.g., "10.0.0"). - /// - public string? RuntimeVersion { get; set; } - /// /// Root path of the layout. /// @@ -75,85 +62,32 @@ public sealed class LayoutConfiguration var relativePath = component switch { LayoutComponent.Cli => Components.Cli, - LayoutComponent.Runtime => Components.Runtime, - LayoutComponent.AppHostServer => Components.ApphostServer, - LayoutComponent.Dashboard => Components.Dashboard, LayoutComponent.Dcp => Components.Dcp, - LayoutComponent.NuGetHelper => Components.NugetHelper, - LayoutComponent.DevCerts => Components.DevCerts, + LayoutComponent.Managed => Components.Managed, _ => null }; return relativePath is not null ? Path.Combine(LayoutPath, relativePath) : null; } - /// - /// Gets the path to the dotnet muxer executable from the bundled runtime. - /// - public string? GetMuxerPath() - { - var runtimePath = GetComponentPath(LayoutComponent.Runtime); - if (runtimePath is null) - { - return null; - } - - var bundledPath = Path.Combine(runtimePath, BundleDiscovery.GetDotNetExecutableName()); - return File.Exists(bundledPath) ? bundledPath : null; - } - - /// - /// Gets the path to the dotnet executable. Alias for GetMuxerPath. - /// - public string? GetDotNetExePath() => GetMuxerPath(); - /// /// Gets the path to the DCP directory. /// public string? GetDcpPath() => GetComponentPath(LayoutComponent.Dcp); /// - /// Gets the path to the Dashboard directory. - /// - public string? GetDashboardPath() => GetComponentPath(LayoutComponent.Dashboard); - - /// - /// Gets the path to the AppHost Server executable. - /// - /// The path to aspire-server.exe. - public string? GetAppHostServerPath() - { - var serverPath = GetComponentPath(LayoutComponent.AppHostServer); - if (serverPath is null) - { - return null; - } - - return Path.Combine(serverPath, BundleDiscovery.GetExecutableFileName(BundleDiscovery.AppHostServerExecutableName)); - } - - /// - /// Gets the path to the NuGet Helper executable. + /// Gets the path to the aspire-managed executable. /// - /// The path to aspire-nuget.exe. - public string? GetNuGetHelperPath() + /// The path to aspire-managed(.exe). + public string? GetManagedPath() { - var helperPath = GetComponentPath(LayoutComponent.NuGetHelper); - if (helperPath is null) + var managedDir = GetComponentPath(LayoutComponent.Managed); + if (managedDir is null) { return null; } - return Path.Combine(helperPath, BundleDiscovery.GetExecutableFileName(BundleDiscovery.NuGetHelperExecutableName)); - } - - /// - /// Gets the path to the dev-certs DLL (requires dotnet muxer to run). - /// - public string? GetDevCertsPath() - { - var devCertsPath = GetComponentPath(LayoutComponent.DevCerts); - return devCertsPath is not null ? Path.Combine(devCertsPath, BundleDiscovery.GetDllFileName(BundleDiscovery.DevCertsExecutableName)) : null; + return Path.Combine(managedDir, BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName)); } } @@ -167,33 +101,13 @@ public sealed class LayoutComponents /// public string? Cli { get; set; } = "aspire"; - /// - /// Path to .NET runtime directory. - /// - public string? Runtime { get; set; } = BundleDiscovery.RuntimeDirectoryName; - - /// - /// Path to pre-built AppHost Server. - /// - public string? ApphostServer { get; set; } = BundleDiscovery.AppHostServerDirectoryName; - - /// - /// Path to Aspire Dashboard. - /// - public string? Dashboard { get; set; } = BundleDiscovery.DashboardDirectoryName; - /// /// Path to Developer Control Plane. /// public string? Dcp { get; set; } = BundleDiscovery.DcpDirectoryName; /// - /// Path to NuGet Helper tool. - /// - public string? NugetHelper { get; set; } = BundleDiscovery.NuGetHelperDirectoryName; - - /// - /// Path to dev-certs tool. + /// Path to the unified managed binary directory. /// - public string? DevCerts { get; set; } = BundleDiscovery.DevCertsDirectoryName; + public string? Managed { get; set; } = BundleDiscovery.ManagedDirectoryName; } diff --git a/src/Aspire.Cli/Layout/LayoutDiscovery.cs b/src/Aspire.Cli/Layout/LayoutDiscovery.cs index 85673ef5486..2fe62c819a6 100644 --- a/src/Aspire.Cli/Layout/LayoutDiscovery.cs +++ b/src/Aspire.Cli/Layout/LayoutDiscovery.cs @@ -73,10 +73,8 @@ public LayoutDiscovery(ILogger logger) // Check environment variable overrides first var envPath = component switch { - LayoutComponent.Runtime => Environment.GetEnvironmentVariable(BundleDiscovery.RuntimePathEnvVar), LayoutComponent.Dcp => Environment.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar), - LayoutComponent.Dashboard => Environment.GetEnvironmentVariable(BundleDiscovery.DashboardPathEnvVar), - LayoutComponent.AppHostServer => Environment.GetEnvironmentVariable(BundleDiscovery.AppHostServerPathEnvVar), + LayoutComponent.Managed => Environment.GetEnvironmentVariable(BundleDiscovery.ManagedPathEnvVar), _ => null }; @@ -114,7 +112,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) private LayoutConfiguration? TryLoadLayoutFromPath(string layoutPath) { _logger.LogDebug("TryLoadLayoutFromPath: {Path}", layoutPath); - + if (!Directory.Exists(layoutPath)) { _logger.LogDebug("Layout path does not exist: {Path}", layoutPath); @@ -122,7 +120,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) } _logger.LogDebug("Layout path exists, checking directory structure..."); - + // Log directory contents for debugging try { @@ -159,7 +157,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) // Check if CLI is in a bundle layout // First, check if components are siblings of the CLI (flat layout): - // {layout}/aspire + {layout}/runtime/ + {layout}/dashboard/ + ... + // {layout}/aspire + {layout}/managed/ + {layout}/dcp/ var layout = TryInferLayout(cliDir); if (layout is not null) { @@ -167,7 +165,7 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) } // Next, check the parent directory (bin/ layout where CLI is in a subdirectory): - // {layout}/bin/aspire + {layout}/runtime/ + {layout}/dashboard/ + ... + // {layout}/bin/aspire + {layout}/managed/ + {layout}/dcp/ var parentDir = Path.GetDirectoryName(cliDir); if (!string.IsNullOrEmpty(parentDir)) { @@ -184,33 +182,28 @@ public bool IsBundleModeAvailable(string? projectDirectory = null) private LayoutConfiguration? TryInferLayout(string layoutPath) { - // Check for essential directories using BundleDiscovery constants - var runtimePath = Path.Combine(layoutPath, BundleDiscovery.RuntimeDirectoryName); - var dashboardPath = Path.Combine(layoutPath, BundleDiscovery.DashboardDirectoryName); + // Check for essential directories + var managedPath = Path.Combine(layoutPath, BundleDiscovery.ManagedDirectoryName); var dcpPath = Path.Combine(layoutPath, BundleDiscovery.DcpDirectoryName); - var serverPath = Path.Combine(layoutPath, BundleDiscovery.AppHostServerDirectoryName); _logger.LogDebug("TryInferLayout: Checking layout at {Path}", layoutPath); - _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.RuntimeDirectoryName, Directory.Exists(runtimePath) ? "exists" : "MISSING"); - _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.DashboardDirectoryName, Directory.Exists(dashboardPath) ? "exists" : "MISSING"); + _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.ManagedDirectoryName, Directory.Exists(managedPath) ? "exists" : "MISSING"); _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.DcpDirectoryName, Directory.Exists(dcpPath) ? "exists" : "MISSING"); - _logger.LogDebug(" {Dir}/: {Exists}", BundleDiscovery.AppHostServerDirectoryName, Directory.Exists(serverPath) ? "exists" : "MISSING"); - if (!Directory.Exists(runtimePath) || !Directory.Exists(dashboardPath) || - !Directory.Exists(dcpPath) || !Directory.Exists(serverPath)) + if (!Directory.Exists(managedPath) || !Directory.Exists(dcpPath)) { _logger.LogDebug("TryInferLayout: Layout rejected - missing required directories"); return null; } - // Check for muxer - var muxerName = BundleDiscovery.GetDotNetExecutableName(); - var muxerPath = Path.Combine(runtimePath, muxerName); - _logger.LogDebug(" runtime/{Muxer}: {Exists}", muxerName, File.Exists(muxerPath) ? "exists" : "MISSING"); - - if (!File.Exists(muxerPath)) + // Check for aspire-managed executable + var managedExeName = BundleDiscovery.GetExecutableFileName(BundleDiscovery.ManagedExecutableName); + var managedExePath = Path.Combine(managedPath, managedExeName); + _logger.LogDebug(" managed/{ManagedExe}: {Exists}", managedExeName, File.Exists(managedExePath) ? "exists" : "MISSING"); + + if (!File.Exists(managedExePath)) { - _logger.LogDebug("TryInferLayout: Layout rejected - muxer not found"); + _logger.LogDebug("TryInferLayout: Layout rejected - aspire-managed not found"); return null; } @@ -228,18 +221,14 @@ private LayoutConfiguration LogEnvironmentOverrides(LayoutConfiguration config) { // Environment variables for specific components take precedence // These will be checked at GetComponentPath time, but we note them here for logging - - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.RuntimePathEnvVar))) - { - _logger.LogDebug("Runtime path override from {EnvVar}", BundleDiscovery.RuntimePathEnvVar); - } + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.DcpPathEnvVar))) { _logger.LogDebug("DCP path override from {EnvVar}", BundleDiscovery.DcpPathEnvVar); } - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.DashboardPathEnvVar))) + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(BundleDiscovery.ManagedPathEnvVar))) { - _logger.LogDebug("Dashboard path override from {EnvVar}", BundleDiscovery.DashboardPathEnvVar); + _logger.LogDebug("Managed path override from {EnvVar}", BundleDiscovery.ManagedPathEnvVar); } return config; @@ -247,23 +236,15 @@ private LayoutConfiguration LogEnvironmentOverrides(LayoutConfiguration config) private bool ValidateLayout(LayoutConfiguration layout) { - // Check that muxer exists (global dotnet in dev mode, bundled in production) - var muxerPath = layout.GetMuxerPath(); - if (muxerPath is null || !File.Exists(muxerPath)) - { - _logger.LogDebug("Layout validation failed: muxer not found at {Path}", muxerPath); - return false; - } - - // Check that AppHostServer exists - var serverPath = layout.GetAppHostServerPath(); - if (serverPath is null || !File.Exists(serverPath)) + // Check that aspire-managed exists + var managedPath = layout.GetManagedPath(); + if (managedPath is null || !File.Exists(managedPath)) { - _logger.LogDebug("Layout validation failed: AppHostServer not found at {Path}", serverPath); + _logger.LogDebug("Layout validation failed: aspire-managed not found at {Path}", managedPath); return false; } - // Require DCP and Dashboard for valid layouts + // Require DCP for valid layouts var dcpPath = layout.GetComponentPath(LayoutComponent.Dcp); if (dcpPath is null || !Directory.Exists(dcpPath)) { @@ -271,13 +252,6 @@ private bool ValidateLayout(LayoutConfiguration layout) return false; } - var dashboardPath = layout.GetComponentPath(LayoutComponent.Dashboard); - if (dashboardPath is null || !Directory.Exists(dashboardPath)) - { - _logger.LogDebug("Layout validation failed: Dashboard not found"); - return false; - } - return true; } } diff --git a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs index 3b2f5e79192..19d8e1716b8 100644 --- a/src/Aspire.Cli/Layout/LayoutProcessRunner.cs +++ b/src/Aspire.Cli/Layout/LayoutProcessRunner.cs @@ -24,29 +24,22 @@ internal static class RuntimeIdentifierHelper } /// -/// Utilities for running processes using the layout's .NET runtime. -/// Supports both native executables and framework-dependent DLLs. +/// Utilities for running processes using layout tools. +/// All layout tools are self-contained executables — no muxer needed. /// internal static class LayoutProcessRunner { /// - /// Determines if a path refers to a DLL that needs dotnet to run. - /// - private static bool IsDll(string path) => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase); - - /// - /// Runs a tool and captures output. Automatically detects if the tool - /// is a DLL (needs muxer) or native executable (runs directly). + /// Runs a tool and captures output. The tool is always run directly as a native executable. /// public static async Task<(int ExitCode, string Output, string Error)> RunAsync( - LayoutConfiguration layout, string toolPath, IEnumerable arguments, string? workingDirectory = null, IDictionary? environmentVariables = null, CancellationToken ct = default) { - using var process = CreateProcess(layout, toolPath, arguments, workingDirectory, environmentVariables, redirectOutput: true); + using var process = CreateProcess(toolPath, arguments, workingDirectory, environmentVariables, redirectOutput: true); process.Start(); @@ -63,49 +56,33 @@ internal static class LayoutProcessRunner /// Returns the Process object for the caller to manage. /// public static Process Start( - LayoutConfiguration layout, string toolPath, IEnumerable arguments, string? workingDirectory = null, IDictionary? environmentVariables = null, bool redirectOutput = false) { - var process = CreateProcess(layout, toolPath, arguments, workingDirectory, environmentVariables, redirectOutput); + var process = CreateProcess(toolPath, arguments, workingDirectory, environmentVariables, redirectOutput); process.Start(); return process; } /// /// Creates a configured Process for running a bundle tool. - /// For DLLs, uses the layout's muxer (dotnet). For executables, runs directly. + /// Tools are always self-contained executables — run directly. /// private static Process CreateProcess( - LayoutConfiguration layout, string toolPath, IEnumerable arguments, string? workingDirectory, IDictionary? environmentVariables, bool redirectOutput) { - var isDll = IsDll(toolPath); var process = new Process(); process.StartInfo.UseShellExecute = false; process.StartInfo.CreateNoWindow = true; - - if (isDll) - { - // DLLs need the muxer to run - var muxerPath = layout.GetMuxerPath() - ?? throw new InvalidOperationException("Layout muxer not found. Cannot run framework-dependent tool."); - process.StartInfo.FileName = muxerPath; - process.StartInfo.ArgumentList.Add(toolPath); - } - else - { - // Native executables run directly - process.StartInfo.FileName = toolPath; - } + process.StartInfo.FileName = toolPath; if (redirectOutput) { @@ -113,14 +90,6 @@ private static Process CreateProcess( process.StartInfo.RedirectStandardError = true; } - // Set DOTNET_ROOT to use the layout's runtime - var runtimePath = layout.GetComponentPath(LayoutComponent.Runtime); - if (runtimePath is not null) - { - process.StartInfo.Environment["DOTNET_ROOT"] = runtimePath; - process.StartInfo.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; - } - // Add custom environment variables if (environmentVariables is not null) { diff --git a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs index ce2a5e61136..7b21897deed 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetPackageCache.cs @@ -118,15 +118,16 @@ private async Task> SearchPackagesInternalAsync( throw new InvalidOperationException("Bundle layout not found. Cannot perform NuGet search in bundle mode."); } - var helperPath = layout.GetNuGetHelperPath(); - if (helperPath is null || !File.Exists(helperPath)) + var managedPath = layout.GetManagedPath(); + if (managedPath is null || !File.Exists(managedPath)) { - throw new InvalidOperationException("NuGet helper tool not found at expected location."); + throw new InvalidOperationException("aspire-managed not found in layout."); } - // Build arguments for NuGetHelper search command + // Build arguments for NuGet search command (via aspire-managed nuget subcommand) var args = new List { + "nuget", "search", "--query", query, "--take", "1000", @@ -155,14 +156,13 @@ private async Task> SearchPackagesInternalAsync( args.Add("--verbose"); } - _logger.LogDebug("Running NuGet search via NuGetHelper: {Query}", query); - _logger.LogDebug("NuGetHelper path: {HelperPath}", helperPath); - _logger.LogDebug("NuGetHelper args: {Args}", string.Join(" ", args)); + _logger.LogDebug("Running NuGet search via aspire-managed: {Query}", query); + _logger.LogDebug("aspire-managed path: {ManagedPath}", managedPath); + _logger.LogDebug("NuGet search args: {Args}", string.Join(" ", args)); _logger.LogDebug("Working directory: {WorkingDir}", workingDirectory.FullName); var (exitCode, output, error) = await LayoutProcessRunner.RunAsync( - layout, - helperPath, + managedPath, args, workingDirectory: workingDirectory.FullName, ct: cancellationToken).ConfigureAwait(false); diff --git a/src/Aspire.Cli/NuGet/BundleNuGetService.cs b/src/Aspire.Cli/NuGet/BundleNuGetService.cs index e54e612559e..4a1193fe051 100644 --- a/src/Aspire.Cli/NuGet/BundleNuGetService.cs +++ b/src/Aspire.Cli/NuGet/BundleNuGetService.cs @@ -60,10 +60,10 @@ public async Task RestorePackagesAsync( throw new InvalidOperationException("Bundle layout not found. Cannot perform NuGet restore in bundle mode."); } - var helperPath = layout.GetNuGetHelperPath(); - if (helperPath is null || !File.Exists(helperPath)) + var managedPath = layout.GetManagedPath(); + if (managedPath is null || !File.Exists(managedPath)) { - throw new InvalidOperationException($"NuGet helper tool not found."); + throw new InvalidOperationException("aspire-managed not found in layout."); } var packageList = packages.ToList(); @@ -89,8 +89,10 @@ public async Task RestorePackagesAsync( Directory.CreateDirectory(objDir); // Step 1: Restore packages + // Prepend "nuget" subcommand for aspire-managed dispatch var restoreArgs = new List { + "nuget", "restore", "--output", objDir, "--framework", targetFramework @@ -125,12 +127,11 @@ public async Task RestorePackagesAsync( } _logger.LogDebug("Restoring {Count} packages", packageList.Count); - _logger.LogDebug("NuGetHelper path: {HelperPath}", helperPath); - _logger.LogDebug("NuGetHelper args: {Args}", string.Join(" ", restoreArgs)); + _logger.LogDebug("aspire-managed path: {ManagedPath}", managedPath); + _logger.LogDebug("NuGet restore args: {Args}", string.Join(" ", restoreArgs)); var (exitCode, output, error) = await LayoutProcessRunner.RunAsync( - layout, - helperPath, + managedPath, restoreArgs, ct: ct); @@ -149,8 +150,10 @@ public async Task RestorePackagesAsync( } // Step 2: Create flat layout + // Prepend "nuget" subcommand for aspire-managed dispatch var layoutArgs = new List { + "nuget", "layout", "--assets", assetsPath, "--output", libsDir, @@ -164,11 +167,10 @@ public async Task RestorePackagesAsync( } _logger.LogDebug("Creating layout from {AssetsPath}", assetsPath); - _logger.LogDebug("Layout args: {Args}", string.Join(" ", layoutArgs)); + _logger.LogDebug("NuGet layout args: {Args}", string.Join(" ", layoutArgs)); (exitCode, output, error) = await LayoutProcessRunner.RunAsync( - layout, - helperPath, + managedPath, layoutArgs, ct: ct); diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index f58c684790f..b8d147ffb70 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -231,7 +231,6 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar if (bundleService.IsBundle) { return new BundleCertificateToolRunner( - bundleService, loggerFactory.CreateLogger()); } diff --git a/src/Aspire.Cli/Projects/AppHostServerProject.cs b/src/Aspire.Cli/Projects/AppHostServerProject.cs index df3784ea968..79db30709c6 100644 --- a/src/Aspire.Cli/Projects/AppHostServerProject.cs +++ b/src/Aspire.Cli/Projects/AppHostServerProject.cs @@ -75,7 +75,7 @@ public async Task CreateAsync(string appPath, Cancellatio var layout = await bundleService.EnsureExtractedAndGetLayoutAsync(cancellationToken); // Priority 3: Check if we have a bundle layout with a pre-built AppHost server - if (layout is not null && layout.GetAppHostServerPath() is string serverPath && File.Exists(serverPath)) + if (layout is not null && layout.GetManagedPath() is string serverPath && File.Exists(serverPath)) { return new PrebuiltAppHostServer( appPath, diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index ee528bedb4d..0a1df3e69a2 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -72,17 +72,17 @@ public PrebuiltAppHostServer( public string AppPath => _appPath; /// - /// Gets the path to the pre-built AppHost server (exe or DLL). + /// Gets the path to the aspire-managed executable (used as the server). /// public string GetServerPath() { - var serverPath = _layout.GetAppHostServerPath(); - if (serverPath is null || !File.Exists(serverPath)) + var managedPath = _layout.GetManagedPath(); + if (managedPath is null || !File.Exists(managedPath)) { - throw new InvalidOperationException("Pre-built AppHost server not found in layout."); + throw new InvalidOperationException("aspire-managed not found in layout."); } - return serverPath; + return managedPath; } /// @@ -220,11 +220,7 @@ public async Task PrepareAsync( { var serverPath = GetServerPath(); - // Get runtime path for DOTNET_ROOT - var runtimePath = _layout.GetDotNetExePath(); - var runtimeDir = runtimePath is not null ? Path.GetDirectoryName(runtimePath) : null; - - // Bundle always uses single-file executables - run directly + // aspire-managed is self-contained - run directly var startInfo = new ProcessStartInfo(serverPath) { WorkingDirectory = _workingDirectory, @@ -233,14 +229,8 @@ public async Task PrepareAsync( CreateNoWindow = true }; - // Set DOTNET_ROOT so the executable can find the runtime - if (runtimeDir is not null) - { - startInfo.Environment["DOTNET_ROOT"] = runtimeDir; - startInfo.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0"; - } - - // Add arguments to point to our appsettings.json + // Insert "server" subcommand, then "--" separator, then remaining args + startInfo.ArgumentList.Add("server"); startInfo.ArgumentList.Add("--"); startInfo.ArgumentList.Add("--contentRoot"); startInfo.ArgumentList.Add(_workingDirectory); @@ -259,12 +249,6 @@ public async Task PrepareAsync( startInfo.Environment["REMOTE_APP_HOST_PID"] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture); startInfo.Environment[KnownConfigNames.CliProcessId] = hostPid.ToString(System.Globalization.CultureInfo.InvariantCulture); - // Also set ASPIRE_RUNTIME_PATH so DashboardEventHandlers knows which dotnet to use - if (runtimeDir is not null) - { - startInfo.Environment[BundleDiscovery.RuntimePathEnvVar] = runtimeDir; - } - // Pass the integration libs path so the server can resolve assemblies via AssemblyLoader if (_integrationLibsPath is not null) { @@ -283,12 +267,11 @@ public async Task PrepareAsync( startInfo.Environment[BundleDiscovery.DcpPathEnvVar] = dcpPath; } - var dashboardPath = _layout.GetDashboardPath(); - if (dashboardPath is not null) + // Set ASPIRE_DASHBOARD_PATH to the aspire-managed exe (DashboardEventHandlers will detect it) + var managedPath = _layout.GetManagedPath(); + if (managedPath is not null) { - // Bundle uses single-file executables - var dashboardExe = Path.Combine(dashboardPath, BundleDiscovery.GetExecutableFileName(BundleDiscovery.DashboardExecutableName)); - startInfo.Environment[BundleDiscovery.DashboardPathEnvVar] = dashboardExe; + startInfo.Environment[BundleDiscovery.DashboardPathEnvVar] = managedPath; } // Apply environment variables from apphost.run.json diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 6f20bb6a086..d2a140c291c 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -288,34 +288,32 @@ private void AddDashboardResource(DistributedApplicationModel model) var fullyQualifiedDashboardPath = Path.GetFullPath(dashboardPath); var dashboardWorkingDirectory = Path.GetDirectoryName(fullyQualifiedDashboardPath); - // Create custom runtime config with AppHost's framework versions - var customRuntimeConfigPath = CreateCustomRuntimeConfig(fullyQualifiedDashboardPath); - - // Determine if this is a single-file executable or DLL-based deployment - // Single-file: run the exe directly with custom runtime config - // DLL-based: run via dotnet exec - var isSingleFileExe = IsSingleFileExecutable(fullyQualifiedDashboardPath); - ExecutableResource dashboardResource; - - if (isSingleFileExe) + + if (BundleDiscovery.IsAspireManagedBinary(fullyQualifiedDashboardPath)) { - // Single-file executable - run directly + // aspire-managed: self-contained binary, no DOTNET_ROOT or custom runtime config needed dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, fullyQualifiedDashboardPath, dashboardWorkingDirectory ?? ""); - - // Set DOTNET_ROOT so the single-file app can find the shared framework - var dotnetRoot = BundleDiscovery.GetDotNetRoot(); - if (!string.IsNullOrEmpty(dotnetRoot)) + + // Prepend "dashboard" subcommand + dashboardResource.Annotations.Add(new CommandLineArgsCallbackAnnotation(args => { - dashboardResource.Annotations.Add(new EnvironmentCallbackAnnotation(env => - { - env["DOTNET_ROOT"] = dotnetRoot; - env["DOTNET_MULTILEVEL_LOOKUP"] = "0"; - })); - } + args.Insert(0, "dashboard"); + })); + } + else if (IsSingleFileExecutable(fullyQualifiedDashboardPath)) + { + // Create custom runtime config with AppHost's framework versions + var customRuntimeConfigPath = CreateCustomRuntimeConfig(fullyQualifiedDashboardPath); + + // Single-file executable - run directly + dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, fullyQualifiedDashboardPath, dashboardWorkingDirectory ?? ""); } else { + // Create custom runtime config with AppHost's framework versions + var customRuntimeConfigPath = CreateCustomRuntimeConfig(fullyQualifiedDashboardPath); + // DLL-based deployment - find the DLL and run via dotnet exec string dashboardDll; if (string.Equals(".dll", Path.GetExtension(fullyQualifiedDashboardPath), StringComparison.OrdinalIgnoreCase)) @@ -338,8 +336,7 @@ private void AddDashboardResource(DistributedApplicationModel model) distributedApplicationLogger.LogError("Dashboard DLL not found: {Path}", dashboardDll); } - var dotnetExecutable = BundleDiscovery.GetDotNetExecutablePath(); - dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, dotnetExecutable, dashboardWorkingDirectory ?? ""); + dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, "dotnet", dashboardWorkingDirectory ?? ""); dashboardResource.Annotations.Add(new CommandLineArgsCallbackAnnotation(args => { diff --git a/src/Aspire.Managed/Aspire.Managed.csproj b/src/Aspire.Managed/Aspire.Managed.csproj new file mode 100644 index 00000000000..84c5f45c27a --- /dev/null +++ b/src/Aspire.Managed/Aspire.Managed.csproj @@ -0,0 +1,44 @@ + + + + Exe + net10.0 + enable + enable + aspire-managed + + + false + false + false + + + $(NoWarn);CS1591 + + + true + + + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Aspire.Managed/Program.cs b/src/Aspire.Managed/Program.cs new file mode 100644 index 00000000000..5deccea0c79 --- /dev/null +++ b/src/Aspire.Managed/Program.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Dashboard; + +return args switch +{ + ["dashboard", .. var rest] => RunDashboard(rest), + ["server", .. var rest] => await RunServer(rest).ConfigureAwait(false), + ["nuget", .. var rest] => await RunNuGet(rest).ConfigureAwait(false), + _ => ShowUsage() +}; + +static int RunDashboard(string[] args) +{ + var options = new WebApplicationOptions + { + Args = args, + ContentRootPath = AppContext.BaseDirectory + }; + + var app = new DashboardWebApplication(options: options); + return app.Run(); +} + +static async Task RunServer(string[] args) +{ + await Aspire.Hosting.RemoteHost.RemoteHostServer.RunAsync(args).ConfigureAwait(false); + return 0; +} + +static async Task RunNuGet(string[] args) +{ + return await Aspire.Cli.NuGetHelper.Program.Main(args).ConfigureAwait(false); +} + +static int ShowUsage() +{ + Console.Error.WriteLine($"Usage: {AppDomain.CurrentDomain.FriendlyName} [args...]"); + return 1; +} diff --git a/src/Shared/BundleDiscovery.cs b/src/Shared/BundleDiscovery.cs index 726b22d0e98..e72818683ac 100644 --- a/src/Shared/BundleDiscovery.cs +++ b/src/Shared/BundleDiscovery.cs @@ -32,18 +32,14 @@ internal static class BundleDiscovery /// /// Environment variable for overriding the Dashboard path. + /// Still used by DcpOptions/DashboardEventHandlers — value now points to aspire-managed exe. /// public const string DashboardPathEnvVar = "ASPIRE_DASHBOARD_PATH"; /// - /// Environment variable for overriding the .NET runtime path. + /// Environment variable for overriding the aspire-managed path. /// - public const string RuntimePathEnvVar = "ASPIRE_RUNTIME_PATH"; - - /// - /// Environment variable for overriding the AppHost Server path. - /// - public const string AppHostServerPathEnvVar = "ASPIRE_APPHOST_SERVER_PATH"; + public const string ManagedPathEnvVar = "ASPIRE_MANAGED_PATH"; /// /// Environment variable to force SDK mode (skip bundle detection). @@ -65,53 +61,18 @@ internal static class BundleDiscovery public const string DcpDirectoryName = "dcp"; /// - /// Directory name for Dashboard in the bundle layout. - /// - public const string DashboardDirectoryName = "dashboard"; - - /// - /// Directory name for .NET runtime in the bundle layout. - /// - public const string RuntimeDirectoryName = "runtime"; - - /// - /// Directory name for AppHost Server in the bundle layout. - /// - public const string AppHostServerDirectoryName = "aspire-server"; - - /// - /// Directory name for NuGet Helper tool in the bundle layout. + /// Directory name for the managed binary in the bundle layout. /// - public const string NuGetHelperDirectoryName = "tools/aspire-nuget"; - - /// - /// Directory name for dev-certs tool in the bundle layout. - /// - public const string DevCertsDirectoryName = "tools/dev-certs"; + public const string ManagedDirectoryName = "managed"; // ═══════════════════════════════════════════════════════════════════════ // EXECUTABLE NAMES (without path, just the file name) // ═══════════════════════════════════════════════════════════════════════ /// - /// Executable name for the AppHost Server. - /// - public const string AppHostServerExecutableName = "aspire-server"; - - /// - /// Executable name for the Dashboard. + /// Executable name for the unified managed binary. /// - public const string DashboardExecutableName = "Aspire.Dashboard"; - - /// - /// Executable name for the NuGet Helper tool. - /// - public const string NuGetHelperExecutableName = "aspire-nuget"; - - /// - /// Executable name for the dev-certs tool. - /// - public const string DevCertsExecutableName = "dotnet-dev-certs"; + public const string ManagedExecutableName = "aspire-managed"; // ═══════════════════════════════════════════════════════════════════════ // DISCOVERY METHODS @@ -156,28 +117,28 @@ public static bool TryDiscoverDcpFromDirectory( } /// - /// Attempts to discover Dashboard from a base directory. + /// Attempts to discover the aspire-managed binary from a base directory. /// /// The base directory to search from. - /// The full path to the Dashboard directory if found. - /// True if Dashboard was found, false otherwise. - public static bool TryDiscoverDashboardFromDirectory( + /// The full path to the aspire-managed executable if found. + /// True if aspire-managed was found, false otherwise. + public static bool TryDiscoverManagedFromDirectory( string baseDirectory, - out string? dashboardPath) + out string? managedPath) { - dashboardPath = null; + managedPath = null; if (string.IsNullOrEmpty(baseDirectory) || !Directory.Exists(baseDirectory)) { return false; } - var dashboardDir = Path.Combine(baseDirectory, DashboardDirectoryName); - var dashboardExe = Path.Combine(dashboardDir, GetExecutableFileName(DashboardExecutableName)); + var managedDir = Path.Combine(baseDirectory, ManagedDirectoryName); + var managedExe = Path.Combine(managedDir, GetExecutableFileName(ManagedExecutableName)); - if (File.Exists(dashboardExe)) + if (File.Exists(managedExe)) { - dashboardPath = dashboardDir; + managedPath = managedExe; return true; } @@ -207,12 +168,12 @@ public static bool TryDiscoverDcpFromEntryAssembly( } /// - /// Attempts to discover Dashboard relative to the entry assembly. + /// Attempts to discover aspire-managed relative to the entry assembly. /// This is used by Aspire.Hosting when no environment variables are set. /// - public static bool TryDiscoverDashboardFromEntryAssembly(out string? dashboardPath) + public static bool TryDiscoverManagedFromEntryAssembly(out string? managedPath) { - dashboardPath = null; + managedPath = null; var baseDir = GetEntryAssemblyDirectory(); if (baseDir is null) @@ -220,52 +181,7 @@ public static bool TryDiscoverDashboardFromEntryAssembly(out string? dashboardPa return false; } - return TryDiscoverDashboardFromDirectory(baseDir, out dashboardPath); - } - - /// - /// Attempts to discover .NET runtime from a base directory. - /// Checks for the expected bundle layout structure with dotnet executable. - /// - /// The base directory to search from. - /// The full path to the runtime directory if found. - /// True if runtime was found, false otherwise. - public static bool TryDiscoverRuntimeFromDirectory(string baseDirectory, out string? runtimePath) - { - runtimePath = null; - - if (string.IsNullOrEmpty(baseDirectory) || !Directory.Exists(baseDirectory)) - { - return false; - } - - var runtimeDir = Path.Combine(baseDirectory, RuntimeDirectoryName); - var dotnetPath = Path.Combine(runtimeDir, GetDotNetExecutableName()); - - if (File.Exists(dotnetPath)) - { - runtimePath = runtimeDir; - return true; - } - - return false; - } - - /// - /// Attempts to discover .NET runtime relative to the entry assembly. - /// This is used by Aspire.Hosting when no environment variables are set. - /// - public static bool TryDiscoverRuntimeFromEntryAssembly(out string? runtimePath) - { - runtimePath = null; - - var baseDir = GetEntryAssemblyDirectory(); - if (baseDir is null) - { - return false; - } - - return TryDiscoverRuntimeFromDirectory(baseDir, out runtimePath); + return TryDiscoverManagedFromDirectory(baseDir, out managedPath); } /// @@ -291,27 +207,11 @@ public static bool TryDiscoverDcpFromProcessPath( } /// - /// Attempts to discover Dashboard relative to the current process. - /// - public static bool TryDiscoverDashboardFromProcessPath(out string? dashboardPath) - { - dashboardPath = null; - - var baseDir = GetProcessDirectory(); - if (baseDir is null) - { - return false; - } - - return TryDiscoverDashboardFromDirectory(baseDir, out dashboardPath); - } - - /// - /// Attempts to discover .NET runtime relative to the current process. + /// Attempts to discover aspire-managed relative to the current process. /// - public static bool TryDiscoverRuntimeFromProcessPath(out string? runtimePath) + public static bool TryDiscoverManagedFromProcessPath(out string? managedPath) { - runtimePath = null; + managedPath = null; var baseDir = GetProcessDirectory(); if (baseDir is null) @@ -319,7 +219,7 @@ public static bool TryDiscoverRuntimeFromProcessPath(out string? runtimePath) return false; } - return TryDiscoverRuntimeFromDirectory(baseDir, out runtimePath); + return TryDiscoverManagedFromDirectory(baseDir, out managedPath); } // ═══════════════════════════════════════════════════════════════════════ @@ -343,18 +243,10 @@ public static string GetDcpExecutableName() return OperatingSystem.IsWindows() ? "dcp.exe" : "dcp"; } - /// - /// Gets the platform-specific dotnet executable name. - /// - public static string GetDotNetExecutableName() - { - return OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet"; - } - /// /// Gets the platform-specific executable name with extension. /// - /// The base executable name without extension (e.g., "aspire-server"). + /// The base executable name without extension (e.g., "aspire-managed"). /// The executable name with platform-appropriate extension. public static string GetExecutableFileName(string baseName) { @@ -372,58 +264,12 @@ public static string GetDllFileName(string baseName) } /// - /// Gets the full path to the dotnet executable from the bundled runtime, or "dotnet" if not available. - /// Resolution order: environment variable → disk discovery → PATH fallback. - /// - /// Full path to bundled dotnet executable, or "dotnet" to use PATH resolution. - public static string GetDotNetExecutablePath() - { - // 1. Check environment variable (set by CLI for guest apphosts) - var runtimePath = Environment.GetEnvironmentVariable(RuntimePathEnvVar); - if (!string.IsNullOrEmpty(runtimePath)) - { - var dotnetPath = Path.Combine(runtimePath, GetDotNetExecutableName()); - if (File.Exists(dotnetPath)) - { - return dotnetPath; - } - } - - // 2. Try disk discovery (for future installed bundle scenario) - if (TryDiscoverRuntimeFromEntryAssembly(out var discoveredRuntimePath) && discoveredRuntimePath is not null) - { - var dotnetPath = Path.Combine(discoveredRuntimePath, GetDotNetExecutableName()); - if (File.Exists(dotnetPath)) - { - return dotnetPath; - } - } - - // 3. Fall back to PATH-based resolution - return "dotnet"; - } - - /// - /// Gets the DOTNET_ROOT path for the bundled runtime. - /// This is the directory containing the dotnet executable and shared frameworks. + /// Determines if the given file path points to an aspire-managed binary. /// - /// The DOTNET_ROOT path if available, otherwise null. - public static string? GetDotNetRoot() + public static bool IsAspireManagedBinary(string path) { - // 1. Check environment variable (set by CLI for guest apphosts) - var runtimePath = Environment.GetEnvironmentVariable(RuntimePathEnvVar); - if (!string.IsNullOrEmpty(runtimePath) && Directory.Exists(runtimePath)) - { - return runtimePath; - } - - // 2. Try disk discovery (for future installed bundle scenario) - if (TryDiscoverRuntimeFromEntryAssembly(out var discoveredRuntimePath) && discoveredRuntimePath is not null) - { - return discoveredRuntimePath; - } - - return null; + var fileName = Path.GetFileNameWithoutExtension(path); + return string.Equals(fileName, ManagedExecutableName, StringComparison.OrdinalIgnoreCase); } /// diff --git a/tools/CreateLayout/Program.cs b/tools/CreateLayout/Program.cs index dbddc286b66..83e96ad4cb1 100644 --- a/tools/CreateLayout/Program.cs +++ b/tools/CreateLayout/Program.cs @@ -38,28 +38,12 @@ public static async Task Main(string[] args) Required = true }; - var runtimeOption = new Option("--runtime", "-r") - { - Description = "Path to .NET runtime to include (alternative to --download-runtime)" - }; - var bundleVersionOption = new Option("--bundle-version") { Description = "Version string for the layout", DefaultValueFactory = _ => "0.0.0-dev" }; - var runtimeVersionOption = new Option("--runtime-version") - { - Description = ".NET SDK version to download", - Required = true - }; - - var downloadRuntimeOption = new Option("--download-runtime") - { - Description = "Download .NET and ASP.NET runtimes from Microsoft" - }; - var archiveOption = new Option("--archive") { Description = "Create archive (zip/tar.gz) after building" @@ -74,10 +58,7 @@ public static async Task Main(string[] args) rootCommand.Options.Add(outputOption); rootCommand.Options.Add(artifactsOption); rootCommand.Options.Add(ridOption); - rootCommand.Options.Add(runtimeOption); rootCommand.Options.Add(bundleVersionOption); - rootCommand.Options.Add(runtimeVersionOption); - rootCommand.Options.Add(downloadRuntimeOption); rootCommand.Options.Add(archiveOption); rootCommand.Options.Add(verboseOption); @@ -86,16 +67,13 @@ public static async Task Main(string[] args) var outputPath = parseResult.GetValue(outputOption)!; var artifactsPath = parseResult.GetValue(artifactsOption)!; var rid = parseResult.GetValue(ridOption)!; - var runtimePath = parseResult.GetValue(runtimeOption); var version = parseResult.GetValue(bundleVersionOption)!; - var runtimeVersion = parseResult.GetValue(runtimeVersionOption)!; - var downloadRuntime = parseResult.GetValue(downloadRuntimeOption); var createArchive = parseResult.GetValue(archiveOption); var verbose = parseResult.GetValue(verboseOption); try { - using var builder = new LayoutBuilder(outputPath, artifactsPath, runtimePath, rid, version, runtimeVersion, downloadRuntime, verbose); + using var builder = new LayoutBuilder(outputPath, artifactsPath, rid, version, verbose); await builder.BuildAsync().ConfigureAwait(false); if (createArchive) @@ -128,29 +106,22 @@ internal sealed class LayoutBuilder : IDisposable { private readonly string _outputPath; private readonly string _artifactsPath; - private readonly string? _runtimePath; private readonly string _rid; private readonly string _version; - private readonly string _runtimeVersion; - private readonly bool _downloadRuntime; private readonly bool _verbose; - private readonly HttpClient _httpClient = new(); - public LayoutBuilder(string outputPath, string artifactsPath, string? runtimePath, string rid, string version, string runtimeVersion, bool downloadRuntime, bool verbose) + public LayoutBuilder(string outputPath, string artifactsPath, string rid, string version, bool verbose) { _outputPath = Path.GetFullPath(outputPath); _artifactsPath = Path.GetFullPath(artifactsPath); - _runtimePath = runtimePath is not null ? Path.GetFullPath(runtimePath) : null; _rid = rid; _version = version; - _runtimeVersion = runtimeVersion; - _downloadRuntime = downloadRuntime; _verbose = verbose; } public void Dispose() { - _httpClient.Dispose(); + // Nothing to dispose } public async Task BuildAsync() @@ -166,381 +137,48 @@ public async Task BuildAsync() } Directory.CreateDirectory(_outputPath); - // Copy components (CLI is not included - the native AOT binary IS the CLI, - // and the bundle payload is embedded as a resource inside it) - await CopyRuntimeAsync().ConfigureAwait(false); - await CopyNuGetHelperAsync().ConfigureAwait(false); - await CopyAppHostServerAsync().ConfigureAwait(false); - await CopyDashboardAsync().ConfigureAwait(false); + // Copy components + CopyManagedAsync(); await CopyDcpAsync().ConfigureAwait(false); - // Enable rollforward for all managed tools - EnableRollForwardForAllTools(); - Log("Layout build complete!"); } - private async Task CopyRuntimeAsync() + private void CopyManagedAsync() { - Log("Copying .NET runtime..."); - - var runtimeDir = Path.Combine(_outputPath, "runtime"); - Directory.CreateDirectory(runtimeDir); + Log("Copying aspire-managed..."); - if (_runtimePath is not null && Directory.Exists(_runtimePath)) - { - CopyRuntimeFromPath(_runtimePath, runtimeDir); - Log($" Copied runtime from {_runtimePath}"); - } - else if (_downloadRuntime) + var managedPublishPath = FindPublishPath("Aspire.Managed"); + if (managedPublishPath is null) { - // Download runtime from Microsoft - await DownloadRuntimeAsync(runtimeDir).ConfigureAwait(false); + throw new InvalidOperationException("Aspire.Managed publish output not found."); } - else - { - // Try to find runtime in artifacts or use shared runtime - var sharedRuntime = FindSharedRuntime(); - if (sharedRuntime is not null) - { - CopyRuntimeFromPath(sharedRuntime, runtimeDir); - Log($" Copied shared runtime from {sharedRuntime}"); - } - else - { - Log(" WARNING: No runtime found. Layout will require runtime to be downloaded separately."); - Log(" Use --download-runtime to download the runtime from Microsoft."); - await File.WriteAllTextAsync( - Path.Combine(runtimeDir, "README.txt"), - "Place .NET runtime files here.\n").ConfigureAwait(false); - } - } - } - /// - /// Copy runtime from a source path, excluding unnecessary frameworks like WindowsDesktop.App. - /// - private void CopyRuntimeFromPath(string sourcePath, string destPath) - { - // Copy everything except the shared/Microsoft.WindowsDesktop.App directory - var sharedDir = Path.Combine(sourcePath, "shared"); - if (Directory.Exists(sharedDir)) - { - var destSharedDir = Path.Combine(destPath, "shared"); - Directory.CreateDirectory(destSharedDir); + var managedDir = Path.Combine(_outputPath, "managed"); + Directory.CreateDirectory(managedDir); - // Only copy NETCore.App and AspNetCore.App - skip WindowsDesktop.App to save space - var frameworksToCopy = new[] { "Microsoft.NETCore.App", "Microsoft.AspNetCore.App" }; - foreach (var framework in frameworksToCopy) - { - var srcFrameworkDir = Path.Combine(sharedDir, framework); - if (Directory.Exists(srcFrameworkDir)) - { - CopyDirectory(srcFrameworkDir, Path.Combine(destSharedDir, framework)); - } - } - } - - // Copy host directory - var hostDir = Path.Combine(sourcePath, "host"); - if (Directory.Exists(hostDir)) - { - CopyDirectory(hostDir, Path.Combine(destPath, "host")); - } - - // Copy dotnet executable and related files + // Copy only the aspire-managed executable and required assets (wwwroot for Dashboard). + // Skip other .exe files — they are native host stubs from referenced Exe projects + // that leak into the publish output but are not needed (everything is in aspire-managed.exe). var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); - var dotnetExe = isWindows ? "dotnet.exe" : "dotnet"; - var dotnetPath = Path.Combine(sourcePath, dotnetExe); - if (File.Exists(dotnetPath)) - { - File.Copy(dotnetPath, Path.Combine(destPath, dotnetExe), overwrite: true); - } - - // Copy LICENSE and ThirdPartyNotices if present - foreach (var file in new[] { "LICENSE.txt", "ThirdPartyNotices.txt" }) - { - var srcFile = Path.Combine(sourcePath, file); - if (File.Exists(srcFile)) - { - File.Copy(srcFile, Path.Combine(destPath, file), overwrite: true); - } - } - } - - private async Task DownloadRuntimeAsync(string runtimeDir) - { - Log($" Downloading .NET SDK {_runtimeVersion} for {_rid}..."); - - var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); - var archiveExt = isWindows ? "zip" : "tar.gz"; - - // Download the full SDK - it contains runtime, aspnetcore, and dev-certs tool - var sdkUrl = $"https://builds.dotnet.microsoft.com/dotnet/Sdk/{_runtimeVersion}/dotnet-sdk-{_runtimeVersion}-{_rid}.{archiveExt}"; - await DownloadAndExtractSdkAsync(sdkUrl, runtimeDir).ConfigureAwait(false); - - Log($" SDK components extracted successfully"); - } - - private async Task DownloadAndExtractSdkAsync(string url, string runtimeDir) - { - Log($" Downloading SDK from {url}..."); - - var tempDir = Path.Combine(Path.GetTempPath(), $"aspire-sdk-{Guid.NewGuid():N}"); - Directory.CreateDirectory(tempDir); - - try - { - var isWindows = _rid.StartsWith("win", StringComparison.OrdinalIgnoreCase); - var archiveExt = isWindows ? "zip" : "tar.gz"; - var archivePath = Path.Combine(tempDir, $"sdk.{archiveExt}"); - - // Download the archive - using (var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false)) - { - response.EnsureSuccessStatusCode(); - using var stream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - using var fileStream = File.Create(archivePath); - await stream.CopyToAsync(fileStream).ConfigureAwait(false); - } - - Log($" Extracting SDK..."); - - // Extract the archive - var extractDir = Path.Combine(tempDir, "extracted"); - Directory.CreateDirectory(extractDir); - - if (isWindows) - { - ZipFile.ExtractToDirectory(archivePath, extractDir); - } - else - { - // Use tar to extract on Unix - var psi = new ProcessStartInfo - { - FileName = "tar", - Arguments = $"-xzf \"{archivePath}\" -C \"{extractDir}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - using var process = Process.Start(psi); - await process!.WaitForExitAsync().ConfigureAwait(false); - if (process.ExitCode != 0) - { - throw new InvalidOperationException($"Failed to extract SDK: tar exited with code {process.ExitCode}"); - } - } - - // Extract runtime components: shared/, host/, dotnet executable - Log($" Extracting runtime components..."); - - // Copy only the shared frameworks we need (exclude WindowsDesktop.App to save space) - var sharedDir = Path.Combine(extractDir, "shared"); - if (Directory.Exists(sharedDir)) - { - var destSharedDir = Path.Combine(runtimeDir, "shared"); - Directory.CreateDirectory(destSharedDir); - - // Only copy NETCore.App and AspNetCore.App - skip WindowsDesktop.App - var frameworksToCopy = new[] { "Microsoft.NETCore.App", "Microsoft.AspNetCore.App" }; - foreach (var framework in frameworksToCopy) - { - var srcFrameworkDir = Path.Combine(sharedDir, framework); - if (Directory.Exists(srcFrameworkDir)) - { - CopyDirectory(srcFrameworkDir, Path.Combine(destSharedDir, framework)); - Log($" Copied {framework}"); - } - } - } - - // Copy host directory - var hostDir = Path.Combine(extractDir, "host"); - if (Directory.Exists(hostDir)) - { - CopyDirectory(hostDir, Path.Combine(runtimeDir, "host")); - } - - // Copy dotnet executable - var dotnetExe = isWindows ? "dotnet.exe" : "dotnet"; - var dotnetPath = Path.Combine(extractDir, dotnetExe); - if (File.Exists(dotnetPath)) - { - var destDotnet = Path.Combine(runtimeDir, dotnetExe); - File.Copy(dotnetPath, destDotnet, overwrite: true); - if (!isWindows) - { - await SetExecutableAsync(destDotnet).ConfigureAwait(false); - } - } - - // Copy LICENSE and ThirdPartyNotices - foreach (var file in new[] { "LICENSE.txt", "ThirdPartyNotices.txt" }) - { - var srcFile = Path.Combine(extractDir, file); - if (File.Exists(srcFile)) - { - File.Copy(srcFile, Path.Combine(runtimeDir, file), overwrite: true); - } - } - - // Extract dev-certs tool from SDK - Log($" Extracting dev-certs tool..."); - await ExtractDevCertsToolAsync(extractDir).ConfigureAwait(false); - - Log($" SDK extraction complete"); - } - finally - { - // Cleanup temp directory - try - { - Directory.Delete(tempDir, recursive: true); - } - catch - { - // Ignore cleanup errors - } - } - } - - private Task ExtractDevCertsToolAsync(string sdkExtractDir) - { - // Find the dev-certs tool in sdk//DotnetTools/dotnet-dev-certs/ - var sdkDir = Path.Combine(sdkExtractDir, "sdk"); - if (!Directory.Exists(sdkDir)) - { - Log($" WARNING: SDK directory not found, skipping dev-certs extraction"); - return Task.CompletedTask; - } - - // Find the SDK version directory (e.g., "10.0.102") - var sdkVersionDirs = Directory.GetDirectories(sdkDir); - if (sdkVersionDirs.Length == 0) - { - Log($" WARNING: No SDK version directory found, skipping dev-certs extraction"); - return Task.CompletedTask; - } - - // Use the first (should be only) SDK version directory - var sdkVersionDir = sdkVersionDirs[0]; - var dotnetToolsDir = Path.Combine(sdkVersionDir, "DotnetTools", "dotnet-dev-certs"); - - if (!Directory.Exists(dotnetToolsDir)) - { - Log($" WARNING: dotnet-dev-certs not found at {dotnetToolsDir}, skipping"); - return Task.CompletedTask; - } + var managedExeName = isWindows ? "aspire-managed.exe" : "aspire-managed"; - // Find the tool version directory (e.g., "10.0.2-servicing.25612.105") - var toolVersionDirs = Directory.GetDirectories(dotnetToolsDir); - if (toolVersionDirs.Length == 0) + var managedExePath = Path.Combine(managedPublishPath, managedExeName); + if (!File.Exists(managedExePath)) { - Log($" WARNING: No dev-certs version directory found, skipping"); - return Task.CompletedTask; + throw new InvalidOperationException($"aspire-managed executable not found at {managedExePath}"); } - // Find the tools/net10.0/any directory containing the actual DLLs - var toolVersionDir = toolVersionDirs[0]; - var toolsDir = Path.Combine(toolVersionDir, "tools"); + File.Copy(managedExePath, Path.Combine(managedDir, managedExeName), overwrite: true); - // Look for net10.0/any or similar pattern - string? devCertsSourceDir = null; - if (Directory.Exists(toolsDir)) + // Copy wwwroot (required for Dashboard static web assets) + var wwwrootPath = Path.Combine(managedPublishPath, "wwwroot"); + if (Directory.Exists(wwwrootPath)) { - foreach (var tfmDir in Directory.GetDirectories(toolsDir)) - { - var anyDir = Path.Combine(tfmDir, "any"); - if (Directory.Exists(anyDir) && File.Exists(Path.Combine(anyDir, "dotnet-dev-certs.dll"))) - { - devCertsSourceDir = anyDir; - break; - } - } + CopyDirectory(wwwrootPath, Path.Combine(managedDir, "wwwroot")); } - if (devCertsSourceDir is null) - { - Log($" WARNING: dev-certs DLLs not found, skipping"); - return Task.CompletedTask; - } - - // Copy to tools/dev-certs/ in the layout - var devCertsDestDir = Path.Combine(_outputPath, "tools", "dev-certs"); - Directory.CreateDirectory(devCertsDestDir); - - // Copy the essential files - foreach (var file in new[] { "dotnet-dev-certs.dll", "dotnet-dev-certs.deps.json", "dotnet-dev-certs.runtimeconfig.json" }) - { - var srcFile = Path.Combine(devCertsSourceDir, file); - if (File.Exists(srcFile)) - { - File.Copy(srcFile, Path.Combine(devCertsDestDir, file), overwrite: true); - } - } - - Log($" dev-certs tool extracted to tools/dev-certs/"); - return Task.CompletedTask; - } - - private Task CopyNuGetHelperAsync() - { - Log("Copying NuGet Helper..."); - - var helperPublishPath = FindPublishPath("Aspire.Cli.NuGetHelper"); - if (helperPublishPath is null) - { - throw new InvalidOperationException("NuGet Helper publish output not found."); - } - - var helperDir = Path.Combine(_outputPath, "tools", "aspire-nuget"); - Directory.CreateDirectory(helperDir); - - CopyDirectory(helperPublishPath, helperDir); - Log($" Copied NuGet Helper to tools/aspire-nuget"); - - return Task.CompletedTask; - } - - private Task CopyAppHostServerAsync() - { - Log("Copying AppHost Server..."); - - var serverPublishPath = FindPublishPath("Aspire.Hosting.RemoteHost"); - if (serverPublishPath is null) - { - throw new InvalidOperationException("AppHost Server (Aspire.Hosting.RemoteHost) publish output not found."); - } - - var serverDir = Path.Combine(_outputPath, "aspire-server"); - Directory.CreateDirectory(serverDir); - - CopyDirectory(serverPublishPath, serverDir); - Log($" Copied AppHost Server to aspire-server"); - - return Task.CompletedTask; - } - - private Task CopyDashboardAsync() - { - Log("Copying Dashboard..."); - - var dashboardPublishPath = FindPublishPath("Aspire.Dashboard"); - if (dashboardPublishPath is null) - { - Log(" WARNING: Dashboard publish output not found. Skipping."); - return Task.CompletedTask; - } - - var dashboardDir = Path.Combine(_outputPath, "dashboard"); - Directory.CreateDirectory(dashboardDir); - - CopyDirectory(dashboardPublishPath, dashboardDir); - Log($" Copied Dashboard to dashboard"); - - return Task.CompletedTask; + Log($" Copied aspire-managed to managed/"); } private Task CopyDcpAsync() @@ -634,25 +272,17 @@ public async Task CreateArchiveAsync() private string? FindPublishPath(string projectName) { // Look for publish output in standard locations - // Order matters - RID-specific single-file publish paths should come first + // Order: RID-specific publish paths first (Release then Debug) var searchPaths = new[] { - // Native AOT output (aspire CLI uses this) - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", _rid, "native"), - // RID-specific single-file publish output (preferred) + // RID-specific self-contained publish output (preferred for Aspire.Managed) Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", _rid, "publish"), - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net8.0", _rid, "publish"), - // Standard publish output - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", "publish"), - // Arcade SDK output - Path.Combine(_artifactsPath, "bin", projectName, "Release", _rid), - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0"), - // net8.0 for Dashboard (it targets net8.0) - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net8.0", "publish"), - Path.Combine(_artifactsPath, "bin", projectName, "Release", "net8.0"), - // Debug fallback - Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", _rid, "native"), Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", _rid, "publish"), + // Native AOT output + Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", _rid, "native"), + Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", _rid, "native"), + // Non-RID publish output + Path.Combine(_artifactsPath, "bin", projectName, "Release", "net10.0", "publish"), Path.Combine(_artifactsPath, "bin", projectName, "Debug", "net10.0", "publish"), }; @@ -667,29 +297,6 @@ public async Task CreateArchiveAsync() return null; } - private static string? FindSharedRuntime() - { - // Look for .NET runtime in common locations - var dotnetRoot = Environment.GetEnvironmentVariable("DOTNET_ROOT"); - if (!string.IsNullOrEmpty(dotnetRoot)) - { - var sharedPath = Path.Combine(dotnetRoot, "shared", "Microsoft.NETCore.App"); - if (Directory.Exists(sharedPath)) - { - // Find the latest version - var versions = Directory.GetDirectories(sharedPath) - .OrderByDescending(d => d) - .FirstOrDefault(); - if (versions is not null) - { - return versions; - } - } - } - - return null; - } - private string? FindDcpPath() { // DCP is in NuGet packages as Microsoft.DeveloperControlPlane.{os}-{arch} @@ -775,57 +382,6 @@ private static void CopyDirectory(string source, string destination) } } - private static async Task SetExecutableAsync(string path) - { - var psi = new ProcessStartInfo - { - FileName = "chmod", - Arguments = $"+x \"{path}\"", - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false - }; - - using var process = Process.Start(psi); - if (process is not null) - { - await process.WaitForExitAsync().ConfigureAwait(false); - } - } - - private void EnableRollForwardForAllTools() - { - Log("Enabling RollForward=Major for all tools..."); - - // Find all runtimeconfig.json files in the bundle - var runtimeConfigFiles = Directory.GetFiles(_outputPath, "*.runtimeconfig.json", SearchOption.AllDirectories); - - foreach (var configFile in runtimeConfigFiles) - { - try - { - var json = File.ReadAllText(configFile); - using var doc = JsonDocument.Parse(json); - - // Check if rollForward is already set - if (doc.RootElement.TryGetProperty("runtimeOptions", out var runtimeOptions) && - !runtimeOptions.TryGetProperty("rollForward", out _)) - { - // Add rollForward: Major to the runtimeOptions - var updatedJson = json.Replace( - "\"runtimeOptions\": {", - "\"runtimeOptions\": {\n \"rollForward\": \"Major\","); - File.WriteAllText(configFile, updatedJson); - Log($" Updated: {Path.GetRelativePath(_outputPath, configFile)}"); - } - } - catch (Exception ex) - { - Log($" WARNING: Failed to update {configFile}: {ex.Message}"); - } - } - } - private void Log(string message) { if (_verbose || !message.StartsWith(" ")) From 4ca1a0af77b73d18d6937b78017ca269b39938e8 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 07:54:04 -0800 Subject: [PATCH 02/14] Migrate dev-certs into aspire-managed Port the CertificateGeneration shared source from aspnetcore into aspire-managed, eliminating the dependency on 'dotnet dev-certs' for bundle mode certificate operations. Changes: - Copy 8 CertificateGeneration files from aspnetcore/src/Shared/ into src/Aspire.Managed/CertificateGeneration/ (vendored shared source) - Add DevCertsCommand with full dotnet dev-certs parity: check-trust, check, trust, clean, import, export - Add 'dev-certs' subcommand dispatch to aspire-managed Program.cs - Recreate BundleCertificateToolRunner to invoke aspire-managed dev-certs instead of dotnet dev-certs - Restore bundle vs SDK branching for ICertificateToolRunner in CLI Program.cs - Suppress SYSLIB0057/IDE1006 warnings for vendored aspnetcore code --- .../BundleCertificateToolRunner.cs | 117 +- src/Aspire.Cli/Program.cs | 8 +- src/Aspire.Managed/Aspire.Managed.csproj | 4 +- .../CertificateExportFormat.cs | 10 + .../CertificateManager.cs | 1445 +++++++++++++++++ .../CertificatePurpose.cs | 10 + .../EnsureCertificateResult.cs | 21 + .../ImportCertificateResult.cs | 15 + .../MacOSCertificateManager.cs | 496 ++++++ .../UnixCertificateManager.cs | 1087 +++++++++++++ .../WindowsCertificateManager.cs | 165 ++ .../DevCerts/DevCertsCommand.cs | 434 +++++ src/Aspire.Managed/Program.cs | 4 +- 13 files changed, 3747 insertions(+), 69 deletions(-) create mode 100644 src/Aspire.Managed/CertificateGeneration/CertificateExportFormat.cs create mode 100644 src/Aspire.Managed/CertificateGeneration/CertificateManager.cs create mode 100644 src/Aspire.Managed/CertificateGeneration/CertificatePurpose.cs create mode 100644 src/Aspire.Managed/CertificateGeneration/EnsureCertificateResult.cs create mode 100644 src/Aspire.Managed/CertificateGeneration/ImportCertificateResult.cs create mode 100644 src/Aspire.Managed/CertificateGeneration/MacOSCertificateManager.cs create mode 100644 src/Aspire.Managed/CertificateGeneration/UnixCertificateManager.cs create mode 100644 src/Aspire.Managed/CertificateGeneration/WindowsCertificateManager.cs create mode 100644 src/Aspire.Managed/DevCerts/DevCertsCommand.cs diff --git a/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs index fbd5e6f0495..65b35e158fc 100644 --- a/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs @@ -1,68 +1,56 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics; using System.Text; using System.Text.Json; using Aspire.Cli.DotNet; +using Aspire.Cli.Layout; using Microsoft.Extensions.Logging; namespace Aspire.Cli.Certificates; /// -/// Certificate tool runner for bundle mode. -/// Falls back to the global dotnet SDK's dev-certs command since the bundle -/// no longer includes a separate muxer/dev-certs DLL. +/// Certificate tool runner for bundle mode that invokes aspire-managed dev-certs +/// instead of the global dotnet SDK's dev-certs command. /// internal sealed class BundleCertificateToolRunner( + ILayoutDiscovery layoutDiscovery, ILogger logger) : ICertificateToolRunner { public async Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { + var managedPath = GetManagedPath(); + var outputBuilder = new StringBuilder(); - var startInfo = new ProcessStartInfo("dotnet") - { - WorkingDirectory = Environment.CurrentDirectory, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; + var arguments = new[] { "dev-certs", "--check-trust-machine-readable" }; - // Use global dotnet dev-certs - startInfo.ArgumentList.Add("dev-certs"); - startInfo.ArgumentList.Add("https"); - startInfo.ArgumentList.Add("--check-trust-machine-readable"); + logger.LogDebug("Running: {ManagedPath} {Args}", managedPath, string.Join(" ", arguments)); - using var process = new Process { StartInfo = startInfo }; + var (exitCode, output, error) = await LayoutProcessRunner.RunAsync( + managedPath, + arguments, + ct: cancellationToken); - process.OutputDataReceived += (sender, e) => + // Forward output/error to callers + if (!string.IsNullOrEmpty(output)) { - if (e.Data is not null) + outputBuilder.Append(output); + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { - outputBuilder.AppendLine(e.Data); - options.StandardOutputCallback?.Invoke(e.Data); + options.StandardOutputCallback?.Invoke(line.TrimEnd('\r')); } - }; + } - process.ErrorDataReceived += (sender, e) => + if (!string.IsNullOrEmpty(error)) { - if (e.Data is not null) + foreach (var line in error.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { - options.StandardErrorCallback?.Invoke(e.Data); + options.StandardErrorCallback?.Invoke(line.TrimEnd('\r')); } - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - await process.WaitForExitAsync(cancellationToken); - - var exitCode = process.ExitCode; + } // Parse the JSON output try @@ -78,7 +66,7 @@ internal sealed class BundleCertificateToolRunner( }); } - var certificates = JsonSerializer.Deserialize(jsonOutput, Aspire.Cli.JsonSourceGenerationContext.Default.ListDevCertInfo); + var certificates = JsonSerializer.Deserialize(jsonOutput, JsonSourceGenerationContext.Default.ListDevCertInfo); if (certificates is null || certificates.Count == 0) { return (exitCode, new CertificateTrustResult @@ -117,44 +105,51 @@ public async Task TrustHttpCertificateAsync( DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { - var startInfo = new ProcessStartInfo("dotnet") - { - WorkingDirectory = Environment.CurrentDirectory, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; + var managedPath = GetManagedPath(); + + var arguments = new[] { "dev-certs", "--trust" }; - // Use global dotnet dev-certs - startInfo.ArgumentList.Add("dev-certs"); - startInfo.ArgumentList.Add("https"); - startInfo.ArgumentList.Add("--trust"); + logger.LogDebug("Running: {ManagedPath} {Args}", managedPath, string.Join(" ", arguments)); - using var process = new Process { StartInfo = startInfo }; + var (exitCode, output, error) = await LayoutProcessRunner.RunAsync( + managedPath, + arguments, + ct: cancellationToken); - process.OutputDataReceived += (sender, e) => + // Forward output/error to callers + if (!string.IsNullOrEmpty(output)) { - if (e.Data is not null) + foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { - options.StandardOutputCallback?.Invoke(e.Data); + options.StandardOutputCallback?.Invoke(line.TrimEnd('\r')); } - }; + } - process.ErrorDataReceived += (sender, e) => + if (!string.IsNullOrEmpty(error)) { - if (e.Data is not null) + foreach (var line in error.Split('\n', StringSplitOptions.RemoveEmptyEntries)) { - options.StandardErrorCallback?.Invoke(e.Data); + options.StandardErrorCallback?.Invoke(line.TrimEnd('\r')); } - }; + } - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); + return exitCode; + } - await process.WaitForExitAsync(cancellationToken); + private string GetManagedPath() + { + var layout = layoutDiscovery.DiscoverLayout(); + if (layout is null) + { + throw new InvalidOperationException("Bundle layout not found. Cannot run dev-certs in bundle mode."); + } + + var managedPath = layout.GetManagedPath(); + if (managedPath is null || !File.Exists(managedPath)) + { + throw new InvalidOperationException("aspire-managed not found in layout."); + } - return process.ExitCode; + return managedPath; } } diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index b8d147ffb70..ed1ef2f2206 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -222,19 +222,17 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTelemetryServices(); builder.Services.AddTransient(); - // Register certificate tool runner implementations - factory chooses based on embedded bundle + // Register certificate tool runner - bundle uses aspire-managed dev-certs, SDK mode uses global dotnet builder.Services.AddSingleton(sp => { var loggerFactory = sp.GetRequiredService(); - var bundleService = sp.GetRequiredService(); - - if (bundleService.IsBundle) + if (sp.GetRequiredService().IsBundle) { return new BundleCertificateToolRunner( + sp.GetRequiredService(), loggerFactory.CreateLogger()); } - // Fall back to SDK-based runner return new SdkCertificateToolRunner(loggerFactory.CreateLogger()); }); diff --git a/src/Aspire.Managed/Aspire.Managed.csproj b/src/Aspire.Managed/Aspire.Managed.csproj index 84c5f45c27a..15a372e06f3 100644 --- a/src/Aspire.Managed/Aspire.Managed.csproj +++ b/src/Aspire.Managed/Aspire.Managed.csproj @@ -12,8 +12,8 @@ false false - - $(NoWarn);CS1591 + + $(NoWarn);CS1591;SYSLIB0057;IDE1006 true diff --git a/src/Aspire.Managed/CertificateGeneration/CertificateExportFormat.cs b/src/Aspire.Managed/CertificateGeneration/CertificateExportFormat.cs new file mode 100644 index 00000000000..70705da5fce --- /dev/null +++ b/src/Aspire.Managed/CertificateGeneration/CertificateExportFormat.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Certificates.Generation; + +internal enum CertificateKeyExportFormat +{ + Pfx, + Pem, +} diff --git a/src/Aspire.Managed/CertificateGeneration/CertificateManager.cs b/src/Aspire.Managed/CertificateGeneration/CertificateManager.cs new file mode 100644 index 00000000000..a51b76e5437 --- /dev/null +++ b/src/Aspire.Managed/CertificateGeneration/CertificateManager.cs @@ -0,0 +1,1445 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.Tracing; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +#nullable enable + +namespace Microsoft.AspNetCore.Certificates.Generation; + +internal abstract class CertificateManager +{ + // This is the version of the ASP.NET Core HTTPS development certificate that will be generated by tooling built with this version of the library. + // Increment this when making any structural changes to the generated certificate (e.g. changing extensions, key usages, SANs, etc.). + // Version 6 was introduced in SDK 10.0.102 and runtime 10.0.2. + internal const int CurrentAspNetCoreCertificateVersion = 6; + // This is the minimum version of the certificate that will be considered valid by runtime components built using this version of the library. + // Increment this only when making breaking changes to the certificate or during major runtime version increments. Must always be less than or equal to CurrentAspNetCoreCertificateVersion. + // This determines the minimum version of the tooling required to generate a certificate that will be considered valid by the runtime. + // Version 4 was introduced in SDK 10.0.100 and runtime 10.0.0. + internal const int CurrentMinimumAspNetCoreCertificateVersion = 4; + + // OID used for HTTPS certs + internal const string AspNetHttpsOid = "1.3.6.1.4.1.311.84.1.1"; + internal const string AspNetHttpsOidFriendlyName = "ASP.NET Core HTTPS development certificate"; + + private const string ServerAuthenticationEnhancedKeyUsageOid = "1.3.6.1.5.5.7.3.1"; + private const string ServerAuthenticationEnhancedKeyUsageOidFriendlyName = "Server Authentication"; + + internal const string SubjectKeyIdentifierOid = "2.5.29.14"; + internal const string AuthorityKeyIdentifierOid = "2.5.29.35"; + + // dns names of the host from a container + private const string LocalhostDockerHttpsDnsName = "host.docker.internal"; + private const string ContainersDockerHttpsDnsName = "host.containers.internal"; + + // wildcard DNS names + private const string LocalhostWildcardHttpsDnsName = "*.dev.localhost"; + private const string InternalWildcardHttpsDnsName = "*.dev.internal"; + + // main cert subject + private const string LocalhostHttpsDnsName = "localhost"; + internal const string LocalhostHttpsDistinguishedName = "CN=" + LocalhostHttpsDnsName; + + public const int RSAMinimumKeySizeInBits = 2048; + + public static CertificateManager Instance { get; } = OperatingSystem.IsWindows() ? +#pragma warning disable CA1416 // Validate platform compatibility + new WindowsCertificateManager() : +#pragma warning restore CA1416 // Validate platform compatibility + OperatingSystem.IsMacOS() ? + new MacOSCertificateManager() as CertificateManager : + new UnixCertificateManager(); + + public static CertificateManagerEventSource Log { get; set; } = new CertificateManagerEventSource(); + + // Setting to 0 means we don't append the version byte, + // which is what all machines currently have. + public int AspNetHttpsCertificateVersion + { + get; + // For testing purposes only + internal set + { + ArgumentOutOfRangeException.ThrowIfLessThan( + value, + MinimumAspNetHttpsCertificateVersion, + $"{nameof(AspNetHttpsCertificateVersion)} cannot be lesser than {nameof(MinimumAspNetHttpsCertificateVersion)}"); + field = value; + } + } + + public int MinimumAspNetHttpsCertificateVersion + { + get; + // For testing purposes only + internal set + { + ArgumentOutOfRangeException.ThrowIfGreaterThan( + value, + AspNetHttpsCertificateVersion, + $"{nameof(MinimumAspNetHttpsCertificateVersion)} cannot be greater than {nameof(AspNetHttpsCertificateVersion)}"); + field = value; + } + } + + public string Subject { get; } + + public CertificateManager() : this(LocalhostHttpsDistinguishedName, CurrentAspNetCoreCertificateVersion, CurrentMinimumAspNetCoreCertificateVersion) + { + } + + // For testing purposes only + internal CertificateManager(string subject, int version) + : this(subject, version, version) + { + } + + // For testing purposes only + internal CertificateManager(string subject, int generatedVersion, int minimumVersion) + { + Subject = subject; + AspNetHttpsCertificateVersion = generatedVersion; + MinimumAspNetHttpsCertificateVersion = minimumVersion; + } + + /// + /// This only checks if the certificate has the OID for ASP.NET Core HTTPS development certificates - + /// it doesn't check the subject, validity, key usages, etc. + /// + public static bool IsHttpsDevelopmentCertificate(X509Certificate2 certificate) + { + foreach (var extension in certificate.Extensions.OfType()) + { + if (string.Equals(AspNetHttpsOid, extension.Oid?.Value, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + + public IList ListCertificates( + StoreName storeName, + StoreLocation location, + bool isValid, + bool requireExportable = true) + { + Log.ListCertificatesStart(location, storeName); + var certificates = new List(); + try + { + using var store = new X509Store(storeName, location); + store.Open(OpenFlags.ReadOnly); + PopulateCertificatesFromStore(store, certificates, requireExportable); + IEnumerable matchingCertificates = certificates; + matchingCertificates = matchingCertificates + .Where(c => HasOid(c, AspNetHttpsOid)); + + if (Log.IsEnabled()) + { + Log.DescribeFoundCertificates(ToCertificateDescription(matchingCertificates)); + } + + if (isValid) + { + // Ensure the certificate hasn't expired, has a private key and its exportable + // (for container/unix scenarios). + Log.CheckCertificatesValidity(); + var now = DateTimeOffset.Now; + var validCertificates = matchingCertificates + .Where(c => IsValidCertificate(c, now, requireExportable)) + .OrderByDescending(GetCertificateVersion) + .ToArray(); + + if (Log.IsEnabled()) + { + var invalidCertificates = matchingCertificates.Except(validCertificates); + Log.DescribeValidCertificates(ToCertificateDescription(validCertificates)); + Log.DescribeInvalidCertificates(ToCertificateDescription(invalidCertificates)); + } + + // Ensure the certificate meets the minimum version requirement. + var validMinVersionCertificates = validCertificates + .Where(c => GetCertificateVersion(c) >= MinimumAspNetHttpsCertificateVersion) + .ToArray(); + + if (Log.IsEnabled()) + { + var belowMinimumVersionCertificates = validCertificates.Except(validMinVersionCertificates); + Log.DescribeMinimumVersionCertificates(ToCertificateDescription(validMinVersionCertificates)); + Log.DescribeBelowMinimumVersionCertificates(ToCertificateDescription(belowMinimumVersionCertificates)); + } + + matchingCertificates = validMinVersionCertificates; + } + + // We need to enumerate the certificates early to prevent disposing issues. + matchingCertificates = matchingCertificates.ToList(); + + var certificatesToDispose = certificates.Except(matchingCertificates); + DisposeCertificates(certificatesToDispose); + + store.Close(); + + Log.ListCertificatesEnd(); + return (IList)matchingCertificates; + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.ListCertificatesError(e.ToString()); + } + DisposeCertificates(certificates); + certificates.Clear(); + return certificates; + } + + bool HasOid(X509Certificate2 certificate, string oid) => + certificate.Extensions.OfType() + .Any(e => string.Equals(oid, e.Oid?.Value, StringComparison.Ordinal)); + } + + /// + /// Validate that the certificate is valid at the given date and time (and exportable if required). + /// + /// The certificate to validate. + /// The current date to validate against. + /// Whether the certificate must be exportable. + /// True if the certificate is valid; otherwise, false. + internal bool IsValidCertificate(X509Certificate2 certificate, DateTimeOffset currentDate, bool requireExportable) + { + return certificate.NotBefore <= currentDate && + currentDate <= certificate.NotAfter && + (!requireExportable || IsExportable(certificate)); + } + + internal static byte GetCertificateVersion(X509Certificate2 c) + { + var byteArray = c.Extensions.OfType() + .Where(e => string.Equals(AspNetHttpsOid, e.Oid?.Value, StringComparison.Ordinal)) + .Single() + .RawData; + + if ((byteArray.Length == AspNetHttpsOidFriendlyName.Length && byteArray[0] == (byte)'A') || byteArray.Length == 0) + { + // No Version set, default to 0 + return 0b0; + } + else + { + // Version is in the only byte of the byte array. + return byteArray[0]; + } + } + + protected virtual void PopulateCertificatesFromStore(X509Store store, List certificates, bool requireExportable) + { + certificates.AddRange(store.Certificates.OfType()); + } + + public IList GetHttpsCertificates() => + ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true); + + /// + /// Ensures that a valid ASP.NET Core HTTPS development certificate is present. + /// + /// The date and time before which the certificate is not valid. + /// The date and time after which the certificate is not valid. + /// Path to export the certificate (directory must exist). + /// Whether to trust the certificate or simply add it to the CurrentUser/My store. + /// Whether to include the private key in the exported certificate. + /// Password for the exported certificate. + /// Format for exporting the certificate key. + /// Whether the operation is interactive (dotnet dev-certs tool) or non-interactive (first run experience). + /// The result of the ensure operation. + /// There was an error ensuring the certificate exists. + /// + /// The minimum certificate version checks behave differently based on whether the operation is interactive or not. In interactive mode, + /// the certificate will only be considered valid if it meets or exceeds the current version of the certificate. In non-interactive mode, + /// the certificate will be considered valid as long as it meets the minimum supported version requirement. This is to allow first run + /// to upgrade a certificate if it becomes necessary to bump the minimum version due to security issues, etc. while not leaving users with + /// a partially valid certificate after a normal first run experience. Interactive scenarios such as the dotnet dev-certs tool should always + /// ensure the certificate is updated to at least the latest supported version. + /// + public EnsureCertificateResult EnsureAspNetCoreHttpsDevelopmentCertificate( + DateTimeOffset notBefore, + DateTimeOffset notAfter, + string? path = null, + bool trust = false, + bool includePrivateKey = false, + string? password = null, + CertificateKeyExportFormat keyExportFormat = CertificateKeyExportFormat.Pfx, + bool isInteractive = true) + { + var result = EnsureCertificateResult.Succeeded; + + var currentUserCertificates = ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, requireExportable: true); + var localMachineCertificates = ListCertificates(StoreName.My, StoreLocation.LocalMachine, isValid: true, requireExportable: true); + var certificates = currentUserCertificates.Concat(localMachineCertificates); + + var filteredCertificates = certificates.Where(c => c.Subject == Subject); + if (isInteractive) + { + // For purposes of updating the dev cert, only consider certificates with the current version or higher as valid + // Only applies to interactive scenarios where we want to ensure we're generating the latest certificate + // For non-interactive scenarios (e.g. first run experience), we want to accept older versions of the certificate as long as they meet the minimum version requirement + // This will allow us to respond to scenarios where we need to invalidate older certificates due to security issues, etc. but not leave users + // with a partially valid certificate after their first run experience. + filteredCertificates = filteredCertificates.Where(c => GetCertificateVersion(c) >= AspNetHttpsCertificateVersion); + } + + if (Log.IsEnabled()) + { + var excludedCertificates = certificates.Except(filteredCertificates); + Log.FilteredCertificates(ToCertificateDescription(filteredCertificates)); + Log.ExcludedCertificates(ToCertificateDescription(excludedCertificates)); + } + + certificates = filteredCertificates; + + X509Certificate2? certificate = null; + var isNewCertificate = false; + if (certificates.Any()) + { + certificate = certificates.First(); + var failedToFixCertificateState = false; + if (isInteractive) + { + // Skip this step if the command is not interactive, + // as we don't want to prompt on first run experience. + foreach (var candidate in currentUserCertificates) + { + var status = CheckCertificateState(candidate); + if (!status.Success) + { + try + { + if (Log.IsEnabled()) + { + Log.CorrectCertificateStateStart(GetDescription(candidate)); + } + CorrectCertificateState(candidate); + Log.CorrectCertificateStateEnd(); + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.CorrectCertificateStateError(e.ToString()); + } + result = EnsureCertificateResult.FailedToMakeKeyAccessible; + // We don't return early on this type of failure to allow for tooling to + // export or trust the certificate even in this situation, as that enables + // exporting the certificate to perform any necessary fix with native tooling. + failedToFixCertificateState = true; + } + } + } + } + + if (!failedToFixCertificateState) + { + if (Log.IsEnabled()) + { + Log.ValidCertificatesFound(ToCertificateDescription(certificates)); + } + certificate = certificates.First(); + if (Log.IsEnabled()) + { + Log.SelectedCertificate(GetDescription(certificate)); + } + result = EnsureCertificateResult.ValidCertificatePresent; + } + } + else + { + Log.NoValidCertificatesFound(); + try + { + Log.CreateDevelopmentCertificateStart(); + isNewCertificate = true; + certificate = CreateAspNetCoreHttpsDevelopmentCertificate(notBefore, notAfter); + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.CreateDevelopmentCertificateError(e.ToString()); + } + result = EnsureCertificateResult.ErrorCreatingTheCertificate; + return result; + } + Log.CreateDevelopmentCertificateEnd(); + + try + { + certificate = SaveCertificate(certificate); + } + catch (Exception e) + { + Log.SaveCertificateInStoreError(e.ToString()); + result = EnsureCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore; + return result; + } + + if (isInteractive) + { + try + { + if (Log.IsEnabled()) + { + Log.CorrectCertificateStateStart(GetDescription(certificate)); + } + CorrectCertificateState(certificate); + Log.CorrectCertificateStateEnd(); + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.CorrectCertificateStateError(e.ToString()); + } + + // We don't return early on this type of failure to allow for tooling to + // export or trust the certificate even in this situation, as that enables + // exporting the certificate to perform any necessary fix with native tooling. + result = EnsureCertificateResult.FailedToMakeKeyAccessible; + } + } + } + + if (path != null) + { + try + { + // If the user specified a non-existent directory, we don't want to be responsible + // for setting the permissions appropriately, so we'll bail. + var exportDir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(exportDir) && !Directory.Exists(exportDir)) + { + result = EnsureCertificateResult.ErrorExportingTheCertificateToNonExistentDirectory; + throw new InvalidOperationException($"The directory '{exportDir}' does not exist. Choose permissions carefully when creating it."); + } + + ExportCertificate(certificate, path, includePrivateKey, password, keyExportFormat); + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.ExportCertificateError(e.ToString()); + } + + // We don't want to mask the original source of the error here. + result = result != EnsureCertificateResult.Succeeded && result != EnsureCertificateResult.ValidCertificatePresent ? + result : + EnsureCertificateResult.ErrorExportingTheCertificate; + + return result; + } + } + + if (trust) + { + try + { + var trustLevel = TrustCertificate(certificate); + switch (trustLevel) + { + case TrustLevel.Full: + // Leave result as-is. + break; + case TrustLevel.Partial: + result = EnsureCertificateResult.PartiallyFailedToTrustTheCertificate; + return result; + case TrustLevel.None: + default: // Treat unknown status (should be impossible) as failure + result = EnsureCertificateResult.FailedToTrustTheCertificate; + return result; + } + } + catch (UserCancelledTrustException) + { + result = EnsureCertificateResult.UserCancelledTrustStep; + return result; + } + catch + { + result = EnsureCertificateResult.FailedToTrustTheCertificate; + return result; + } + + if (result == EnsureCertificateResult.ValidCertificatePresent) + { + result = EnsureCertificateResult.ExistingHttpsCertificateTrusted; + } + else + { + result = EnsureCertificateResult.NewHttpsCertificateTrusted; + } + } + + DisposeCertificates(!isNewCertificate ? certificates : certificates.Append(certificate)); + + return result; + } + + internal ImportCertificateResult ImportCertificate(string certificatePath, string password) + { + if (!File.Exists(certificatePath)) + { + Log.ImportCertificateMissingFile(certificatePath); + return ImportCertificateResult.CertificateFileMissing; + } + + var certificates = ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false); + if (certificates.Any()) + { + if (Log.IsEnabled()) + { + Log.ImportCertificateExistingCertificates(ToCertificateDescription(certificates)); + } + return ImportCertificateResult.ExistingCertificatesPresent; + } + + X509Certificate2 certificate; + try + { + Log.LoadCertificateStart(certificatePath); + certificate = new X509Certificate2(certificatePath, password, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); + if (Log.IsEnabled()) + { + Log.LoadCertificateEnd(GetDescription(certificate)); + } + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.LoadCertificateError(e.ToString()); + } + return ImportCertificateResult.InvalidCertificate; + } + + // Note that we're checking Subject, rather than LocalhostHttpsDistinguishedName, + // because the tests use a different subject. + if (!string.Equals(certificate.Subject, Subject, StringComparison.Ordinal) || // Kestrel requires this + !IsHttpsDevelopmentCertificate(certificate)) + { + if (Log.IsEnabled()) + { + Log.NoHttpsDevelopmentCertificate(GetDescription(certificate)); + } + return ImportCertificateResult.NoDevelopmentHttpsCertificate; + } + + try + { + SaveCertificate(certificate); + } + catch (Exception e) + { + if (Log.IsEnabled()) + { + Log.SaveCertificateInStoreError(e.ToString()); + } + return ImportCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore; + } + + return ImportCertificateResult.Succeeded; + } + + public void CleanupHttpsCertificates() + { + var certificates = ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false); + var filteredCertificates = certificates.Where(c => c.Subject == Subject); + + if (Log.IsEnabled()) + { + var excludedCertificates = certificates.Except(filteredCertificates); + Log.FilteredCertificates(ToCertificateDescription(filteredCertificates)); + Log.ExcludedCertificates(ToCertificateDescription(excludedCertificates)); + } + + foreach (var certificate in filteredCertificates) + { + // RemoveLocations.All will first remove from the trusted roots (e.g. keychain on + // macOS) and then from the local user store. + RemoveCertificate(certificate, RemoveLocations.All); + } + } + + public abstract TrustLevel GetTrustLevel(X509Certificate2 certificate); + + protected abstract X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation); + + /// Implementations may choose to throw, rather than returning . + protected abstract TrustLevel TrustCertificateCore(X509Certificate2 certificate); + + internal abstract bool IsExportable(X509Certificate2 c); + + protected abstract void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate); + + protected abstract IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation); + + protected abstract void CreateDirectoryWithPermissions(string directoryPath); + + /// + /// Will create directories to make it possible to write to . + /// If you don't want that, check for existence before calling this method. + /// + internal void ExportCertificate(X509Certificate2 certificate, string path, bool includePrivateKey, string? password, CertificateKeyExportFormat format) + { + if (Log.IsEnabled()) + { + Log.ExportCertificateStart(GetDescription(certificate), path, includePrivateKey); + } + + if (includePrivateKey && password == null) + { + Log.NoPasswordForCertificate(); + } + + var targetDirectoryPath = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(targetDirectoryPath)) + { + Log.CreateExportCertificateDirectory(targetDirectoryPath); + CreateDirectoryWithPermissions(targetDirectoryPath); + } + + byte[] bytes; + byte[] keyBytes; + byte[]? pemEnvelope = null; + RSA? key = null; + + try + { + if (includePrivateKey) + { + switch (format) + { + case CertificateKeyExportFormat.Pfx: + bytes = certificate.Export(X509ContentType.Pkcs12, password); + break; + case CertificateKeyExportFormat.Pem: + key = certificate.GetRSAPrivateKey()!; + + char[] pem; + if (password != null) + { + keyBytes = key.ExportEncryptedPkcs8PrivateKey(password, new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 100000)); + pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + pemEnvelope = Encoding.ASCII.GetBytes(pem); + } + else + { + // Export the key first to an encrypted PEM to avoid issues with System.Security.Cryptography.Cng indicating that the operation is not supported. + // This is likely by design to avoid exporting the key by mistake. + // To bypass it, we export the certificate to pem temporarily and then we import it and export it as unprotected PEM. + keyBytes = key.ExportEncryptedPkcs8PrivateKey(string.Empty, new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 1)); + pem = PemEncoding.Write("ENCRYPTED PRIVATE KEY", keyBytes); + key.Dispose(); + key = RSA.Create(); + key.ImportFromEncryptedPem(pem, string.Empty); + Array.Clear(keyBytes, 0, keyBytes.Length); + Array.Clear(pem, 0, pem.Length); + keyBytes = key.ExportPkcs8PrivateKey(); + pem = PemEncoding.Write("PRIVATE KEY", keyBytes); + pemEnvelope = Encoding.ASCII.GetBytes(pem); + } + + Array.Clear(keyBytes, 0, keyBytes.Length); + Array.Clear(pem, 0, pem.Length); + + bytes = Encoding.ASCII.GetBytes(PemEncoding.Write("CERTIFICATE", certificate.Export(X509ContentType.Cert))); + break; + default: + throw new InvalidOperationException("Unknown format."); + } + } + else + { + if (format == CertificateKeyExportFormat.Pem) + { + bytes = Encoding.ASCII.GetBytes(PemEncoding.Write("CERTIFICATE", certificate.Export(X509ContentType.Cert))); + } + else + { + bytes = certificate.Export(X509ContentType.Cert); + } + } + } + catch (Exception e) when (Log.IsEnabled()) + { + Log.ExportCertificateError(e.ToString()); + throw; + } + finally + { + key?.Dispose(); + } + + try + { + Log.WriteCertificateToDisk(path); + + // Create a temp file with the correct Unix file mode before moving it to the expected path. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var tempFilename = Path.GetTempFileName(); + File.Move(tempFilename, path, overwrite: true); + } + + File.WriteAllBytes(path, bytes); + } + catch (Exception ex) when (Log.IsEnabled()) + { + Log.WriteCertificateToDiskError(ex.ToString()); + throw; + } + finally + { + Array.Clear(bytes, 0, bytes.Length); + } + + if (includePrivateKey && format == CertificateKeyExportFormat.Pem) + { + Debug.Assert(pemEnvelope != null); + + try + { + var keyPath = Path.ChangeExtension(path, ".key"); + Log.WritePemKeyToDisk(keyPath); + + // Create a temp file with the correct Unix file mode before moving it to the expected path. + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var tempFilename = Path.GetTempFileName(); + File.Move(tempFilename, keyPath, overwrite: true); + } + + File.WriteAllBytes(keyPath, pemEnvelope); + } + catch (Exception ex) when (Log.IsEnabled()) + { + Log.WritePemKeyToDiskError(ex.ToString()); + throw; + } + finally + { + Array.Clear(pemEnvelope, 0, pemEnvelope.Length); + } + } + } + + /// + /// Creates a new ASP.NET Core HTTPS development certificate. + /// + /// The date and time before which the certificate is not valid. + /// The date and time after which the certificate is not valid. + /// The created X509Certificate2 instance. + /// + /// When making changes to the certificate generated by this method, ensure that the constant is updated accordingly. + /// + internal X509Certificate2 CreateAspNetCoreHttpsDevelopmentCertificate(DateTimeOffset notBefore, DateTimeOffset notAfter) + { + var subject = new X500DistinguishedName(Subject); + var extensions = new List(); + var sanBuilder = new SubjectAlternativeNameBuilder(); + sanBuilder.AddDnsName(LocalhostHttpsDnsName); + sanBuilder.AddDnsName(LocalhostWildcardHttpsDnsName); + sanBuilder.AddDnsName(InternalWildcardHttpsDnsName); + sanBuilder.AddDnsName(LocalhostDockerHttpsDnsName); + sanBuilder.AddDnsName(ContainersDockerHttpsDnsName); + sanBuilder.AddIpAddress(IPAddress.Loopback); + sanBuilder.AddIpAddress(IPAddress.IPv6Loopback); + + var keyUsage = new X509KeyUsageExtension(X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DigitalSignature, critical: true); + var enhancedKeyUsage = new X509EnhancedKeyUsageExtension( + new OidCollection() { + new Oid( + ServerAuthenticationEnhancedKeyUsageOid, + ServerAuthenticationEnhancedKeyUsageOidFriendlyName) + }, + critical: true); + + var basicConstraints = new X509BasicConstraintsExtension( + certificateAuthority: false, + hasPathLengthConstraint: false, + pathLengthConstraint: 0, + critical: true); + + byte[] bytePayload; + + if (AspNetHttpsCertificateVersion != 0) + { + bytePayload = new byte[1]; + bytePayload[0] = (byte)AspNetHttpsCertificateVersion; + } + else + { + bytePayload = Encoding.ASCII.GetBytes(AspNetHttpsOidFriendlyName); + } + + var aspNetHttpsExtension = new X509Extension( + new AsnEncodedData( + new Oid(AspNetHttpsOid, AspNetHttpsOidFriendlyName), + bytePayload), + critical: false); + + extensions.Add(basicConstraints); + extensions.Add(keyUsage); + extensions.Add(enhancedKeyUsage); + extensions.Add(sanBuilder.Build(critical: true)); + extensions.Add(aspNetHttpsExtension); + + var certificate = CreateSelfSignedCertificate(subject, extensions, notBefore, notAfter); + return certificate; + } + + internal X509Certificate2 SaveCertificate(X509Certificate2 certificate) + { + var name = StoreName.My; + var location = StoreLocation.CurrentUser; + + if (Log.IsEnabled()) + { + Log.SaveCertificateInStoreStart(GetDescription(certificate), name, location); + } + + certificate = SaveCertificateCore(certificate, name, location); + + Log.SaveCertificateInStoreEnd(); + return certificate; + } + + internal TrustLevel TrustCertificate(X509Certificate2 certificate) + { + try + { + if (Log.IsEnabled()) + { + Log.TrustCertificateStart(GetDescription(certificate)); + } + var trustLevel = TrustCertificateCore(certificate); + Log.TrustCertificateEnd(); + return trustLevel; + } + catch (Exception ex) when (Log.IsEnabled()) + { + Log.TrustCertificateError(ex.ToString()); + throw; + } + } + + // Internal, for testing purposes only. + internal void RemoveAllCertificates(StoreName storeName, StoreLocation storeLocation) + { + var certificates = GetCertificatesToRemove(storeName, storeLocation); + var certificatesWithName = certificates.Where(c => c.Subject == Subject); + + var removeLocation = storeName == StoreName.My ? RemoveLocations.Local : RemoveLocations.Trusted; + + foreach (var certificate in certificates) + { + RemoveCertificate(certificate, removeLocation); + } + + DisposeCertificates(certificates); + } + + internal void RemoveCertificate(X509Certificate2 certificate, RemoveLocations locations) + { + switch (locations) + { + case RemoveLocations.Undefined: + throw new InvalidOperationException($"'{nameof(RemoveLocations.Undefined)}' is not a valid location."); + case RemoveLocations.Local: + RemoveCertificateFromUserStore(certificate); + break; + case RemoveLocations.Trusted: + RemoveCertificateFromTrustedRoots(certificate); + break; + case RemoveLocations.All: + RemoveCertificateFromTrustedRoots(certificate); + RemoveCertificateFromUserStore(certificate); + break; + default: + throw new InvalidOperationException("Invalid location."); + } + } + + internal abstract CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate); + + internal abstract void CorrectCertificateState(X509Certificate2 candidate); + + /// + /// Creates a self-signed certificate with the specified subject, extensions, and validity period. + /// + /// The subject distinguished name for the certificate. + /// The collection of X509 extensions to include in the certificate. + /// The date and time before which the certificate is not valid. + /// The date and time after which the certificate is not valid. + /// The created X509Certificate2 instance. + /// If a key with the specified minimum size cannot be created. + /// + /// If making changes to the certificate generated by this method, ensure that the constant is updated accordingly. + /// + internal static X509Certificate2 CreateSelfSignedCertificate( + X500DistinguishedName subject, + IEnumerable extensions, + DateTimeOffset notBefore, + DateTimeOffset notAfter) + { + using var key = CreateKeyMaterial(RSAMinimumKeySizeInBits); + + var request = new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + foreach (var extension in extensions) + { + request.CertificateExtensions.Add(extension); + } + + // Only add the SKI and AKI extensions if neither is already present. + // OpenSSL needs these to correctly identify the trust chain for a private key. If multiple certificates don't have a subject key identifier and share the same subject, + // the wrong certificate can be chosen for the trust chain, leading to validation errors. + if (!request.CertificateExtensions.Any(ext => ext.Oid?.Value is SubjectKeyIdentifierOid or AuthorityKeyIdentifierOid)) + { + // RFC 5280 section 4.2.1.2 + var subjectKeyIdentifier = new X509SubjectKeyIdentifierExtension(request.PublicKey, X509SubjectKeyIdentifierHashAlgorithm.Sha256, critical: false); + // RFC 5280 section 4.2.1.1 + var authorityKeyIdentifier = X509AuthorityKeyIdentifierExtension.CreateFromSubjectKeyIdentifier(subjectKeyIdentifier); + + request.CertificateExtensions.Add(subjectKeyIdentifier); + request.CertificateExtensions.Add(authorityKeyIdentifier); + } + + var result = request.CreateSelfSigned(notBefore, notAfter); + return result; + + static RSA CreateKeyMaterial(int minimumKeySize) + { + var rsa = RSA.Create(minimumKeySize); + if (rsa.KeySize < minimumKeySize) + { + throw new InvalidOperationException($"Failed to create a key with a size of {minimumKeySize} bits"); + } + + return rsa; + } + } + + internal static void DisposeCertificates(IEnumerable disposables) + { + foreach (var disposable in disposables) + { + try + { + disposable.Dispose(); + } + catch + { + } + } + } + + protected void RemoveCertificateFromUserStore(X509Certificate2 certificate) + { + try + { + if (Log.IsEnabled()) + { + Log.RemoveCertificateFromUserStoreStart(GetDescription(certificate)); + } + RemoveCertificateFromUserStoreCore(certificate); + Log.RemoveCertificateFromUserStoreEnd(); + } + catch (Exception ex) when (Log.IsEnabled()) + { + Log.RemoveCertificateFromUserStoreError(ex.ToString()); + throw; + } + } + + protected virtual void RemoveCertificateFromUserStoreCore(X509Certificate2 certificate) + { + using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + var matching = store.Certificates + .OfType() + .Single(c => c.SerialNumber == certificate.SerialNumber); + + store.Remove(matching); + } + + internal static string ToCertificateDescription(IEnumerable certificates) + { + var list = certificates.ToList(); + var certificatesDescription = list.Count switch + { + 0 => "no certificates", + 1 => "1 certificate", + _ => $"{list.Count} certificates", + }; + var description = list.OrderBy(c => c.Thumbprint).Select((c, i) => $" {i + 1}) " + GetDescription(c)).Prepend(certificatesDescription); + return string.Join(Environment.NewLine, description); + } + + internal static string GetDescription(X509Certificate2 c) => + $"{c.Thumbprint} - {c.Subject} - Valid from {c.NotBefore:u} to {c.NotAfter:u} - IsHttpsDevelopmentCertificate: {IsHttpsDevelopmentCertificate(c).ToString().ToLowerInvariant()} - IsExportable: {Instance.IsExportable(c).ToString().ToLowerInvariant()}"; + + /// + /// is not adequate for security purposes. + /// + internal static bool AreCertificatesEqual(X509Certificate2 cert1, X509Certificate2 cert2) + { + return cert1.RawDataMemory.Span.SequenceEqual(cert2.RawDataMemory.Span); + } + + /// + /// Given a certificate, usually from the store, try to find the + /// corresponding certificate in (usually the store)."/> + /// + /// An open . + /// A certificate to search for. + /// The certificate, if any, corresponding to in . + /// True if a corresponding certificate was found. + /// has richer filtering and a lot of debugging output that's unhelpful here. + internal static bool TryFindCertificateInStore(X509Store store, X509Certificate2 certificate, [NotNullWhen(true)] out X509Certificate2? foundCertificate) + { + foundCertificate = null; + + // We specifically don't search by thumbprint to avoid being flagged for using a SHA-1 hash. + var certificatesWithSubjectName = store.Certificates.Find(X509FindType.FindBySerialNumber, certificate.SerialNumber, validOnly: false); + if (certificatesWithSubjectName.Count == 0) + { + return false; + } + + var certificatesToDispose = new List(); + foreach (var candidate in certificatesWithSubjectName.OfType()) + { + if (foundCertificate is null && AreCertificatesEqual(candidate, certificate)) + { + foundCertificate = candidate; + } + else + { + certificatesToDispose.Add(candidate); + } + } + DisposeCertificates(certificatesToDispose); + return foundCertificate is not null; + } + + [EventSource(Name = "Dotnet-dev-certs")] + public sealed class CertificateManagerEventSource : EventSource + { + [Event(1, Level = EventLevel.Verbose, Message = "Listing certificates from {0}\\{1}")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Parameters passed to WriteEvent are all primative values.")] + public void ListCertificatesStart(StoreLocation location, StoreName storeName) => WriteEvent(1, location, storeName); + + [Event(2, Level = EventLevel.Verbose, Message = "Found certificates: {0}")] + public void DescribeFoundCertificates(string matchingCertificates) => WriteEvent(2, matchingCertificates); + + [Event(3, Level = EventLevel.Verbose, Message = "Checking certificates validity")] + public void CheckCertificatesValidity() => WriteEvent(3); + + [Event(4, Level = EventLevel.Verbose, Message = "Valid certificates: {0}")] + public void DescribeValidCertificates(string validCertificates) => WriteEvent(4, validCertificates); + + [Event(5, Level = EventLevel.Verbose, Message = "Invalid certificates: {0}")] + public void DescribeInvalidCertificates(string invalidCertificates) => WriteEvent(5, invalidCertificates); + + [Event(6, Level = EventLevel.Verbose, Message = "Finished listing certificates.")] + public void ListCertificatesEnd() => WriteEvent(6); + + [Event(7, Level = EventLevel.Error, Message = "An error occurred while listing the certificates: {0}")] + public void ListCertificatesError(string e) => WriteEvent(7, e); + + [Event(8, Level = EventLevel.Verbose, Message = "Filtered certificates: {0}")] + public void FilteredCertificates(string filteredCertificates) => WriteEvent(8, filteredCertificates); + + [Event(9, Level = EventLevel.Verbose, Message = "Excluded certificates: {0}")] + public void ExcludedCertificates(string excludedCertificates) => WriteEvent(9, excludedCertificates); + + [Event(14, Level = EventLevel.Verbose, Message = "Valid certificates: {0}")] + public void ValidCertificatesFound(string certificates) => WriteEvent(14, certificates); + + [Event(15, Level = EventLevel.Verbose, Message = "Selected certificate: {0}")] + public void SelectedCertificate(string certificate) => WriteEvent(15, certificate); + + [Event(16, Level = EventLevel.Verbose, Message = "No valid certificates found.")] + public void NoValidCertificatesFound() => WriteEvent(16); + + [Event(17, Level = EventLevel.Verbose, Message = "Generating HTTPS development certificate.")] + public void CreateDevelopmentCertificateStart() => WriteEvent(17); + + [Event(18, Level = EventLevel.Verbose, Message = "Finished generating HTTPS development certificate.")] + public void CreateDevelopmentCertificateEnd() => WriteEvent(18); + + [Event(19, Level = EventLevel.Error, Message = "An error has occurred generating the certificate: {0}.")] + public void CreateDevelopmentCertificateError(string e) => WriteEvent(19, e); + + [Event(20, Level = EventLevel.Verbose, Message = "Saving certificate '{0}' to store {2}\\{1}.")] + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Parameters passed to WriteEvent are all primitive values.")] + public void SaveCertificateInStoreStart(string certificate, StoreName name, StoreLocation location) => WriteEvent(20, certificate, name, location); + + [Event(21, Level = EventLevel.Verbose, Message = "Finished saving certificate to the store.")] + public void SaveCertificateInStoreEnd() => WriteEvent(21); + + [Event(22, Level = EventLevel.Error, Message = "An error has occurred saving the certificate: {0}.")] + public void SaveCertificateInStoreError(string e) => WriteEvent(22, e); + + [Event(23, Level = EventLevel.Verbose, Message = "Saving certificate '{0}' to {1} {2} private key.")] + public void ExportCertificateStart(string certificate, string path, bool includePrivateKey) => WriteEvent(23, certificate, path, includePrivateKey ? "with" : "without"); + + [Event(24, Level = EventLevel.Verbose, Message = "Exporting certificate with private key but no password.")] + public void NoPasswordForCertificate() => WriteEvent(24); + + [Event(25, Level = EventLevel.Verbose, Message = "Creating directory {0}.")] + public void CreateExportCertificateDirectory(string path) => WriteEvent(25, path); + + [Event(26, Level = EventLevel.Error, Message = "An error has occurred while exporting the certificate: {0}.")] + public void ExportCertificateError(string error) => WriteEvent(26, error); + + [Event(27, Level = EventLevel.Verbose, Message = "Writing the certificate to: {0}.")] + public void WriteCertificateToDisk(string path) => WriteEvent(27, path); + + [Event(28, Level = EventLevel.Error, Message = "An error has occurred while writing the certificate to disk: {0}.")] + public void WriteCertificateToDiskError(string error) => WriteEvent(28, error); + + [Event(29, Level = EventLevel.Verbose, Message = "Trusting the certificate to: {0}.")] + public void TrustCertificateStart(string certificate) => WriteEvent(29, certificate); + + [Event(30, Level = EventLevel.Verbose, Message = "Finished trusting the certificate.")] + public void TrustCertificateEnd() => WriteEvent(30); + + [Event(31, Level = EventLevel.Error, Message = "An error has occurred while trusting the certificate: {0}.")] + public void TrustCertificateError(string error) => WriteEvent(31, error); + + [Event(32, Level = EventLevel.Verbose, Message = "Running the trust command {0}.")] + public void MacOSTrustCommandStart(string command) => WriteEvent(32, command); + + [Event(33, Level = EventLevel.Verbose, Message = "Finished running the trust command.")] + public void MacOSTrustCommandEnd() => WriteEvent(33); + + [Event(34, Level = EventLevel.Warning, Message = "An error has occurred while running the trust command: {0}.")] + public void MacOSTrustCommandError(int exitCode) => WriteEvent(34, exitCode); + + [Event(35, Level = EventLevel.Verbose, Message = "Running the remove trust command for {0}.")] + public void MacOSRemoveCertificateTrustRuleStart(string certificate) => WriteEvent(35, certificate); + + [Event(36, Level = EventLevel.Verbose, Message = "Finished running the remove trust command.")] + public void MacOSRemoveCertificateTrustRuleEnd() => WriteEvent(36); + + [Event(37, Level = EventLevel.Warning, Message = "An error has occurred while running the remove trust command: {0}.")] + public void MacOSRemoveCertificateTrustRuleError(int exitCode) => WriteEvent(37, exitCode); + + [Event(38, Level = EventLevel.Verbose, Message = "The certificate is not trusted: {0}.")] + public void MacOSCertificateUntrusted(string certificate) => WriteEvent(38, certificate); + + [Event(39, Level = EventLevel.Verbose, Message = "Removing the certificate from the keychain {0} {1}.")] + public void MacOSRemoveCertificateFromKeyChainStart(string keyChain, string certificate) => WriteEvent(39, keyChain, certificate); + + [Event(40, Level = EventLevel.Verbose, Message = "Finished removing the certificate from the keychain.")] + public void MacOSRemoveCertificateFromKeyChainEnd() => WriteEvent(40); + + [Event(41, Level = EventLevel.Warning, Message = "An error has occurred while running the remove trust command: {0}.")] + public void MacOSRemoveCertificateFromKeyChainError(int exitCode) => WriteEvent(41, exitCode); + + [Event(42, Level = EventLevel.Verbose, Message = "Removing the certificate from the user store {0}.")] + public void RemoveCertificateFromUserStoreStart(string certificate) => WriteEvent(42, certificate); + + [Event(43, Level = EventLevel.Verbose, Message = "Finished removing the certificate from the user store.")] + public void RemoveCertificateFromUserStoreEnd() => WriteEvent(43); + + [Event(44, Level = EventLevel.Error, Message = "An error has occurred while removing the certificate from the user store: {0}.")] + public void RemoveCertificateFromUserStoreError(string error) => WriteEvent(44, error); + + [Event(45, Level = EventLevel.Verbose, Message = "Adding certificate to the trusted root certification authority store.")] + public void WindowsAddCertificateToRootStore() => WriteEvent(45); + + [Event(46, Level = EventLevel.Verbose, Message = "The certificate is already trusted.")] + public void WindowsCertificateAlreadyTrusted() => WriteEvent(46); + + [Event(47, Level = EventLevel.Verbose, Message = "Trusting the certificate was cancelled by the user.")] + public void WindowsCertificateTrustCanceled() => WriteEvent(47); + + [Event(48, Level = EventLevel.Verbose, Message = "Removing the certificate from the trusted root certification authority store.")] + public void WindowsRemoveCertificateFromRootStoreStart() => WriteEvent(48); + + [Event(49, Level = EventLevel.Verbose, Message = "Finished removing the certificate from the trusted root certification authority store.")] + public void WindowsRemoveCertificateFromRootStoreEnd() => WriteEvent(49); + + [Event(50, Level = EventLevel.Verbose, Message = "The certificate was not trusted.")] + public void WindowsRemoveCertificateFromRootStoreNotFound() => WriteEvent(50); + + [Event(51, Level = EventLevel.Verbose, Message = "Correcting the the certificate state for '{0}'.")] + public void CorrectCertificateStateStart(string certificate) => WriteEvent(51, certificate); + + [Event(52, Level = EventLevel.Verbose, Message = "Finished correcting the certificate state.")] + public void CorrectCertificateStateEnd() => WriteEvent(52); + + [Event(53, Level = EventLevel.Error, Message = "An error has occurred while correcting the certificate state: {0}.")] + public void CorrectCertificateStateError(string error) => WriteEvent(53, error); + + [Event(54, Level = EventLevel.Verbose, Message = "Importing the certificate {1} to the keychain '{0}'.")] + internal void MacOSAddCertificateToKeyChainStart(string keychain, string certificate) => WriteEvent(54, keychain, certificate); + + [Event(55, Level = EventLevel.Verbose, Message = "Finished importing the certificate to the keychain.")] + internal void MacOSAddCertificateToKeyChainEnd() => WriteEvent(55); + + [Event(56, Level = EventLevel.Error, Message = "An error has occurred while importing the certificate to the keychain: {0}, {1}")] + internal void MacOSAddCertificateToKeyChainError(int exitCode, string output) => WriteEvent(56, exitCode, output); + + [Event(57, Level = EventLevel.Verbose, Message = "Writing the certificate to: {0}.")] + public void WritePemKeyToDisk(string path) => WriteEvent(57, path); + + [Event(58, Level = EventLevel.Error, Message = "An error has occurred while writing the certificate to disk: {0}.")] + public void WritePemKeyToDiskError(string error) => WriteEvent(58, error); + + [Event(59, Level = EventLevel.Error, Message = "The file '{0}' does not exist.")] + internal void ImportCertificateMissingFile(string certificatePath) => WriteEvent(59, certificatePath); + + [Event(60, Level = EventLevel.Error, Message = "One or more HTTPS certificates exist '{0}'.")] + internal void ImportCertificateExistingCertificates(string certificateDescription) => WriteEvent(60, certificateDescription); + + [Event(61, Level = EventLevel.Verbose, Message = "Loading certificate from path '{0}'.")] + internal void LoadCertificateStart(string certificatePath) => WriteEvent(61, certificatePath); + + [Event(62, Level = EventLevel.Verbose, Message = "The certificate '{0}' has been loaded successfully.")] + internal void LoadCertificateEnd(string description) => WriteEvent(62, description); + + [Event(63, Level = EventLevel.Error, Message = "An error has occurred while loading the certificate from disk: {0}.")] + internal void LoadCertificateError(string error) => WriteEvent(63, error); + + [Event(64, Level = EventLevel.Error, Message = "The provided certificate '{0}' is not a valid ASP.NET Core HTTPS development certificate.")] + internal void NoHttpsDevelopmentCertificate(string description) => WriteEvent(64, description); + + [Event(65, Level = EventLevel.Verbose, Message = "The certificate is already trusted.")] + public void MacOSCertificateAlreadyTrusted() => WriteEvent(65); + + [Event(66, Level = EventLevel.Verbose, Message = "Saving the certificate {1} to the user profile folder '{0}'.")] + internal void MacOSAddCertificateToUserProfileDirStart(string directory, string certificate) => WriteEvent(66, directory, certificate); + + [Event(67, Level = EventLevel.Verbose, Message = "Finished saving the certificate to the user profile folder.")] + internal void MacOSAddCertificateToUserProfileDirEnd() => WriteEvent(67); + + [Event(68, Level = EventLevel.Error, Message = "An error has occurred while saving certificate '{0}' in the user profile folder: {1}.")] + internal void MacOSAddCertificateToUserProfileDirError(string certificateThumbprint, string errorMessage) => WriteEvent(68, certificateThumbprint, errorMessage); + + [Event(69, Level = EventLevel.Error, Message = "An error has occurred while removing certificate '{0}' from the user profile folder: {1}.")] + internal void MacOSRemoveCertificateFromUserProfileDirError(string certificateThumbprint, string errorMessage) => WriteEvent(69, certificateThumbprint, errorMessage); + + [Event(70, Level = EventLevel.Error, Message = "The file '{0}' is not a valid certificate.")] + internal void MacOSFileIsNotAValidCertificate(string path) => WriteEvent(70, path); + + [Event(71, Level = EventLevel.Warning, Message = "The on-disk store directory was not found.")] + internal void MacOSDiskStoreDoesNotExist() => WriteEvent(71); + + [Event(72, Level = EventLevel.Verbose, Message = "Reading OpenSSL trusted certificates location from {0}.")] + internal void UnixOpenSslCertificateDirectoryOverridePresent(string nssDbOverrideVariableName) => WriteEvent(72, nssDbOverrideVariableName); + + [Event(73, Level = EventLevel.Verbose, Message = "Reading NSS database locations from {0}.")] + internal void UnixNssDbOverridePresent(string environmentVariable) => WriteEvent(73, environmentVariable); + + // Recoverable - just don't use it. + [Event(74, Level = EventLevel.Warning, Message = "The NSS database '{0}' provided via {1} does not exist.")] + internal void UnixNssDbDoesNotExist(string nssDb, string environmentVariable) => WriteEvent(74, nssDb, environmentVariable); + + [Event(75, Level = EventLevel.Warning, Message = "The certificate is not trusted by .NET. This will likely affect System.Net.Http.HttpClient.")] + internal void UnixNotTrustedByDotnet() => WriteEvent(75); + + [Event(76, Level = EventLevel.Warning, Message = "The certificate is not trusted by OpenSSL. Ensure that the {0} environment variable is set correctly.")] + internal void UnixNotTrustedByOpenSsl(string envVarName) => WriteEvent(76, envVarName); + + [Event(77, Level = EventLevel.Warning, Message = "The certificate is not trusted in the NSS database in '{0}'. This will likely affect the {1} family of browsers.")] + internal void UnixNotTrustedByNss(string path, string browser) => WriteEvent(77, path, browser); + + // If there's no home directory, there are no NSS DBs to check (barring an override), but this isn't strictly a problem. + [Event(78, Level = EventLevel.Verbose, Message = "Home directory '{0}' does not exist. Unable to discover NSS databases for user '{1}'. This will likely affect browsers.")] + internal void UnixHomeDirectoryDoesNotExist(string homeDirectory, string username) => WriteEvent(78, homeDirectory, username); + + // Checking the system-wide OpenSSL directory is only used to make output more helpful - don't warn if it fails. + [Event(79, Level = EventLevel.Verbose, Message = "OpenSSL reported its directory in an unexpected format.")] + internal void UnixOpenSslVersionParsingFailed() => WriteEvent(79); + + // Checking the system-wide OpenSSL directory is only used to make output more helpful - don't warn if it fails. + [Event(80, Level = EventLevel.Verbose, Message = "Unable to determine the OpenSSL directory.")] + internal void UnixOpenSslVersionFailed() => WriteEvent(80); + + // Checking the system-wide OpenSSL directory is only used to make output more helpful - don't warn if it fails. + [Event(81, Level = EventLevel.Verbose, Message = "Unable to determine the OpenSSL directory: {0}.")] + internal void UnixOpenSslVersionException(string exceptionMessage) => WriteEvent(81, exceptionMessage); + + // We'll continue on to NSS DB, but leaving the OpenSSL hash files in a bad state is a real problem. + [Event(82, Level = EventLevel.Error, Message = "Unable to compute the hash of certificate {0}. OpenSSL trust is likely in an inconsistent state.")] + internal void UnixOpenSslHashFailed(string certificatePath) => WriteEvent(82, certificatePath); + + // We'll continue on to NSS DB, but leaving the OpenSSL hash files in a bad state is a real problem. + [Event(83, Level = EventLevel.Error, Message = "Unable to compute the certificate hash: {0}. OpenSSL trust is likely in an inconsistent state.")] + internal void UnixOpenSslHashException(string certificatePath, string exceptionMessage) => WriteEvent(83, certificatePath, exceptionMessage); + + // We'll continue on to NSS DB, but leaving the OpenSSL hash files in a bad state is a real problem. + [Event(84, Level = EventLevel.Error, Message = "Unable to update certificate '{0}' in the OpenSSL trusted certificate hash collection - {2} certificates have the hash {1}.")] + internal void UnixOpenSslRehashTooManyHashes(string fullName, string hash, int maxHashCollisions) => WriteEvent(84, fullName, hash, maxHashCollisions); + + // We'll continue on to NSS DB, but leaving the OpenSSL hash files in a bad state is a real problem. + [Event(85, Level = EventLevel.Error, Message = "Unable to update the OpenSSL trusted certificate hash collection: {0}. " + + "Manually rehashing may help. See https://aka.ms/dev-certs-trust for more information.")] // This should recommend manually running c_rehash. + internal void UnixOpenSslRehashException(string exceptionMessage) => WriteEvent(85, exceptionMessage); + + [Event(86, Level = EventLevel.Warning, Message = "Failed to trust the certificate in .NET: {0}.")] + internal void UnixDotnetTrustException(string exceptionMessage) => WriteEvent(86, exceptionMessage); + + [Event(87, Level = EventLevel.Verbose, Message = "Trusted the certificate in .NET.")] + internal void UnixDotnetTrustSucceeded() => WriteEvent(87); + + [Event(88, Level = EventLevel.Warning, Message = "Clients that validate certificate trust using OpenSSL will not trust the certificate.")] + internal void UnixOpenSslTrustFailed() => WriteEvent(88); + + [Event(89, Level = EventLevel.Verbose, Message = "Trusted the certificate in OpenSSL.")] + internal void UnixOpenSslTrustSucceeded() => WriteEvent(89); + + [Event(90, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers.")] + internal void UnixNssDbTrustFailed(string path, string browser) => WriteEvent(90, path, browser); + + [Event(91, Level = EventLevel.Verbose, Message = "Trusted the certificate in the NSS database in '{0}'.")] + internal void UnixNssDbTrustSucceeded(string path) => WriteEvent(91, path); + + [Event(92, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in .NET: {0}.")] + internal void UnixDotnetUntrustException(string exceptionMessage) => WriteEvent(92, exceptionMessage); + + [Event(93, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in OpenSSL.")] + internal void UnixOpenSslUntrustFailed() => WriteEvent(93); + + [Event(94, Level = EventLevel.Verbose, Message = "Untrusted the certificate in OpenSSL.")] + internal void UnixOpenSslUntrustSucceeded() => WriteEvent(94); + + [Event(95, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}'.")] + internal void UnixNssDbUntrustFailed(string path) => WriteEvent(95, path); + + [Event(96, Level = EventLevel.Verbose, Message = "Removed the certificate from the NSS database in '{0}'.")] + internal void UnixNssDbUntrustSucceeded(string path) => WriteEvent(96, path); + + [Event(97, Level = EventLevel.Warning, Message = "The certificate is only partially trusted - some clients will not accept it.")] + internal void UnixTrustPartiallySucceeded() => WriteEvent(97); + + [Event(98, Level = EventLevel.Warning, Message = "Failed to look up the certificate in the NSS database in '{0}': {1}.")] + internal void UnixNssDbCheckException(string path, string exceptionMessage) => WriteEvent(98, path, exceptionMessage); + + [Event(99, Level = EventLevel.Warning, Message = "Failed to add the certificate to the NSS database in '{0}': {1}.")] + internal void UnixNssDbAdditionException(string path, string exceptionMessage) => WriteEvent(99, path, exceptionMessage); + + [Event(100, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}': {1}.")] + internal void UnixNssDbRemovalException(string path, string exceptionMessage) => WriteEvent(100, path, exceptionMessage); + + [Event(101, Level = EventLevel.Warning, Message = "Failed to find the Firefox profiles in directory '{0}': {1}.")] + internal void UnixFirefoxProfileEnumerationException(string firefoxDirectory, string message) => WriteEvent(101, firefoxDirectory, message); + + [Event(102, Level = EventLevel.Verbose, Message = "No Firefox profiles found in directory '{0}'.")] + internal void UnixNoFirefoxProfilesFound(string firefoxDirectory) => WriteEvent(102, firefoxDirectory); + + [Event(103, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers. " + + "This likely indicates that the database already contains an entry for the certificate under a different name. Please remove it and try again.")] + internal void UnixNssDbTrustFailedWithProbableConflict(string path, string browser) => WriteEvent(103, path, browser); + + // This may be annoying, since anyone setting the variable for un/trust will likely leave it set for --check. + // However, it seems important to warn users who set it specifically for --check. + [Event(104, Level = EventLevel.Warning, Message = "The {0} environment variable is set but will not be consumed while checking trust.")] + internal void UnixOpenSslCertificateDirectoryOverrideIgnored(string openSslCertDirectoryOverrideVariableName) => WriteEvent(104, openSslCertDirectoryOverrideVariableName); + + [Event(105, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for updating certificate trust in OpenSSL.")] + internal void UnixMissingOpenSslCommand(string openSslCommand) => WriteEvent(105, openSslCommand); + + [Event(106, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for querying and updating NSS databases, which are chiefly used to trust certificates in browsers.")] + internal void UnixMissingCertUtilCommand(string certUtilCommand) => WriteEvent(106, certUtilCommand); + + [Event(107, Level = EventLevel.Verbose, Message = "Untrusting the certificate in OpenSSL was skipped since '{0}' does not exist.")] + internal void UnixOpenSslUntrustSkipped(string certPath) => WriteEvent(107, certPath); + + [Event(108, Level = EventLevel.Warning, Message = "Failed to delete certificate file '{0}': {1}.")] + internal void UnixCertificateFileDeletionException(string certPath, string exceptionMessage) => WriteEvent(108, certPath, exceptionMessage); + + [Event(109, Level = EventLevel.Error, Message = "Unable to export the certificate since '{0}' already exists. Please remove it.")] + internal void UnixNotOverwritingCertificate(string certPath) => WriteEvent(109, certPath); + + [Event(110, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + + "For example, `export {2}=\"{0}:{1}\"`. " + + "See https://aka.ms/dev-certs-trust for more information.")] + internal void UnixSuggestSettingEnvironmentVariable(string certDir, string openSslDir, string envVarName) => WriteEvent(110, certDir, openSslDir, envVarName); + + [Event(111, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + + "See https://aka.ms/dev-certs-trust for more information.")] + internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => WriteEvent(111, certDir, envVarName); + + [Event(112, Level = EventLevel.Warning, Message = "Directory '{0}' may be readable by other users.")] + internal void DirectoryPermissionsNotSecure(string directoryPath) => WriteEvent(112, directoryPath); + + [Event(113, Level = EventLevel.Verbose, Message = "The certificate directory '{0}' is already included in the {1} environment variable.")] + internal void UnixOpenSslCertificateDirectoryAlreadyConfigured(string certDir, string envVarName) => WriteEvent(113, certDir, envVarName); + + [Event(114, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {1} environment variable. " + + "For example, `export {1}=\"{0}:${1}\"`. " + + "See https://aka.ms/dev-certs-trust for more information.")] + internal void UnixSuggestAppendingToEnvironmentVariable(string certDir, string envVarName) => WriteEvent(114, certDir, envVarName); + + [Event(115, Level = EventLevel.Verbose, Message = "Successfully trusted the certificate in the Windows certificate store via WSL.")] + internal void WslWindowsTrustSucceeded() => WriteEvent(115); + + [Event(116, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the Windows certificate store via WSL.")] + internal void WslWindowsTrustFailed() => WriteEvent(116); + + [Event(117, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the Windows certificate store via WSL: {0}.")] + internal void WslWindowsTrustException(string exceptionMessage) => WriteEvent(117, exceptionMessage); + + [Event(118, Level = EventLevel.Verbose, Message = "Meets minimum version certificates: {0}")] + public void DescribeMinimumVersionCertificates(string meetsMinimumVersionCertificates) => WriteEvent(118, meetsMinimumVersionCertificates); + + [Event(119, Level = EventLevel.Verbose, Message = "Below minimum version certificates: {0}")] + public void DescribeBelowMinimumVersionCertificates(string belowMinimumVersionCertificates) => WriteEvent(119, belowMinimumVersionCertificates); + } + + internal sealed class UserCancelledTrustException : Exception + { + } + + internal readonly struct CheckCertificateStateResult + { + public bool Success { get; } + public string? FailureMessage { get; } + + public CheckCertificateStateResult(bool success, string? failureMessage) + { + Success = success; + FailureMessage = failureMessage; + } + } + + internal enum RemoveLocations + { + Undefined, + Local, + Trusted, + All + } + + internal enum TrustLevel + { + /// No trust has been granted. + None, + /// Trust has been granted in some, but not all, clients. + Partial, + /// Trust has been granted in all clients. + Full, + } +} diff --git a/src/Aspire.Managed/CertificateGeneration/CertificatePurpose.cs b/src/Aspire.Managed/CertificateGeneration/CertificatePurpose.cs new file mode 100644 index 00000000000..7abe411dbd8 --- /dev/null +++ b/src/Aspire.Managed/CertificateGeneration/CertificatePurpose.cs @@ -0,0 +1,10 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Certificates.Generation; + +internal enum CertificatePurpose +{ + All, + HTTPS +} diff --git a/src/Aspire.Managed/CertificateGeneration/EnsureCertificateResult.cs b/src/Aspire.Managed/CertificateGeneration/EnsureCertificateResult.cs new file mode 100644 index 00000000000..5c28eaca306 --- /dev/null +++ b/src/Aspire.Managed/CertificateGeneration/EnsureCertificateResult.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Certificates.Generation; + +internal enum EnsureCertificateResult +{ + Succeeded = 1, + ValidCertificatePresent, + ErrorCreatingTheCertificate, + ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore, + ErrorExportingTheCertificate, + ErrorExportingTheCertificateToNonExistentDirectory, + FailedToTrustTheCertificate, + PartiallyFailedToTrustTheCertificate, + UserCancelledTrustStep, + FailedToMakeKeyAccessible, + ExistingHttpsCertificateTrusted, + NewHttpsCertificateTrusted +} + diff --git a/src/Aspire.Managed/CertificateGeneration/ImportCertificateResult.cs b/src/Aspire.Managed/CertificateGeneration/ImportCertificateResult.cs new file mode 100644 index 00000000000..53706d8ce88 --- /dev/null +++ b/src/Aspire.Managed/CertificateGeneration/ImportCertificateResult.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Certificates.Generation; + +internal enum ImportCertificateResult +{ + Succeeded = 1, + CertificateFileMissing, + InvalidCertificate, + NoDevelopmentHttpsCertificate, + ExistingCertificatesPresent, + ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore, +} + diff --git a/src/Aspire.Managed/CertificateGeneration/MacOSCertificateManager.cs b/src/Aspire.Managed/CertificateGeneration/MacOSCertificateManager.cs new file mode 100644 index 00000000000..e1a8620358e --- /dev/null +++ b/src/Aspire.Managed/CertificateGeneration/MacOSCertificateManager.cs @@ -0,0 +1,496 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; + +namespace Microsoft.AspNetCore.Certificates.Generation; + +/// +/// Normally, we avoid the use of because it's a SHA-1 hash and, therefore, +/// not adequate for security applications. However, the MacOS security tool uses SHA-1 hashes for certificate +/// identification, so we're stuck. +/// +internal sealed class MacOSCertificateManager : CertificateManager +{ + private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + // User keychain. Guard with quotes when using in command lines since users may have set + // their user profile (HOME) directory to a non-standard path that includes whitespace. + private static readonly string MacOSUserKeychain = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/Library/Keychains/login.keychain-db"; + + // System keychain. We no longer store certificates or create trust rules in the system + // keychain, but check for their presence here so that we can clean up state left behind + // by pre-.NET 7 versions of this tool. + private const string MacOSSystemKeychain = "/Library/Keychains/System.keychain"; + + // Well-known location on disk where dev-certs are stored. + private static readonly string MacOSUserHttpsCertificateLocation = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspnet", "dev-certs", "https"); + + // Verify the certificate {0} for the SSL and X.509 Basic Policy. + private const string MacOSVerifyCertificateCommandLine = "security"; + private const string MacOSVerifyCertificateCommandLineArgumentsFormat = "verify-cert -c \"{0}\" -p basic -p ssl"; + + // Delete a certificate with the specified SHA-256 (or SHA-1) hash {0} from keychain {1}. + private const string MacOSDeleteCertificateCommandLine = "sudo"; + private const string MacOSDeleteCertificateCommandLineArgumentsFormat = "security delete-certificate -Z {0} \"{1}\""; + + // Add a certificate to the per-user trust settings in the user keychain. The trust policy + // for the certificate will be set to be always trusted for SSL and X.509 Basic Policy. + // Note: This operation will require user authentication. + private const string MacOSTrustCertificateCommandLine = "security"; + private static readonly string MacOSTrustCertificateCommandLineArguments = $"add-trusted-cert -p basic -p ssl -k \"{MacOSUserKeychain}\" "; + + // Import a pkcs12 certificate into the user keychain using the unwrapping passphrase {1}, and + // allow any application to access the imported key without warning. + private const string MacOSAddCertificateToKeyChainCommandLine = "security"; + private static readonly string MacOSAddCertificateToKeyChainCommandLineArgumentsFormat = "import \"{0}\" -k \"" + MacOSUserKeychain + "\" -t cert -f pkcs12 -P {1} -A"; + + // Remove a certificate from the admin trust settings. We no longer add certificates to the + // admin trust settings, but need this for cleaning up certs generated by pre-.NET 7 versions + // of this tool that used to create trust settings in the system keychain. + // Note: This operation will require user authentication. + private const string MacOSUntrustLegacyCertificateCommandLine = "sudo"; + private const string MacOSUntrustLegacyCertificateCommandLineArguments = "security remove-trusted-cert -d \"{0}\""; + + // Find all matching certificates on the keychain {1} that have the name {0} and print + // print their SHA-256 and SHA-1 hashes. + private const string MacOSFindCertificateOnKeychainCommandLine = "security"; + private const string MacOSFindCertificateOnKeychainCommandLineArgumentsFormat = "find-certificate -c {0} -a -Z -p \"{1}\""; + + // Format used by the tool when printing SHA-1 hashes. + private const string MacOSFindCertificateOutputRegex = "SHA-1 hash: ([0-9A-Z]+)"; + + public const string InvalidCertificateState = + "The ASP.NET Core developer certificate is in an invalid state. " + + "To fix this issue, run 'dotnet dev-certs https --clean' and 'dotnet dev-certs https' " + + "to remove all existing ASP.NET Core development certificates " + + "and create a new untrusted developer certificate. " + + "Use 'dotnet dev-certs https --trust' to trust the new certificate."; + + public MacOSCertificateManager() + { + } + + internal MacOSCertificateManager(string subject, int version) + : base(subject, version) + { + } + + protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertificate) + { + var oldTrustLevel = GetTrustLevel(publicCertificate); + if (oldTrustLevel != TrustLevel.None) + { + Debug.Assert(oldTrustLevel == TrustLevel.Full); // Mac trust is all or nothing + Log.MacOSCertificateAlreadyTrusted(); + return oldTrustLevel; + } + + var tmpFile = Path.GetTempFileName(); + try + { + // We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key + ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pfx); + if (Log.IsEnabled()) + { + Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {MacOSTrustCertificateCommandLineArguments}{tmpFile}"); + } + using (var process = Process.Start(MacOSTrustCertificateCommandLine, MacOSTrustCertificateCommandLineArguments + tmpFile)) + { + process.WaitForExit(); + if (process.ExitCode != 0) + { + Log.MacOSTrustCommandError(process.ExitCode); + throw new InvalidOperationException("There was an error trusting the certificate."); + } + } + Log.MacOSTrustCommandEnd(); + return TrustLevel.Full; + } + finally + { + try + { + File.Delete(tmpFile); + } + catch + { + // We don't care if we can't delete the temp file. + } + } + } + + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate) + { + return File.Exists(GetCertificateFilePath(candidate)) ? + new CheckCertificateStateResult(true, null) : + new CheckCertificateStateResult(false, InvalidCertificateState); + } + + internal override void CorrectCertificateState(X509Certificate2 candidate) + { + try + { + // This path is in a well-known folder, so we trust the permissions. + var certificatePath = GetCertificateFilePath(candidate); + ExportCertificate(candidate, certificatePath, includePrivateKey: true, null, CertificateKeyExportFormat.Pfx); + } + catch (Exception ex) + { + Log.MacOSAddCertificateToUserProfileDirError(candidate.Thumbprint, ex.Message); + } + } + + // Use verify-cert to verify the certificate for the SSL and X.509 Basic Policy. + public override TrustLevel GetTrustLevel(X509Certificate2 certificate) + { + var tmpFile = Path.GetTempFileName(); + try + { + // We can't guarantee that the temp file is in a directory with sensible permissions, but we're not exporting the private key + ExportCertificate(certificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem); + + using var checkTrustProcess = Process.Start(new ProcessStartInfo( + MacOSVerifyCertificateCommandLine, + string.Format(CultureInfo.InvariantCulture, MacOSVerifyCertificateCommandLineArgumentsFormat, tmpFile)) + { + RedirectStandardOutput = true, + // Do this to avoid showing output to the console when the cert is not trusted. It is trivial to export + // the cert and replicate the command to see details. + RedirectStandardError = true, + }); + checkTrustProcess!.WaitForExit(); + return checkTrustProcess.ExitCode == 0 ? TrustLevel.Full : TrustLevel.None; + } + finally + { + File.Delete(tmpFile); + } + } + + protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) + { + if (IsCertOnKeychain(MacOSSystemKeychain, certificate)) + { + // Pre-.NET 7 versions of this tool used to store certs and trust settings on the + // system keychain. Check if that's the case for this cert, and if so, remove the + // trust rule and the cert from the system keychain. + try + { + RemoveAdminTrustRule(certificate); + RemoveCertificateFromKeychain(MacOSSystemKeychain, certificate); + } + catch + { + } + } + + RemoveCertificateFromUserStoreCore(certificate); + } + + // Remove the certificate from the admin trust settings. + private static void RemoveAdminTrustRule(X509Certificate2 certificate) + { + Log.MacOSRemoveCertificateTrustRuleStart(GetDescription(certificate)); + var certificatePath = Path.GetTempFileName(); + try + { + var certBytes = certificate.Export(X509ContentType.Cert); + File.WriteAllBytes(certificatePath, certBytes); + var processInfo = new ProcessStartInfo( + MacOSUntrustLegacyCertificateCommandLine, + string.Format( + CultureInfo.InvariantCulture, + MacOSUntrustLegacyCertificateCommandLineArguments, + certificatePath + )); + + using var process = Process.Start(processInfo); + process!.WaitForExit(); + + if (process.ExitCode != 0) + { + Log.MacOSRemoveCertificateTrustRuleError(process.ExitCode); + } + + Log.MacOSRemoveCertificateTrustRuleEnd(); + } + finally + { + try + { + File.Delete(certificatePath); + } + catch + { + // We don't care if we can't delete the temp file. + } + } + } + + private static void RemoveCertificateFromKeychain(string keychain, X509Certificate2 certificate) + { + var processInfo = new ProcessStartInfo( + MacOSDeleteCertificateCommandLine, + string.Format( + CultureInfo.InvariantCulture, + MacOSDeleteCertificateCommandLineArgumentsFormat, + certificate.Thumbprint.ToUpperInvariant(), + keychain + )) + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + if (Log.IsEnabled()) + { + Log.MacOSRemoveCertificateFromKeyChainStart(keychain, GetDescription(certificate)); + } + + using (var process = Process.Start(processInfo)) + { + var output = process!.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + Log.MacOSRemoveCertificateFromKeyChainError(process.ExitCode); + throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'. + +{output}"); + } + } + + Log.MacOSRemoveCertificateFromKeyChainEnd(); + } + + private static bool IsCertOnKeychain(string keychain, X509Certificate2 certificate) + { + TimeSpan MaxRegexTimeout = TimeSpan.FromMinutes(1); + const string CertificateSubjectRegex = "CN=(.*[^,]+).*"; + + var subjectMatch = Regex.Match(certificate.Subject, CertificateSubjectRegex, RegexOptions.Singleline, MaxRegexTimeout); + if (!subjectMatch.Success) + { + throw new InvalidOperationException($"Can't determine the subject for the certificate with subject '{certificate.Subject}'."); + } + + var subject = subjectMatch.Groups[1].Value; + + // Run the find-certificate command, and look for the cert's hash in the output + using var findCertificateProcess = Process.Start(new ProcessStartInfo( + MacOSFindCertificateOnKeychainCommandLine, + string.Format(CultureInfo.InvariantCulture, MacOSFindCertificateOnKeychainCommandLineArgumentsFormat, subject, keychain)) + { + RedirectStandardOutput = true + }); + + var output = findCertificateProcess!.StandardOutput.ReadToEnd(); + findCertificateProcess.WaitForExit(); + + var matches = Regex.Matches(output, MacOSFindCertificateOutputRegex, RegexOptions.Multiline, MaxRegexTimeout); + var hashes = matches.OfType().Select(m => m.Groups[1].Value).ToList(); + + return hashes.Any(h => string.Equals(h, certificate.Thumbprint, StringComparison.Ordinal)); + } + + // We don't have a good way of checking on the underlying implementation if it is exportable, so just return true. + internal override bool IsExportable(X509Certificate2 c) => true; + + protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation) + { + SaveCertificateToUserKeychain(certificate); + + try + { + var certBytes = certificate.Export(X509ContentType.Pfx); + + if (Log.IsEnabled()) + { + Log.MacOSAddCertificateToUserProfileDirStart(MacOSUserKeychain, GetDescription(certificate)); + } + + // Ensure that the directory exists before writing to the file. + CreateDirectoryWithPermissions(MacOSUserHttpsCertificateLocation); + + File.WriteAllBytes(GetCertificateFilePath(certificate), certBytes); + } + catch (Exception ex) + { + Log.MacOSAddCertificateToUserProfileDirError(certificate.Thumbprint, ex.Message); + } + + Log.MacOSAddCertificateToKeyChainEnd(); + Log.MacOSAddCertificateToUserProfileDirEnd(); + + return certificate; + } + + private static void SaveCertificateToUserKeychain(X509Certificate2 certificate) + { + var passwordBytes = new byte[48]; + RandomNumberGenerator.Fill(passwordBytes.AsSpan()[0..35]); + var password = Convert.ToBase64String(passwordBytes, 0, 36); + var certBytes = certificate.Export(X509ContentType.Pfx, password); + var certificatePath = Path.GetTempFileName(); + File.WriteAllBytes(certificatePath, certBytes); + + var processInfo = new ProcessStartInfo( + MacOSAddCertificateToKeyChainCommandLine, + string.Format(CultureInfo.InvariantCulture, MacOSAddCertificateToKeyChainCommandLineArgumentsFormat, certificatePath, password)) + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + if (Log.IsEnabled()) + { + Log.MacOSAddCertificateToKeyChainStart(MacOSUserKeychain, GetDescription(certificate)); + } + + using (var process = Process.Start(processInfo)) + { + var output = process!.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(); + + if (process.ExitCode != 0) + { + Log.MacOSAddCertificateToKeyChainError(process.ExitCode, output); + throw new InvalidOperationException("Failed to add the certificate to the keychain. Are you running in a non-interactive session perhaps?"); + } + } + + Log.MacOSAddCertificateToKeyChainEnd(); + } + + private static string GetCertificateFilePath(X509Certificate2 certificate) => + Path.Combine(MacOSUserHttpsCertificateLocation, $"aspnetcore-localhost-{certificate.Thumbprint}.pfx"); + + protected override IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) + { + return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false); + } + + protected override void PopulateCertificatesFromStore(X509Store store, List certificates, bool requireExportable) + { + if (store.Name! == StoreName.My.ToString() && store.Location == StoreLocation.CurrentUser && Directory.Exists(MacOSUserHttpsCertificateLocation)) + { + var certsFromDisk = GetCertsFromDisk(); + + var certsFromStore = new List(); + base.PopulateCertificatesFromStore(store, certsFromStore, requireExportable); + + // Certs created by pre-.NET 7. + var onlyOnKeychain = certsFromStore.Except(certsFromDisk, ThumbprintComparer.Instance); + + // Certs created (or "upgraded") by .NET 7+. + // .NET 7+ installs the certificate on disk as well as on the user keychain (for backwards + // compatibility with pre-.NET 7). + // Note that if we require exportable certs, the actual certs we populate need to be the ones + // from the store location, and not the version from disk. If we don't require exportability, + // we favor the version of the cert that's on disk (avoiding unnecessary keychain access + // prompts). Intersect compares with the specified comparer and returns the matching elements + // from the first set. + var onDiskAndKeychain = requireExportable ? certsFromStore.Intersect(certsFromDisk, ThumbprintComparer.Instance) + : certsFromDisk.Intersect(certsFromStore, ThumbprintComparer.Instance); + + // The only times we can find a certificate on the keychain and a certificate on keychain+disk + // are when the certificate on disk and keychain has expired and a pre-.NET 7 SDK has been + // used to create a new certificate, or when a pre-.NET 7 certificate has expired and .NET 7+ + // has been used to create a new certificate. In both cases, the caller filters the invalid + // certificates out, so only the valid certificate is selected. + certificates.AddRange(onlyOnKeychain); + certificates.AddRange(onDiskAndKeychain); + } + else + { + base.PopulateCertificatesFromStore(store, certificates, requireExportable); + } + } + + private sealed class ThumbprintComparer : IEqualityComparer + { + public static readonly IEqualityComparer Instance = new ThumbprintComparer(); + +#pragma warning disable CS8769 // Nullability of reference types in type of parameter doesn't match implemented member (possibly because of nullability attributes). + bool IEqualityComparer.Equals(X509Certificate2 x, X509Certificate2 y) => + EqualityComparer.Default.Equals(x?.Thumbprint, y?.Thumbprint); +#pragma warning restore CS8769 // Nullability of reference types in type of parameter doesn't match implemented member (possibly because of nullability attributes). + + int IEqualityComparer.GetHashCode([DisallowNull] X509Certificate2 obj) => + EqualityComparer.Default.GetHashCode(obj.Thumbprint); + } + + private static ICollection GetCertsFromDisk() + { + var certsFromDisk = new List(); + if (!Directory.Exists(MacOSUserHttpsCertificateLocation)) + { + Log.MacOSDiskStoreDoesNotExist(); + } + else + { + var certificateFiles = Directory.EnumerateFiles(MacOSUserHttpsCertificateLocation, "aspnetcore-localhost-*.pfx"); + foreach (var file in certificateFiles) + { + try + { + var certificate = new X509Certificate2(file); + certsFromDisk.Add(certificate); + } + catch (Exception) + { + Log.MacOSFileIsNotAValidCertificate(file); + throw; + } + } + } + + return certsFromDisk; + } + + protected override void RemoveCertificateFromUserStoreCore(X509Certificate2 certificate) + { + try + { + var certificatePath = GetCertificateFilePath(certificate); + if (File.Exists(certificatePath)) + { + File.Delete(certificatePath); + } + } + catch (Exception ex) + { + Log.MacOSRemoveCertificateFromUserProfileDirError(certificate.Thumbprint, ex.Message); + } + + if (IsCertOnKeychain(MacOSUserKeychain, certificate)) + { + RemoveCertificateFromKeychain(MacOSUserKeychain, certificate); + } + } + + protected override void CreateDirectoryWithPermissions(string directoryPath) + { +#pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows) + var dirInfo = new DirectoryInfo(directoryPath); + if (dirInfo.Exists) + { + if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0) + { + Log.DirectoryPermissionsNotSecure(dirInfo.FullName); + } + } + else + { + Directory.CreateDirectory(directoryPath, DirectoryPermissions); + } +#pragma warning restore CA1416 // Validate platform compatibility + } +} diff --git a/src/Aspire.Managed/CertificateGeneration/UnixCertificateManager.cs b/src/Aspire.Managed/CertificateGeneration/UnixCertificateManager.cs new file mode 100644 index 00000000000..efe74c25a44 --- /dev/null +++ b/src/Aspire.Managed/CertificateGeneration/UnixCertificateManager.cs @@ -0,0 +1,1087 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography.X509Certificates; +using System.Text.RegularExpressions; + +namespace Microsoft.AspNetCore.Certificates.Generation; + +/// +/// On Unix, we trust the certificate in the following locations: +/// 1. dotnet (i.e. the CurrentUser/Root store) +/// 2. OpenSSL (i.e. adding it to a directory in $SSL_CERT_DIR) +/// 3. Firefox & Chromium (i.e. adding it to an NSS DB for each browser) +/// All of these locations are per-user. +/// +internal sealed partial class UnixCertificateManager : CertificateManager +{ + private const UnixFileMode DirectoryPermissions = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; + + /// The name of an environment variable consumed by OpenSSL to locate certificates. + private const string OpenSslCertificateDirectoryVariableName = "SSL_CERT_DIR"; + + private const string OpenSslCertDirectoryOverrideVariableName = "DOTNET_DEV_CERTS_OPENSSL_CERTIFICATE_DIRECTORY"; + private const string NssDbOverrideVariableName = "DOTNET_DEV_CERTS_NSSDB_PATHS"; + // CONSIDER: we could have a distinct variable for Mozilla NSS DBs, but detecting them from the path seems sufficient for now. + + private const string BrowserFamilyChromium = "Chromium"; + private const string BrowserFamilyFirefox = "Firefox"; + + private const string PowerShellCommand = "powershell.exe"; + private const string WslInteropPath = "/proc/sys/fs/binfmt_misc/WSLInterop"; + private const string WslInteropLatePath = "/proc/sys/fs/binfmt_misc/WSLInterop-late"; + private const string WslFriendlyName = AspNetHttpsOidFriendlyName + " (WSL)"; + + private const string OpenSslCommand = "openssl"; + private const string CertUtilCommand = "certutil"; + + private const int MaxHashCollisions = 10; // Something is going badly wrong if we have this many dev certs with the same hash + + private HashSet? _availableCommands; + + public UnixCertificateManager() + { + } + + internal UnixCertificateManager(string subject, int version) + : base(subject, version) + { + } + + public override TrustLevel GetTrustLevel(X509Certificate2 certificate) + { + var sawTrustSuccess = false; + var sawTrustFailure = false; + + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(OpenSslCertDirectoryOverrideVariableName))) + { + // Warn but don't bail. + Log.UnixOpenSslCertificateDirectoryOverrideIgnored(OpenSslCertDirectoryOverrideVariableName); + } + + // Building the chain will check whether dotnet trusts the cert. We could, instead, + // enumerate the Root store and/or look for the file in the OpenSSL directory, but + // this tests the real-world behavior. + var chain = new X509Chain(); + try + { + // This is just a heuristic for whether or not we should prompt the user to re-run with `--trust` + // so we don't need to check revocation (which doesn't really make sense for dev certs anyway) + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + if (chain.Build(certificate)) + { + sawTrustSuccess = true; + } + else + { + sawTrustFailure = true; + Log.UnixNotTrustedByDotnet(); + } + } + finally + { + // Disposing the chain does not dispose the elements we potentially built. + // Do the full walk manually to dispose. + for (var i = 0; i < chain.ChainElements.Count; i++) + { + chain.ChainElements[i].Certificate.Dispose(); + } + + chain.Dispose(); + } + + // Will become the name of the file on disk and the nickname in the NSS DBs + var certificateNickname = GetCertificateNickname(certificate); + + var sslCertDirString = Environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName); + if (string.IsNullOrEmpty(sslCertDirString)) + { + sawTrustFailure = true; + Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName); + } + else + { + var foundCert = false; + var sslCertDirs = sslCertDirString.Split(Path.PathSeparator); + foreach (var sslCertDir in sslCertDirs) + { + var certPath = Path.Combine(sslCertDir, certificateNickname + ".pem"); + if (File.Exists(certPath)) + { + using var candidate = X509CertificateLoader.LoadCertificateFromFile(certPath); + if (AreCertificatesEqual(certificate, candidate)) + { + foundCert = true; + break; + } + } + } + + if (foundCert) + { + sawTrustSuccess = true; + } + else + { + sawTrustFailure = true; + Log.UnixNotTrustedByOpenSsl(OpenSslCertificateDirectoryVariableName); + } + } + + var nssDbs = GetNssDbs(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)); + if (nssDbs.Count > 0) + { + if (!IsCommandAvailable(CertUtilCommand)) + { + // If there are browsers but we don't have certutil, we can't check trust and, + // in all probability, we can't have previously established it. + Log.UnixMissingCertUtilCommand(CertUtilCommand); + sawTrustFailure = true; + } + else + { + foreach (var nssDb in nssDbs) + { + if (IsCertificateInNssDb(certificateNickname, nssDb)) + { + sawTrustSuccess = true; + } + else + { + sawTrustFailure = true; + Log.UnixNotTrustedByNss(nssDb.Path, nssDb.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium); + } + } + } + } + + // Success & Failure => Partial; Success => Full; Failure => None + return sawTrustSuccess + ? sawTrustFailure + ? TrustLevel.Partial + : TrustLevel.Full + : TrustLevel.None; + } + + protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation) + { + var export = certificate.Export(X509ContentType.Pkcs12, ""); + certificate.Dispose(); + certificate = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + Array.Clear(export, 0, export.Length); + + using (var store = new X509Store(storeName, storeLocation)) + { + store.Open(OpenFlags.ReadWrite); + store.Add(certificate); + store.Close(); + }; + + return certificate; + } + + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate) + { + // Return true as we don't perform any check. + // This is about checking storage, not trust. + return new CheckCertificateStateResult(true, null); + } + + internal override void CorrectCertificateState(X509Certificate2 candidate) + { + // Do nothing since we don't have anything to check here. + // This is about correcting storage, not trust. + } + + internal override bool IsExportable(X509Certificate2 c) => true; + + protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) + { + var sawTrustFailure = false; + var sawTrustSuccess = false; + + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + if (TryFindCertificateInStore(store, certificate, out _)) + { + sawTrustSuccess = true; + } + else + { + try + { + using var publicCertificate = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert)); + // FriendlyName is Windows-only, so we don't set it here. + store.Add(publicCertificate); + Log.UnixDotnetTrustSucceeded(); + sawTrustSuccess = true; + } + catch (Exception ex) + { + sawTrustFailure = true; + Log.UnixDotnetTrustException(ex.Message); + } + } + + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + + // Rather than create a temporary file we'll have to clean up, we prefer to export the dev cert + // to its final location in the OpenSSL directory. As a result, any failure up until that point + // is fatal (i.e. we can't trust the cert in other locations). + + var certDir = GetOpenSslCertificateDirectory(homeDirectory)!; // May not exist + + var nickname = GetCertificateNickname(certificate); + var certPath = Path.Combine(certDir, nickname) + ".pem"; + + var needToExport = true; + + // We do our own check for file collisions since ExportCertificate silently overwrites. + if (File.Exists(certPath)) + { + try + { + using var existingCert = X509CertificateLoader.LoadCertificateFromFile(certPath); + if (!AreCertificatesEqual(existingCert, certificate)) + { + Log.UnixNotOverwritingCertificate(certPath); + return TrustLevel.None; + } + + needToExport = false; // If the bits are on disk, we don't need to re-export + } + catch + { + // If we couldn't load the file, then we also can't safely overwite it. + Log.UnixNotOverwritingCertificate(certPath); + return TrustLevel.None; + } + } + + if (needToExport) + { + // Security: we don't need the private key for trust, so we don't export it. + // Note that this will create directories as needed. We control `certPath`, so the permissions should be fine. + ExportCertificate(certificate, certPath, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pem); + } + + // Once the certificate is on disk, we prefer not to throw - some subsequent trust step might succeed. + + var openSslTrustSucceeded = false; + + var isOpenSslAvailable = IsCommandAvailable(OpenSslCommand); + if (isOpenSslAvailable) + { + if (TryRehashOpenSslCertificates(certDir)) + { + openSslTrustSucceeded = true; + } + } + else + { + Log.UnixMissingOpenSslCommand(OpenSslCommand); + } + + if (openSslTrustSucceeded) + { + Log.UnixOpenSslTrustSucceeded(); + sawTrustSuccess = true; + } + else + { + // The helpers log their own failure reasons - we just describe the consequences + Log.UnixOpenSslTrustFailed(); + sawTrustFailure = true; + } + + var nssDbs = GetNssDbs(homeDirectory); + if (nssDbs.Count > 0) + { + var isCertUtilAvailable = IsCommandAvailable(CertUtilCommand); + if (!isCertUtilAvailable) + { + Log.UnixMissingCertUtilCommand(CertUtilCommand); + // We'll loop over the nssdbs anyway so they'll be listed + } + + foreach (var nssDb in nssDbs) + { + if (isCertUtilAvailable && TryAddCertificateToNssDb(certPath, nickname, nssDb)) + { + if (IsCertificateInNssDb(nickname, nssDb)) + { + Log.UnixNssDbTrustSucceeded(nssDb.Path); + sawTrustSuccess = true; + } + else + { + // If the dev cert is in the db under a different nickname, adding it will succeed (and probably even cause it to be trusted) + // but IsTrusted won't find it. This is unlikely to happen in practice, so we warn here, rather than hardening IsTrusted. + Log.UnixNssDbTrustFailedWithProbableConflict(nssDb.Path, nssDb.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium); + sawTrustFailure = true; + } + } + else + { + Log.UnixNssDbTrustFailed(nssDb.Path, nssDb.IsFirefox ? BrowserFamilyFirefox : BrowserFamilyChromium); + sawTrustFailure = true; + } + } + } + + if (sawTrustFailure) + { + if (sawTrustSuccess) + { + // Untrust throws in this case, but we're more lenient since a partially trusted state may be useful in practice. + Log.UnixTrustPartiallySucceeded(); + } + else + { + return TrustLevel.None; + } + } + + if (openSslTrustSucceeded) + { + Debug.Assert(IsCommandAvailable(OpenSslCommand), "How did we trust without the openssl command?"); + + var homeDirectoryWithSlash = homeDirectory[^1] == Path.DirectorySeparatorChar + ? homeDirectory + : homeDirectory + Path.DirectorySeparatorChar; + + var prettyCertDir = certDir.StartsWith(homeDirectoryWithSlash, StringComparison.Ordinal) + ? Path.Combine("$HOME", certDir[homeDirectoryWithSlash.Length..]) + : certDir; + + var hasValidSslCertDir = false; + + // Check if SSL_CERT_DIR is already set and if certDir is already included + var existingSslCertDir = Environment.GetEnvironmentVariable(OpenSslCertificateDirectoryVariableName); + if (!string.IsNullOrEmpty(existingSslCertDir)) + { + var existingDirs = existingSslCertDir.Split(Path.PathSeparator); + var certDirFullPath = Path.GetFullPath(certDir); + var isCertDirIncluded = existingDirs.Any(dir => + { + if (string.IsNullOrWhiteSpace(dir)) + { + return false; + } + + try + { + return string.Equals(Path.GetFullPath(dir), certDirFullPath, StringComparison.Ordinal); + } + catch + { + // Ignore invalid directory entries in SSL_CERT_DIR + return false; + } + }); + + if (isCertDirIncluded) + { + // The certificate directory is already in SSL_CERT_DIR, no action needed + Log.UnixOpenSslCertificateDirectoryAlreadyConfigured(prettyCertDir, OpenSslCertificateDirectoryVariableName); + hasValidSslCertDir = true; + } + else + { + // SSL_CERT_DIR is set but doesn't include our directory - suggest appending + Log.UnixSuggestAppendingToEnvironmentVariable(prettyCertDir, OpenSslCertificateDirectoryVariableName); + hasValidSslCertDir = false; + } + } + else if (TryGetOpenSslDirectory(out var openSslDir)) + { + Log.UnixSuggestSettingEnvironmentVariable(prettyCertDir, Path.Combine(openSslDir, "certs"), OpenSslCertificateDirectoryVariableName); + hasValidSslCertDir = false; + } + else + { + Log.UnixSuggestSettingEnvironmentVariableWithoutExample(prettyCertDir, OpenSslCertificateDirectoryVariableName); + hasValidSslCertDir = false; + } + + sawTrustFailure = !hasValidSslCertDir; + } + + // Check to see if we're running in WSL; if so, use powershell.exe to add the certificate to the Windows trust store as well + if (IsRunningOnWslWithInterop()) + { + try + { + if (TrustCertificateInWindowsStore(certificate)) + { + Log.WslWindowsTrustSucceeded(); + } + else + { + Log.WslWindowsTrustFailed(); + sawTrustFailure = true; + } + } + catch (Exception ex) + { + Log.WslWindowsTrustException(ex.Message); + sawTrustFailure = true; + } + } + + return sawTrustFailure + ? TrustLevel.Partial + : TrustLevel.Full; + } + + protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) + { + var sawUntrustFailure = false; + + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + if (TryFindCertificateInStore(store, certificate, out var matching)) + { + try + { + store.Remove(matching); + } + catch (Exception ex) + { + Log.UnixDotnetUntrustException(ex.Message); + sawUntrustFailure = true; + } + } + + var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)!; + + // We don't attempt to remove the directory when it's empty - it's a standard location + // and will almost certainly be used in the future. + var certDir = GetOpenSslCertificateDirectory(homeDirectory); // May not exist + + var nickname = GetCertificateNickname(certificate); + var certPath = Path.Combine(certDir, nickname) + ".pem"; + + if (File.Exists(certPath)) + { + var openSslUntrustSucceeded = false; + + if (IsCommandAvailable(OpenSslCommand)) + { + if (TryDeleteCertificateFile(certPath) && TryRehashOpenSslCertificates(certDir)) + { + openSslUntrustSucceeded = true; + } + } + else + { + Log.UnixMissingOpenSslCommand(OpenSslCommand); + } + + if (openSslUntrustSucceeded) + { + Log.UnixOpenSslUntrustSucceeded(); + } + else + { + // The helpers log their own failure reasons - we just describe the consequences + Log.UnixOpenSslUntrustFailed(); + sawUntrustFailure = true; + } + } + else + { + Log.UnixOpenSslUntrustSkipped(certPath); + } + + var nssDbs = GetNssDbs(homeDirectory); + if (nssDbs.Count > 0) + { + var isCertUtilAvailable = IsCommandAvailable(CertUtilCommand); + if (!isCertUtilAvailable) + { + Log.UnixMissingCertUtilCommand(CertUtilCommand); + // We'll loop over the nssdbs anyway so they'll be listed + } + + foreach (var nssDb in nssDbs) + { + if (isCertUtilAvailable && TryRemoveCertificateFromNssDb(nickname, nssDb)) + { + Log.UnixNssDbUntrustSucceeded(nssDb.Path); + } + else + { + Log.UnixNssDbUntrustFailed(nssDb.Path); + sawUntrustFailure = true; + } + } + } + + if (sawUntrustFailure) + { + // It might be nice to include more specific error information in the exception message, but we've logged it anyway. + throw new InvalidOperationException($@"There was an error removing the certificate with thumbprint '{certificate.Thumbprint}'."); + } + } + + protected override IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) + { + return ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: false, requireExportable: false); + } + + protected override void CreateDirectoryWithPermissions(string directoryPath) + { +#pragma warning disable CA1416 // Validate platform compatibility (not supported on Windows) + var dirInfo = new DirectoryInfo(directoryPath); + if (dirInfo.Exists) + { + if ((dirInfo.UnixFileMode & ~DirectoryPermissions) != 0) + { + Log.DirectoryPermissionsNotSecure(dirInfo.FullName); + } + } + else + { + Directory.CreateDirectory(directoryPath, DirectoryPermissions); + } +#pragma warning restore CA1416 // Validate platform compatibility + } + + private static string GetChromiumNssDb(string homeDirectory) + { + return Path.Combine(homeDirectory, ".pki", "nssdb"); + } + + private static string GetChromiumSnapNssDb(string homeDirectory) + { + return Path.Combine(homeDirectory, "snap", "chromium", "current", ".pki", "nssdb"); + } + + private static string GetFirefoxDirectory(string homeDirectory) + { + return Path.Combine(homeDirectory, ".mozilla", "firefox"); + } + + private static string GetFirefoxSnapDirectory(string homeDirectory) + { + return Path.Combine(homeDirectory, "snap", "firefox", "common", ".mozilla", "firefox"); + } + + private bool IsCommandAvailable(string command) + { + _availableCommands ??= FindAvailableCommands(); + return _availableCommands.Contains(command); + } + + private static HashSet FindAvailableCommands() + { + var availableCommands = new HashSet(); + + // We need OpenSSL 1.1.1h or newer (to pick up https://github.com/openssl/openssl/pull/12357), + // but, given that all of v1 is EOL, it doesn't seem worthwhile to check the version. + var commands = new[] { OpenSslCommand, CertUtilCommand }; + + var searchPath = Environment.GetEnvironmentVariable("PATH"); + + if (searchPath is null) + { + return availableCommands; + } + + var searchFolders = searchPath.Split(Path.PathSeparator); + + foreach (var searchFolder in searchFolders) + { + foreach (var command in commands) + { + if (!availableCommands.Contains(command)) + { + try + { + if (File.Exists(Path.Combine(searchFolder, command))) + { + availableCommands.Add(command); + } + } + catch + { + // It's not interesting to report (e.g.) permission errors here. + } + } + } + + // Stop early if we've found all the required commands. + // They're usually all in the same folder (/bin or /usr/bin). + if (availableCommands.Count == commands.Length) + { + break; + } + } + + return availableCommands; + } + + private static string GetCertificateNickname(X509Certificate2 certificate) + { + return $"aspnetcore-localhost-{certificate.Thumbprint}"; + } + + /// + /// Detects if the current environment is Windows Subsystem for Linux (WSL) with interop enabled. + /// + /// True if running on WSL with interop; otherwise, false. + private static bool IsRunningOnWslWithInterop() + { + // WSL exposes special files that indicate WSL interop is enabled. + // Either WSLInterop or WSLInterop-late may be present depending on the WSL version and configuration. + if (File.Exists(WslInteropPath) || File.Exists(WslInteropLatePath)) + { + return true; + } + + // Additionally check for standard WSL environment variables as a fallback. + // WSL_INTEROP is set to the path of the interop socket. + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WSL_INTEROP"))) + { + return true; + } + + return false; + } + + /// + /// Attempts to trust the certificate in the Windows certificate store via PowerShell when running on WSL. + /// If the certificate already exists in the store, this is a no-op. + /// + /// The certificate to trust. + /// True if the certificate was successfully added to the Windows store; otherwise, false. + private static bool TrustCertificateInWindowsStore(X509Certificate2 certificate) + { + // Export the certificate as DER-encoded bytes (no private key needed for trust) + // and embed it directly in the PowerShell script as Base64 to avoid file path + // translation issues between WSL and Windows. + var certBytes = certificate.Export(X509ContentType.Cert); + var certBase64 = Convert.ToBase64String(certBytes); + + var escapedFriendlyName = WslFriendlyName.Replace("'", "''"); + var powershellScript = $@" + $certBytes = [Convert]::FromBase64String('{certBase64}') + $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,$certBytes) + $cert.FriendlyName = '{escapedFriendlyName}' + $store = New-Object System.Security.Cryptography.X509Certificates.X509Store('Root', 'CurrentUser') + $store.Open('ReadWrite') + $store.Add($cert) + $store.Close() + "; + + // Encode the PowerShell script to Base64 (UTF-16LE as required by PowerShell) + var encodedCommand = Convert.ToBase64String(System.Text.Encoding.Unicode.GetBytes(powershellScript)); + + var startInfo = new ProcessStartInfo(PowerShellCommand, $"-NoProfile -NonInteractive -EncodedCommand {encodedCommand}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + return process.ExitCode == 0; + } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private static bool IsCertificateInNssDb(string nickname, NssDb nssDb) + { + // -V will validate that a cert can be used for a given purpose, in this case, server verification. + // There is no corresponding -V check for the "Trusted CA" status required by Firefox, so we just check for existence. + // (The docs suggest that "-V -u A" should do this, but it seems to accept all certs.) + var operation = nssDb.IsFirefox ? "-L" : "-V -u V"; + + var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -n {nickname} {operation}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + try + { + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + return process.ExitCode == 0; + } + catch (Exception ex) + { + Log.UnixNssDbCheckException(nssDb.Path, ex.Message); + // This method is used to determine whether more trust is needed, so it's better to underestimate the amount of trust. + return false; + } + } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private static bool TryAddCertificateToNssDb(string certificatePath, string nickname, NssDb nssDb) + { + // Firefox doesn't seem to respected the more correct "trusted peer" (P) usage, so we use "trusted CA" (C) instead. + var usage = nssDb.IsFirefox ? "C" : "P"; + + // This silently clobbers an existing entry, so there's no need to check for existence first. + var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -n {nickname} -A -i {certificatePath} -t \"{usage},,\"") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + try + { + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + return process.ExitCode == 0; + } + catch (Exception ex) + { + Log.UnixNssDbAdditionException(nssDb.Path, ex.Message); + return false; + } + } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private static bool TryRemoveCertificateFromNssDb(string nickname, NssDb nssDb) + { + var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -D -n {nickname}") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + try + { + using var process = Process.Start(startInfo)!; + process.WaitForExit(); + if (process.ExitCode == 0) + { + return true; + } + + // Maybe it wasn't in there because the overrides have change or trust only partially succeeded. + return !IsCertificateInNssDb(nickname, nssDb); + } + catch (Exception ex) + { + Log.UnixNssDbRemovalException(nssDb.Path, ex.Message); + return false; + } + } + + private static IEnumerable GetFirefoxProfiles(string firefoxDirectory) + { + try + { + var profiles = Directory.GetDirectories(firefoxDirectory, "*.default", SearchOption.TopDirectoryOnly).Concat( + Directory.GetDirectories(firefoxDirectory, "*.default-*", SearchOption.TopDirectoryOnly)); // There can be one of these for each release channel + if (!profiles.Any()) + { + // This is noteworthy, given that we're in a firefox directory. + Log.UnixNoFirefoxProfilesFound(firefoxDirectory); + } + return profiles; + } + catch (Exception ex) + { + Log.UnixFirefoxProfileEnumerationException(firefoxDirectory, ex.Message); + return []; + } + } + + private static string GetOpenSslCertificateDirectory(string homeDirectory) + { + var @override = Environment.GetEnvironmentVariable(OpenSslCertDirectoryOverrideVariableName); + if (!string.IsNullOrEmpty(@override)) + { + Log.UnixOpenSslCertificateDirectoryOverridePresent(OpenSslCertDirectoryOverrideVariableName); + return @override; + } + + return Path.Combine(homeDirectory, ".aspnet", "dev-certs", "trust"); + } + + private static bool TryDeleteCertificateFile(string certPath) + { + try + { + File.Delete(certPath); + return true; + } + catch (Exception ex) + { + Log.UnixCertificateFileDeletionException(certPath, ex.Message); + return false; + } + } + + private static bool TryGetNssDbOverrides(out IReadOnlyList overrides) + { + var nssDbOverride = Environment.GetEnvironmentVariable(NssDbOverrideVariableName); + if (string.IsNullOrEmpty(nssDbOverride)) + { + overrides = []; + return false; + } + + // Normally, we'd let the caller log this, since it's not really an exceptional condition, + // but it's not worth duplicating the code and the work. + Log.UnixNssDbOverridePresent(NssDbOverrideVariableName); + + var nssDbs = new List(); + + var paths = nssDbOverride.Split(Path.PathSeparator); // May be empty - the user may not want to add browser trust + foreach (var path in paths) + { + var nssDb = Path.GetFullPath(path); + if (!Directory.Exists(nssDb)) + { + Log.UnixNssDbDoesNotExist(nssDb, NssDbOverrideVariableName); + continue; + } + nssDbs.Add(nssDb); + } + + overrides = nssDbs; + return true; + } + + private static List GetNssDbs(string homeDirectory) + { + var nssDbs = new List(); + + if (TryGetNssDbOverrides(out var nssDbOverrides)) + { + foreach (var nssDb in nssDbOverrides) + { + // Our Firefox approach is a hack, so we'd rather under-recognize it than over-recognize it. + var isFirefox = nssDb.Contains("/.mozilla/firefox/", StringComparison.Ordinal); + nssDbs.Add(new NssDb(nssDb, isFirefox)); + } + + return nssDbs; + } + + if (!Directory.Exists(homeDirectory)) + { + Log.UnixHomeDirectoryDoesNotExist(homeDirectory, Environment.UserName); + return nssDbs; + } + + // Chrome, Chromium, and Edge all use this directory + var chromiumNssDb = GetChromiumNssDb(homeDirectory); + if (Directory.Exists(chromiumNssDb)) + { + nssDbs.Add(new NssDb(chromiumNssDb, isFirefox: false)); + } + + // Chromium Snap, when launched under snap confinement, uses this directory + // (On Ubuntu, the GUI launcher uses confinement, but the terminal does not) + var chromiumSnapNssDb = GetChromiumSnapNssDb(homeDirectory); + if (Directory.Exists(chromiumSnapNssDb)) + { + nssDbs.Add(new NssDb(chromiumSnapNssDb, isFirefox: false)); + } + + var firefoxDir = GetFirefoxDirectory(homeDirectory); + if (Directory.Exists(firefoxDir)) + { + var profileDirs = GetFirefoxProfiles(firefoxDir); + foreach (var profileDir in profileDirs) + { + nssDbs.Add(new NssDb(profileDir, isFirefox: true)); + } + } + + var firefoxSnapDir = GetFirefoxSnapDirectory(homeDirectory); + if (Directory.Exists(firefoxSnapDir)) + { + var profileDirs = GetFirefoxProfiles(firefoxSnapDir); + foreach (var profileDir in profileDirs) + { + nssDbs.Add(new NssDb(profileDir, isFirefox: true)); + } + } + + return nssDbs; + } + + [GeneratedRegex("OPENSSLDIR:\\s*\"([^\"]+)\"")] + private static partial Regex OpenSslVersionRegex { get; } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private static bool TryGetOpenSslDirectory([NotNullWhen(true)] out string? openSslDir) + { + openSslDir = null; + + try + { + var processInfo = new ProcessStartInfo(OpenSslCommand, $"version -d") + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using var process = Process.Start(processInfo); + var stdout = process!.StandardOutput.ReadToEnd(); + + process.WaitForExit(); + if (process.ExitCode != 0) + { + Log.UnixOpenSslVersionFailed(); + return false; + } + + var match = OpenSslVersionRegex.Match(stdout); + if (!match.Success) + { + Log.UnixOpenSslVersionParsingFailed(); + return false; + } + + openSslDir = match.Groups[1].Value; + return true; + } + catch (Exception ex) + { + Log.UnixOpenSslVersionException(ex.Message); + return false; + } + } + + /// + /// It is the caller's responsibility to ensure that is available. + /// + private static bool TryGetOpenSslHash(string certificatePath, [NotNullWhen(true)] out string? hash) + { + hash = null; + + try + { + // c_rehash actually does this twice: once with -subject_hash (equivalent to -hash) and again + // with -subject_hash_old. Old hashes are only needed for pre-1.0.0, so we skip that. + var processInfo = new ProcessStartInfo(OpenSslCommand, $"x509 -hash -noout -in {certificatePath}") + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + using var process = Process.Start(processInfo); + var stdout = process!.StandardOutput.ReadToEnd(); + + process.WaitForExit(); + if (process.ExitCode != 0) + { + Log.UnixOpenSslHashFailed(certificatePath); + return false; + } + + hash = stdout.Trim(); + return true; + } + catch (Exception ex) + { + Log.UnixOpenSslHashException(certificatePath, ex.Message); + return false; + } + } + + [GeneratedRegex("^[0-9a-f]+\\.[0-9]+$")] + private static partial Regex OpenSslHashFilenameRegex { get; } + + /// + /// We only ever use .pem, but someone will eventually put their own cert in this directory, + /// so we should handle the same extensions as c_rehash (other than .crl). + /// + [GeneratedRegex("\\.(pem|crt|cer)$")] + private static partial Regex OpenSslCertificateExtensionRegex { get; } + + /// + /// This is a simplified version of c_rehash from OpenSSL. Using the real one would require + /// installing the OpenSSL perl tools and perl itself, which might be annoying in a container. + /// + private static bool TryRehashOpenSslCertificates(string certificateDirectory) + { + try + { + // First, delete all the existing symlinks, so we don't have to worry about fragmentation or leaks. + var certs = new List(); + + var dirInfo = new DirectoryInfo(certificateDirectory); + foreach (var file in dirInfo.EnumerateFiles()) + { + var isSymlink = (file.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + if (isSymlink && OpenSslHashFilenameRegex.IsMatch(file.Name)) + { + file.Delete(); + } + else if (OpenSslCertificateExtensionRegex.IsMatch(file.Name)) + { + certs.Add(file); + } + } + + // Then, enumerate all certificates - there will usually be zero or one. + + // c_rehash doesn't create additional symlinks for certs with the same fingerprint, + // but we don't expect this to happen, so we favor slightly slower look-ups when it + // does, rather than slightly slower rehashing when it doesn't. + + foreach (var cert in certs) + { + if (!TryGetOpenSslHash(cert.FullName, out var hash)) + { + return false; + } + + var linkCreated = false; + for (var i = 0; i < MaxHashCollisions; i++) + { + var linkPath = Path.Combine(certificateDirectory, $"{hash}.{i}"); + if (!File.Exists(linkPath)) + { + // As in c_rehash, we link using a relative path. + File.CreateSymbolicLink(linkPath, cert.Name); + linkCreated = true; + break; + } + } + + if (!linkCreated) + { + Log.UnixOpenSslRehashTooManyHashes(cert.FullName, hash, MaxHashCollisions); + return false; + } + } + } + catch (Exception ex) + { + Log.UnixOpenSslRehashException(ex.Message); + return false; + } + + return true; + } + + private sealed class NssDb(string path, bool isFirefox) + { + public string Path => path; + public bool IsFirefox => isFirefox; + } +} diff --git a/src/Aspire.Managed/CertificateGeneration/WindowsCertificateManager.cs b/src/Aspire.Managed/CertificateGeneration/WindowsCertificateManager.cs new file mode 100644 index 00000000000..8b0bd4c9b8a --- /dev/null +++ b/src/Aspire.Managed/CertificateGeneration/WindowsCertificateManager.cs @@ -0,0 +1,165 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Security.Principal; + +namespace Microsoft.AspNetCore.Certificates.Generation; + +[SupportedOSPlatform("windows")] +internal sealed class WindowsCertificateManager : CertificateManager +{ + private const int UserCancelledErrorCode = 1223; + + public WindowsCertificateManager() + { + } + + // For testing purposes only + internal WindowsCertificateManager(string subject, int version) + : base(subject, version) + { + } + + internal override bool IsExportable(X509Certificate2 c) + { +#if XPLAT + // For the first run experience we don't need to know if the certificate can be exported. + return true; +#else + using var key = c.GetRSAPrivateKey(); + return (key is RSACryptoServiceProvider rsaPrivateKey && + rsaPrivateKey.CspKeyContainerInfo.Exportable) || + (key is RSACng cngPrivateKey && + cngPrivateKey.Key.ExportPolicy == CngExportPolicies.AllowExport); +#endif + } + + internal override CheckCertificateStateResult CheckCertificateState(X509Certificate2 candidate) + { + return new CheckCertificateStateResult(true, null); + } + + internal override void CorrectCertificateState(X509Certificate2 candidate) + { + // Do nothing since we don't have anything to check here. + } + + protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certificate, StoreName storeName, StoreLocation storeLocation) + { + // On non OSX systems we need to export the certificate and import it so that the transient + // key that we generated gets persisted. + var export = certificate.Export(X509ContentType.Pkcs12, ""); + certificate.Dispose(); + certificate = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + Array.Clear(export, 0, export.Length); + certificate.FriendlyName = AspNetHttpsOidFriendlyName; + + using (var store = new X509Store(storeName, storeLocation)) + { + store.Open(OpenFlags.ReadWrite); + store.Add(certificate); + store.Close(); + }; + + return certificate; + } + + protected override TrustLevel TrustCertificateCore(X509Certificate2 certificate) + { + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + if (TryFindCertificateInStore(store, certificate, out _)) + { + Log.WindowsCertificateAlreadyTrusted(); + return TrustLevel.Full; + } + + try + { + Log.WindowsAddCertificateToRootStore(); + + using var publicCertificate = X509CertificateLoader.LoadCertificate(certificate.Export(X509ContentType.Cert)); + publicCertificate.FriendlyName = certificate.FriendlyName; + store.Add(publicCertificate); + return TrustLevel.Full; + } + catch (CryptographicException exception) when (exception.HResult == UserCancelledErrorCode) + { + Log.WindowsCertificateTrustCanceled(); + throw new UserCancelledTrustException(); + } + } + + protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certificate) + { + Log.WindowsRemoveCertificateFromRootStoreStart(); + + using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser); + store.Open(OpenFlags.ReadWrite); + + if (TryFindCertificateInStore(store, certificate, out var matching)) + { + store.Remove(matching); + } + else + { + Log.WindowsRemoveCertificateFromRootStoreNotFound(); + } + + Log.WindowsRemoveCertificateFromRootStoreEnd(); + } + + public override TrustLevel GetTrustLevel(X509Certificate2 certificate) + { + var isTrusted = ListCertificates(StoreName.Root, StoreLocation.CurrentUser, isValid: true, requireExportable: false) + .Any(c => AreCertificatesEqual(c, certificate)); + return isTrusted ? TrustLevel.Full : TrustLevel.None; + } + + protected override IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) + { + return ListCertificates(storeName, storeLocation, isValid: false); + } + + protected override void CreateDirectoryWithPermissions(string directoryPath) + { + var dirInfo = new DirectoryInfo(directoryPath); + + if (!dirInfo.Exists) + { + // We trust the default permissions on Windows enough not to apply custom ACLs. + // We'll warn below if things seem really off. + dirInfo.Create(); + } + + var currentUser = WindowsIdentity.GetCurrent(); + var currentUserSid = currentUser.User; + var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, domainSid: null); + var adminGroupSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, domainSid: null); + + var dirSecurity = dirInfo.GetAccessControl(); + var accessRules = dirSecurity.GetAccessRules(true, true, typeof(SecurityIdentifier)); + + foreach (FileSystemAccessRule rule in accessRules) + { + var idRef = rule.IdentityReference; + if (rule.AccessControlType == AccessControlType.Allow && + !idRef.Equals(currentUserSid) && + !idRef.Equals(systemSid) && + !idRef.Equals(adminGroupSid)) + { + // This is just a heuristic - determining whether the cumulative effect of the rules + // is to allow access to anyone other than the current user, system, or administrators + // is very complicated. We're not going to do anything but log, so an approximation + // is fine. + Log.DirectoryPermissionsNotSecure(dirInfo.FullName); + break; + } + } + } +} diff --git a/src/Aspire.Managed/DevCerts/DevCertsCommand.cs b/src/Aspire.Managed/DevCerts/DevCertsCommand.cs new file mode 100644 index 00000000000..6640fd967f8 --- /dev/null +++ b/src/Aspire.Managed/DevCerts/DevCertsCommand.cs @@ -0,0 +1,434 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Certificates.Generation; + +namespace Aspire.Managed.DevCerts; + +/// +/// Implements the dev-certs subcommand for aspire-managed, providing full parity +/// with dotnet dev-certs https without requiring the .NET SDK on PATH. +/// +internal static class DevCertsCommand +{ + // Exit codes — matches dotnet dev-certs for compatibility. + private const int CriticalError = -1; + private const int Success = 0; + private const int ErrorCreatingTheCertificate = 1; + private const int ErrorSavingTheCertificate = 2; + private const int ErrorExportingTheCertificate = 3; + private const int ErrorTrustingTheCertificate = 4; + private const int ErrorUserCancelledTrustPrompt = 5; + private const int ErrorNoValidCertificateFound = 6; + private const int ErrorCertificateNotTrusted = 7; + private const int ErrorCleaningUpCertificates = 8; + private const int InvalidCertificateState = 9; + private const int InvalidKeyExportFormat = 10; + private const int ErrorImportingCertificate = 11; + private const int MissingCertificateFile = 12; + private const int FailedToLoadCertificate = 13; + private const int NoDevelopmentHttpsCertificate = 14; + private const int ExistingCertificatesPresent = 15; + + public static readonly TimeSpan HttpsCertificateValidity = TimeSpan.FromDays(365); + + public static int Run(string[] args) + { + try + { + // Parse args without a CLI framework — simple flag matching. + var flags = new HashSet(StringComparer.OrdinalIgnoreCase); + string? exportPath = null; + string? password = null; + string? format = null; + string? importPath = null; + + for (var i = 0; i < args.Length; i++) + { + switch (args[i]) + { + case "-ep" or "--export-path": + exportPath = GetNextArg(args, ref i); + break; + case "-p" or "--password": + password = GetNextArg(args, ref i); + break; + case "--format": + format = GetNextArg(args, ref i); + break; + case "-i" or "--import": + importPath = GetNextArg(args, ref i); + break; + default: + flags.Add(args[i]); + break; + } + } + + var hasCheck = flags.Contains("--check") || flags.Contains("-c"); + var hasTrust = flags.Contains("--trust") || flags.Contains("-t"); + var hasClean = flags.Contains("--clean"); + var hasCheckJson = flags.Contains("--check-trust-machine-readable"); + var hasNoPassword = flags.Contains("--no-password") || flags.Contains("-np"); + var hasVerbose = flags.Contains("--verbose") || flags.Contains("-v"); + var hasQuiet = flags.Contains("--quiet") || flags.Contains("-q"); + var hasHelp = flags.Contains("--help") || flags.Contains("-h"); + + if (hasHelp) + { + ShowHelp(); + return Success; + } + + // Route to the appropriate handler. + if (hasCheckJson) + { + return CheckHttpsCertificateJsonOutput(); + } + + if (hasCheck) + { + return CheckHttpsCertificate(hasTrust, hasVerbose); + } + + if (hasClean) + { + var cleanResult = CleanHttpsCertificates(); + if (cleanResult != Success || importPath is null) + { + return cleanResult; + } + + return ImportCertificate(importPath, password); + } + + return EnsureHttpsCertificate(exportPath, password, hasNoPassword, hasTrust, format); + } + catch + { + return CriticalError; + } + } + + private static string? GetNextArg(string[] args, ref int index) + { + if (index + 1 < args.Length) + { + return args[++index]; + } + + return null; + } + + private static void ShowHelp() + { + Console.WriteLine(""" + Usage: aspire-managed dev-certs [options] + + Options: + --check, -c Check for the existence of the certificate + --check-trust-machine-readable Check trust status and output JSON + --trust, -t Trust the certificate + --clean Remove all HTTPS development certificates + --import, -i Import a certificate (use with --clean) + --export-path, -ep Export the certificate to a file + --password, -p Password for export/import + --no-password, -np Export PEM key without password + --format Export format (default: Pfx) + --verbose, -v Display verbose output + --quiet, -q Display warnings and errors only + --help, -h Show this help + """); + } + + private static int CheckHttpsCertificateJsonOutput() + { + var availableCertificates = CertificateManager.Instance.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); + var certReports = availableCertificates.Select(CertificateReport.FromX509Certificate2).ToList(); + Console.WriteLine(JsonSerializer.Serialize(certReports, DevCertsJsonContext.Default.ListCertificateReport)); + return Success; + } + + private static int CheckHttpsCertificate(bool checkTrust, bool verbose) + { + var certificateManager = CertificateManager.Instance; + var certificates = certificateManager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); + + if (certificates.Count == 0) + { + Console.WriteLine("No valid certificate found."); + return ErrorNoValidCertificateFound; + } + + var validCertificates = new List(); + foreach (var certificate in certificates) + { + var status = certificateManager.CheckCertificateState(certificate); + if (!status.Success) + { + Console.Error.WriteLine(status.FailureMessage); + return InvalidCertificateState; + } + validCertificates.Add(certificate); + } + + if (checkTrust) + { + var trustedCertificates = certificates + .Where(cert => certificateManager.GetTrustLevel(cert) == CertificateManager.TrustLevel.Full) + .ToList(); + + if (trustedCertificates.Count == 0) + { + Console.WriteLine($"The following certificates were found, but none of them is trusted: {CertificateManager.ToCertificateDescription(certificates)}"); + if (!verbose) + { + Console.WriteLine("Run the command with --verbose for more details."); + } + return ErrorCertificateNotTrusted; + } + + ReportCertificates(trustedCertificates, "trusted"); + } + else + { + ReportCertificates(validCertificates, "valid"); + Console.WriteLine("Run the command with both --check and --trust options to ensure that the certificate is not only valid but also trusted."); + } + + return Success; + } + + private static void ReportCertificates(IReadOnlyList certificates, string certificateState) + { + Console.WriteLine(certificates.Count switch + { + 1 => $"A {certificateState} certificate was found: {CertificateManager.GetDescription(certificates[0])}", + _ => $"{certificates.Count} {certificateState} certificates were found: {CertificateManager.ToCertificateDescription(certificates)}" + }); + } + + private static int CleanHttpsCertificates() + { + var manager = CertificateManager.Instance; + try + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Console.WriteLine("Cleaning HTTPS development certificates from the machine. A prompt might get " + + "displayed to confirm the removal of some of the certificates."); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Console.WriteLine("Cleaning HTTPS development certificates from the machine. This operation might " + + "require elevated privileges. If that is the case, a prompt for credentials will be displayed."); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Console.WriteLine("Cleaning HTTPS development certificates from the machine. You may wish to update the " + + "SSL_CERT_DIR environment variable. " + + "See https://aka.ms/dev-certs-trust for more information."); + } + + manager.CleanupHttpsCertificates(); + Console.WriteLine("HTTPS development certificates successfully removed from the machine."); + return Success; + } + catch (Exception e) + { + Console.Error.WriteLine("There was an error trying to clean HTTPS development certificates on this machine."); + Console.Error.WriteLine(e.Message); + return ErrorCleaningUpCertificates; + } + } + + private static int ImportCertificate(string importPath, string? password) + { + if (password is null) + { + Console.Error.WriteLine("Password is required when importing a certificate."); + return CriticalError; + } + + var manager = CertificateManager.Instance; + try + { + var result = manager.ImportCertificate(importPath, password); + return result switch + { + ImportCertificateResult.Succeeded => PrintAndReturn("The certificate was successfully imported.", Success), + ImportCertificateResult.CertificateFileMissing => PrintErrorAndReturn($"The certificate file '{importPath}' does not exist.", MissingCertificateFile), + ImportCertificateResult.InvalidCertificate => PrintErrorAndReturn($"The provided certificate file '{importPath}' is not a valid PFX file or the password is incorrect.", FailedToLoadCertificate), + ImportCertificateResult.NoDevelopmentHttpsCertificate => PrintErrorAndReturn($"The certificate at '{importPath}' is not a valid ASP.NET Core HTTPS development certificate.", NoDevelopmentHttpsCertificate), + ImportCertificateResult.ExistingCertificatesPresent => PrintErrorAndReturn("There are one or more ASP.NET Core HTTPS development certificates present in the environment. Remove them before importing the given certificate.", ExistingCertificatesPresent), + ImportCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore => PrintErrorAndReturn("There was an error saving the HTTPS developer certificate to the current user personal certificate store.", ErrorSavingTheCertificate), + _ => Success + }; + } + catch (Exception exception) + { + Console.Error.WriteLine($"An unexpected error occurred: {exception}"); + return ErrorImportingCertificate; + } + } + + private static int EnsureHttpsCertificate(string? exportPath, string? password, bool noPassword, bool trust, string? exportFormat) + { + var now = DateTimeOffset.Now; + var manager = CertificateManager.Instance; + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + var certificates = manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, exportPath is not null); + foreach (var certificate in certificates) + { + var status = manager.CheckCertificateState(certificate); + if (!status.Success) + { + Console.Error.WriteLine("One or more certificates might be in an invalid state. We will try to access the certificate key " + + "for each certificate and as a result you might be prompted one or more times to enter " + + "your password to access the user keychain. " + + "When that happens, select 'Always Allow' to grant access to the certificate key in the future."); + } + break; + } + } + + if (trust) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + Console.Error.WriteLine("Trusting the HTTPS development certificate was requested. If the certificate is not " + + "already trusted we will run the following command:" + Environment.NewLine + + "'security add-trusted-cert -p basic -p ssl -k <> <>'" + + Environment.NewLine + "This command might prompt you for your password to install the certificate " + + "on the keychain. To undo these changes: 'security remove-trusted-cert <>'" + Environment.NewLine); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Console.Error.WriteLine("Trusting the HTTPS development certificate was requested. A confirmation prompt will be displayed " + + "if the certificate was not previously trusted. Click yes on the prompt to trust the certificate."); + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + Console.Error.WriteLine("Trusting the HTTPS development certificate was requested. " + + "Trust is per-user and may require additional configuration. " + + "See https://aka.ms/dev-certs-trust for more information."); + } + } + + var format = CertificateKeyExportFormat.Pfx; + if (exportFormat is not null && !Enum.TryParse(exportFormat, ignoreCase: true, out format)) + { + Console.Error.WriteLine($"Unknown key format '{exportFormat}'."); + return InvalidKeyExportFormat; + } + + var result = manager.EnsureAspNetCoreHttpsDevelopmentCertificate( + now, + now.Add(HttpsCertificateValidity), + exportPath, + trust, + password is not null || (noPassword && format == CertificateKeyExportFormat.Pem), + password, + exportFormat is not null ? format : CertificateKeyExportFormat.Pfx); + + return result switch + { + EnsureCertificateResult.Succeeded => PrintAndReturn("The HTTPS developer certificate was generated successfully.", Success), + EnsureCertificateResult.ValidCertificatePresent => PrintAndReturn("A valid HTTPS certificate is already present.", Success), + EnsureCertificateResult.ErrorCreatingTheCertificate => PrintErrorAndReturn("There was an error creating the HTTPS developer certificate.", ErrorCreatingTheCertificate), + EnsureCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore => PrintErrorAndReturn("There was an error saving the HTTPS developer certificate to the current user personal certificate store.", ErrorSavingTheCertificate), + EnsureCertificateResult.ErrorExportingTheCertificate or EnsureCertificateResult.ErrorExportingTheCertificateToNonExistentDirectory => PrintErrorAndReturn("There was an error exporting the HTTPS developer certificate to a file.", ErrorExportingTheCertificate), + EnsureCertificateResult.PartiallyFailedToTrustTheCertificate => PrintErrorAndReturn("There was an error trusting the HTTPS developer certificate. It will be trusted by some clients but not by others.", ErrorTrustingTheCertificate), + EnsureCertificateResult.FailedToTrustTheCertificate => PrintErrorAndReturn("There was an error trusting the HTTPS developer certificate.", ErrorTrustingTheCertificate), + EnsureCertificateResult.UserCancelledTrustStep => PrintErrorAndReturn("The user cancelled the trust step.", ErrorUserCancelledTrustPrompt), + EnsureCertificateResult.ExistingHttpsCertificateTrusted => PrintAndReturn("Successfully trusted the existing HTTPS certificate.", Success), + EnsureCertificateResult.NewHttpsCertificateTrusted => PrintAndReturn("Successfully created and trusted a new HTTPS certificate.", Success), + _ => PrintErrorAndReturn("Something went wrong. The HTTPS developer certificate could not be created.", CriticalError) + }; + } + + private static int PrintAndReturn(string message, int exitCode) + { + Console.WriteLine(message); + return exitCode; + } + + private static int PrintErrorAndReturn(string message, int exitCode) + { + Console.Error.WriteLine(message); + return exitCode; + } +} + +/// +/// JSON-serializable certificate report matching the dotnet dev-certs https --check-trust-machine-readable output format. +/// +internal sealed class CertificateReport +{ + public string? Thumbprint { get; init; } + public string? Subject { get; init; } + public List? X509SubjectAlternativeNameExtension { get; init; } + public int Version { get; init; } + public DateTime ValidityNotBefore { get; init; } + public DateTime ValidityNotAfter { get; init; } + public bool IsHttpsDevelopmentCertificate { get; init; } + public bool IsExportable { get; init; } + public string? TrustLevel { get; init; } + + public static CertificateReport FromX509Certificate2(X509Certificate2 cert) + { + var certificateManager = CertificateManager.Instance; + var status = certificateManager.CheckCertificateState(cert); + string statusString; + if (!status.Success) + { + statusString = "Invalid"; + } + else + { + var trustStatus = certificateManager.GetTrustLevel(cert); + statusString = trustStatus.ToString(); + } + + return new CertificateReport + { + Thumbprint = cert.Thumbprint, + Subject = cert.Subject, + X509SubjectAlternativeNameExtension = GetSanExtension(cert), + Version = CertificateManager.GetCertificateVersion(cert), + ValidityNotBefore = cert.NotBefore, + ValidityNotAfter = cert.NotAfter, + IsHttpsDevelopmentCertificate = CertificateManager.IsHttpsDevelopmentCertificate(cert), + IsExportable = certificateManager.IsExportable(cert), + TrustLevel = statusString + }; + + static List GetSanExtension(X509Certificate2 cert) + { + var dnsNames = new List(); + foreach (var extension in cert.Extensions) + { + if (extension is X509SubjectAlternativeNameExtension sanExtension) + { + foreach (var dns in sanExtension.EnumerateDnsNames()) + { + dnsNames.Add(dns); + } + } + } + return dnsNames; + } + } +} + +[JsonSerializable(typeof(List))] +[JsonSourceGenerationOptions(WriteIndented = true)] +internal sealed partial class DevCertsJsonContext : JsonSerializerContext; diff --git a/src/Aspire.Managed/Program.cs b/src/Aspire.Managed/Program.cs index 5deccea0c79..fa46035845e 100644 --- a/src/Aspire.Managed/Program.cs +++ b/src/Aspire.Managed/Program.cs @@ -2,12 +2,14 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard; +using Aspire.Managed.DevCerts; return args switch { ["dashboard", .. var rest] => RunDashboard(rest), ["server", .. var rest] => await RunServer(rest).ConfigureAwait(false), ["nuget", .. var rest] => await RunNuGet(rest).ConfigureAwait(false), + ["dev-certs", .. var rest] => DevCertsCommand.Run(rest), _ => ShowUsage() }; @@ -36,6 +38,6 @@ static async Task RunNuGet(string[] args) static int ShowUsage() { - Console.Error.WriteLine($"Usage: {AppDomain.CurrentDomain.FriendlyName} [args...]"); + Console.Error.WriteLine($"Usage: {AppDomain.CurrentDomain.FriendlyName} [args...]"); return 1; } From a44bb5f6d0a65364fb8c310f625798d3cc6246e2 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 08:25:39 -0800 Subject: [PATCH 03/14] Move CertificateGeneration into Aspire.Cli with ILogger replacing EventSource - Move 8 CertificateGeneration files from Aspire.Managed to Aspire.Cli/Certificates/CertificateGeneration/ - Replace CertificateManagerEventSource (AOT-incompatible) with CertificateManagerLogger backed by ILogger - Add NativeCertificateToolRunner that calls CertificateManager.Instance directly (no subprocess) - Delete BundleCertificateToolRunner and SdkCertificateToolRunner (subprocess spawners) - Remove dev-certs command and CertificateGeneration from aspire-managed - Clean up aspire-managed csproj warning suppressions --- src/Aspire.Cli/Aspire.Cli.csproj | 2 +- .../BundleCertificateToolRunner.cs | 155 ----- .../CertificateExportFormat.cs | 0 .../CertificateManager.cs | 624 ++++++++++-------- .../CertificatePurpose.cs | 0 .../EnsureCertificateResult.cs | 0 .../ImportCertificateResult.cs | 0 .../MacOSCertificateManager.cs | 0 .../UnixCertificateManager.cs | 0 .../WindowsCertificateManager.cs | 0 .../NativeCertificateToolRunner.cs | 100 +++ .../Certificates/SdkCertificateToolRunner.cs | 155 ----- src/Aspire.Cli/Program.cs | 11 +- src/Aspire.Managed/Aspire.Managed.csproj | 3 +- .../DevCerts/DevCertsCommand.cs | 434 ------------ src/Aspire.Managed/Program.cs | 4 +- 16 files changed, 470 insertions(+), 1018 deletions(-) delete mode 100644 src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs rename src/{Aspire.Managed => Aspire.Cli/Certificates}/CertificateGeneration/CertificateExportFormat.cs (100%) rename src/{Aspire.Managed => Aspire.Cli/Certificates}/CertificateGeneration/CertificateManager.cs (67%) rename src/{Aspire.Managed => Aspire.Cli/Certificates}/CertificateGeneration/CertificatePurpose.cs (100%) rename src/{Aspire.Managed => Aspire.Cli/Certificates}/CertificateGeneration/EnsureCertificateResult.cs (100%) rename src/{Aspire.Managed => Aspire.Cli/Certificates}/CertificateGeneration/ImportCertificateResult.cs (100%) rename src/{Aspire.Managed => Aspire.Cli/Certificates}/CertificateGeneration/MacOSCertificateManager.cs (100%) rename src/{Aspire.Managed => Aspire.Cli/Certificates}/CertificateGeneration/UnixCertificateManager.cs (100%) rename src/{Aspire.Managed => Aspire.Cli/Certificates}/CertificateGeneration/WindowsCertificateManager.cs (100%) create mode 100644 src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs delete mode 100644 src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs delete mode 100644 src/Aspire.Managed/DevCerts/DevCertsCommand.cs diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index 654c480a3a3..efdf4b4233f 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -9,7 +9,7 @@ false aspire Aspire.Cli - $(NoWarn);CS1591 + $(NoWarn);CS1591;SYSLIB0057;IDE1006 true Size $(DefineConstants);CLI diff --git a/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs deleted file mode 100644 index 65b35e158fc..00000000000 --- a/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs +++ /dev/null @@ -1,155 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Text; -using System.Text.Json; -using Aspire.Cli.DotNet; -using Aspire.Cli.Layout; -using Microsoft.Extensions.Logging; - -namespace Aspire.Cli.Certificates; - -/// -/// Certificate tool runner for bundle mode that invokes aspire-managed dev-certs -/// instead of the global dotnet SDK's dev-certs command. -/// -internal sealed class BundleCertificateToolRunner( - ILayoutDiscovery layoutDiscovery, - ILogger logger) : ICertificateToolRunner -{ - public async Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( - DotNetCliRunnerInvocationOptions options, - CancellationToken cancellationToken) - { - var managedPath = GetManagedPath(); - - var outputBuilder = new StringBuilder(); - - var arguments = new[] { "dev-certs", "--check-trust-machine-readable" }; - - logger.LogDebug("Running: {ManagedPath} {Args}", managedPath, string.Join(" ", arguments)); - - var (exitCode, output, error) = await LayoutProcessRunner.RunAsync( - managedPath, - arguments, - ct: cancellationToken); - - // Forward output/error to callers - if (!string.IsNullOrEmpty(output)) - { - outputBuilder.Append(output); - foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) - { - options.StandardOutputCallback?.Invoke(line.TrimEnd('\r')); - } - } - - if (!string.IsNullOrEmpty(error)) - { - foreach (var line in error.Split('\n', StringSplitOptions.RemoveEmptyEntries)) - { - options.StandardErrorCallback?.Invoke(line.TrimEnd('\r')); - } - } - - // Parse the JSON output - try - { - var jsonOutput = outputBuilder.ToString().Trim(); - if (string.IsNullOrEmpty(jsonOutput)) - { - return (exitCode, new CertificateTrustResult - { - HasCertificates = false, - TrustLevel = null, - Certificates = [] - }); - } - - var certificates = JsonSerializer.Deserialize(jsonOutput, JsonSourceGenerationContext.Default.ListDevCertInfo); - if (certificates is null || certificates.Count == 0) - { - return (exitCode, new CertificateTrustResult - { - HasCertificates = false, - TrustLevel = null, - Certificates = [] - }); - } - - // Find the highest versioned valid certificate - var now = DateTimeOffset.Now; - var validCertificates = certificates - .Where(c => c.IsHttpsDevelopmentCertificate && c.ValidityNotBefore <= now && now <= c.ValidityNotAfter) - .OrderByDescending(c => c.Version) - .ToList(); - - var highestVersionedCert = validCertificates.FirstOrDefault(); - var trustLevel = highestVersionedCert?.TrustLevel; - - return (exitCode, new CertificateTrustResult - { - HasCertificates = validCertificates.Count > 0, - TrustLevel = trustLevel, - Certificates = certificates - }); - } - catch (JsonException ex) - { - logger.LogDebug(ex, "Failed to parse dev-certs machine-readable output"); - return (exitCode, null); - } - } - - public async Task TrustHttpCertificateAsync( - DotNetCliRunnerInvocationOptions options, - CancellationToken cancellationToken) - { - var managedPath = GetManagedPath(); - - var arguments = new[] { "dev-certs", "--trust" }; - - logger.LogDebug("Running: {ManagedPath} {Args}", managedPath, string.Join(" ", arguments)); - - var (exitCode, output, error) = await LayoutProcessRunner.RunAsync( - managedPath, - arguments, - ct: cancellationToken); - - // Forward output/error to callers - if (!string.IsNullOrEmpty(output)) - { - foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) - { - options.StandardOutputCallback?.Invoke(line.TrimEnd('\r')); - } - } - - if (!string.IsNullOrEmpty(error)) - { - foreach (var line in error.Split('\n', StringSplitOptions.RemoveEmptyEntries)) - { - options.StandardErrorCallback?.Invoke(line.TrimEnd('\r')); - } - } - - return exitCode; - } - - private string GetManagedPath() - { - var layout = layoutDiscovery.DiscoverLayout(); - if (layout is null) - { - throw new InvalidOperationException("Bundle layout not found. Cannot run dev-certs in bundle mode."); - } - - var managedPath = layout.GetManagedPath(); - if (managedPath is null || !File.Exists(managedPath)) - { - throw new InvalidOperationException("aspire-managed not found in layout."); - } - - return managedPath; - } -} diff --git a/src/Aspire.Managed/CertificateGeneration/CertificateExportFormat.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateExportFormat.cs similarity index 100% rename from src/Aspire.Managed/CertificateGeneration/CertificateExportFormat.cs rename to src/Aspire.Cli/Certificates/CertificateGeneration/CertificateExportFormat.cs diff --git a/src/Aspire.Managed/CertificateGeneration/CertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs similarity index 67% rename from src/Aspire.Managed/CertificateGeneration/CertificateManager.cs rename to src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs index a51b76e5437..57e5967f869 100644 --- a/src/Aspire.Managed/CertificateGeneration/CertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs @@ -3,12 +3,13 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Diagnostics.Tracing; using System.Net; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; #nullable enable @@ -58,7 +59,7 @@ internal abstract class CertificateManager new MacOSCertificateManager() as CertificateManager : new UnixCertificateManager(); - public static CertificateManagerEventSource Log { get; set; } = new CertificateManagerEventSource(); + public static CertificateManagerLogger Log { get; set; } = new CertificateManagerLogger(); // Setting to 0 means we don't append the version byte, // which is what all machines currently have. @@ -678,7 +679,7 @@ internal void ExportCertificate(X509Certificate2 certificate, string path, bool } } } - catch (Exception e) when (Log.IsEnabled()) + catch (Exception e) { Log.ExportCertificateError(e.ToString()); throw; @@ -701,7 +702,7 @@ internal void ExportCertificate(X509Certificate2 certificate, string path, bool File.WriteAllBytes(path, bytes); } - catch (Exception ex) when (Log.IsEnabled()) + catch (Exception ex) { Log.WriteCertificateToDiskError(ex.ToString()); throw; @@ -729,7 +730,7 @@ internal void ExportCertificate(X509Certificate2 certificate, string path, bool File.WriteAllBytes(keyPath, pemEnvelope); } - catch (Exception ex) when (Log.IsEnabled()) + catch (Exception ex) { Log.WritePemKeyToDiskError(ex.ToString()); throw; @@ -834,7 +835,7 @@ internal TrustLevel TrustCertificate(X509Certificate2 certificate) Log.TrustCertificateEnd(); return trustLevel; } - catch (Exception ex) when (Log.IsEnabled()) + catch (Exception ex) { Log.TrustCertificateError(ex.ToString()); throw; @@ -962,7 +963,7 @@ protected void RemoveCertificateFromUserStore(X509Certificate2 certificate) RemoveCertificateFromUserStoreCore(certificate); Log.RemoveCertificateFromUserStoreEnd(); } - catch (Exception ex) when (Log.IsEnabled()) + catch (Exception ex) { Log.RemoveCertificateFromUserStoreError(ex.ToString()); throw; @@ -1040,373 +1041,478 @@ internal static bool TryFindCertificateInStore(X509Store store, X509Certificate2 return foundCertificate is not null; } - [EventSource(Name = "Dotnet-dev-certs")] - public sealed class CertificateManagerEventSource : EventSource + internal sealed class CertificateManagerLogger { - [Event(1, Level = EventLevel.Verbose, Message = "Listing certificates from {0}\\{1}")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Parameters passed to WriteEvent are all primative values.")] - public void ListCertificatesStart(StoreLocation location, StoreName storeName) => WriteEvent(1, location, storeName); + private readonly ILogger _logger; + + public CertificateManagerLogger() : this(NullLogger.Instance) { } + + public CertificateManagerLogger(ILogger logger) + { + _logger = logger; + } + + public bool IsEnabled() => _logger.IsEnabled(LogLevel.Debug); + + // Event 1 - Verbose + public void ListCertificatesStart(StoreLocation location, StoreName storeName) => + _logger.LogDebug("Listing certificates from {Location}\\{StoreName}", location, storeName); - [Event(2, Level = EventLevel.Verbose, Message = "Found certificates: {0}")] - public void DescribeFoundCertificates(string matchingCertificates) => WriteEvent(2, matchingCertificates); + // Event 2 - Verbose + public void DescribeFoundCertificates(string matchingCertificates) => + _logger.LogDebug("Found certificates: {MatchingCertificates}", matchingCertificates); - [Event(3, Level = EventLevel.Verbose, Message = "Checking certificates validity")] - public void CheckCertificatesValidity() => WriteEvent(3); + // Event 3 - Verbose + public void CheckCertificatesValidity() => + _logger.LogDebug("Checking certificates validity"); - [Event(4, Level = EventLevel.Verbose, Message = "Valid certificates: {0}")] - public void DescribeValidCertificates(string validCertificates) => WriteEvent(4, validCertificates); + // Event 4 - Verbose + public void DescribeValidCertificates(string validCertificates) => + _logger.LogDebug("Valid certificates: {ValidCertificates}", validCertificates); - [Event(5, Level = EventLevel.Verbose, Message = "Invalid certificates: {0}")] - public void DescribeInvalidCertificates(string invalidCertificates) => WriteEvent(5, invalidCertificates); + // Event 5 - Verbose + public void DescribeInvalidCertificates(string invalidCertificates) => + _logger.LogDebug("Invalid certificates: {InvalidCertificates}", invalidCertificates); - [Event(6, Level = EventLevel.Verbose, Message = "Finished listing certificates.")] - public void ListCertificatesEnd() => WriteEvent(6); + // Event 6 - Verbose + public void ListCertificatesEnd() => + _logger.LogDebug("Finished listing certificates."); - [Event(7, Level = EventLevel.Error, Message = "An error occurred while listing the certificates: {0}")] - public void ListCertificatesError(string e) => WriteEvent(7, e); + // Event 7 - Error + public void ListCertificatesError(string e) => + _logger.LogError("An error occurred while listing the certificates: {Error}", e); - [Event(8, Level = EventLevel.Verbose, Message = "Filtered certificates: {0}")] - public void FilteredCertificates(string filteredCertificates) => WriteEvent(8, filteredCertificates); + // Event 8 - Verbose + public void FilteredCertificates(string filteredCertificates) => + _logger.LogDebug("Filtered certificates: {FilteredCertificates}", filteredCertificates); - [Event(9, Level = EventLevel.Verbose, Message = "Excluded certificates: {0}")] - public void ExcludedCertificates(string excludedCertificates) => WriteEvent(9, excludedCertificates); + // Event 9 - Verbose + public void ExcludedCertificates(string excludedCertificates) => + _logger.LogDebug("Excluded certificates: {ExcludedCertificates}", excludedCertificates); - [Event(14, Level = EventLevel.Verbose, Message = "Valid certificates: {0}")] - public void ValidCertificatesFound(string certificates) => WriteEvent(14, certificates); + // Event 14 - Verbose + public void ValidCertificatesFound(string certificates) => + _logger.LogDebug("Valid certificates: {Certificates}", certificates); - [Event(15, Level = EventLevel.Verbose, Message = "Selected certificate: {0}")] - public void SelectedCertificate(string certificate) => WriteEvent(15, certificate); + // Event 15 - Verbose + public void SelectedCertificate(string certificate) => + _logger.LogDebug("Selected certificate: {Certificate}", certificate); - [Event(16, Level = EventLevel.Verbose, Message = "No valid certificates found.")] - public void NoValidCertificatesFound() => WriteEvent(16); + // Event 16 - Verbose + public void NoValidCertificatesFound() => + _logger.LogDebug("No valid certificates found."); - [Event(17, Level = EventLevel.Verbose, Message = "Generating HTTPS development certificate.")] - public void CreateDevelopmentCertificateStart() => WriteEvent(17); + // Event 17 - Verbose + public void CreateDevelopmentCertificateStart() => + _logger.LogDebug("Generating HTTPS development certificate."); - [Event(18, Level = EventLevel.Verbose, Message = "Finished generating HTTPS development certificate.")] - public void CreateDevelopmentCertificateEnd() => WriteEvent(18); + // Event 18 - Verbose + public void CreateDevelopmentCertificateEnd() => + _logger.LogDebug("Finished generating HTTPS development certificate."); - [Event(19, Level = EventLevel.Error, Message = "An error has occurred generating the certificate: {0}.")] - public void CreateDevelopmentCertificateError(string e) => WriteEvent(19, e); + // Event 19 - Error + public void CreateDevelopmentCertificateError(string e) => + _logger.LogError("An error has occurred generating the certificate: {Error}.", e); - [Event(20, Level = EventLevel.Verbose, Message = "Saving certificate '{0}' to store {2}\\{1}.")] - [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Parameters passed to WriteEvent are all primitive values.")] - public void SaveCertificateInStoreStart(string certificate, StoreName name, StoreLocation location) => WriteEvent(20, certificate, name, location); + // Event 20 - Verbose + public void SaveCertificateInStoreStart(string certificate, StoreName name, StoreLocation location) => + _logger.LogDebug("Saving certificate '{Certificate}' to store {Location}\\{StoreName}.", certificate, location, name); - [Event(21, Level = EventLevel.Verbose, Message = "Finished saving certificate to the store.")] - public void SaveCertificateInStoreEnd() => WriteEvent(21); + // Event 21 - Verbose + public void SaveCertificateInStoreEnd() => + _logger.LogDebug("Finished saving certificate to the store."); - [Event(22, Level = EventLevel.Error, Message = "An error has occurred saving the certificate: {0}.")] - public void SaveCertificateInStoreError(string e) => WriteEvent(22, e); + // Event 22 - Error + public void SaveCertificateInStoreError(string e) => + _logger.LogError("An error has occurred saving the certificate: {Error}.", e); - [Event(23, Level = EventLevel.Verbose, Message = "Saving certificate '{0}' to {1} {2} private key.")] - public void ExportCertificateStart(string certificate, string path, bool includePrivateKey) => WriteEvent(23, certificate, path, includePrivateKey ? "with" : "without"); + // Event 23 - Verbose + public void ExportCertificateStart(string certificate, string path, bool includePrivateKey) => + _logger.LogDebug("Saving certificate '{Certificate}' to {Path} {PrivateKey} private key.", certificate, path, includePrivateKey ? "with" : "without"); - [Event(24, Level = EventLevel.Verbose, Message = "Exporting certificate with private key but no password.")] - public void NoPasswordForCertificate() => WriteEvent(24); + // Event 24 - Verbose + public void NoPasswordForCertificate() => + _logger.LogDebug("Exporting certificate with private key but no password."); - [Event(25, Level = EventLevel.Verbose, Message = "Creating directory {0}.")] - public void CreateExportCertificateDirectory(string path) => WriteEvent(25, path); + // Event 25 - Verbose + public void CreateExportCertificateDirectory(string path) => + _logger.LogDebug("Creating directory {Path}.", path); - [Event(26, Level = EventLevel.Error, Message = "An error has occurred while exporting the certificate: {0}.")] - public void ExportCertificateError(string error) => WriteEvent(26, error); + // Event 26 - Error + public void ExportCertificateError(string error) => + _logger.LogError("An error has occurred while exporting the certificate: {Error}.", error); - [Event(27, Level = EventLevel.Verbose, Message = "Writing the certificate to: {0}.")] - public void WriteCertificateToDisk(string path) => WriteEvent(27, path); + // Event 27 - Verbose + public void WriteCertificateToDisk(string path) => + _logger.LogDebug("Writing the certificate to: {Path}.", path); - [Event(28, Level = EventLevel.Error, Message = "An error has occurred while writing the certificate to disk: {0}.")] - public void WriteCertificateToDiskError(string error) => WriteEvent(28, error); + // Event 28 - Error + public void WriteCertificateToDiskError(string error) => + _logger.LogError("An error has occurred while writing the certificate to disk: {Error}.", error); - [Event(29, Level = EventLevel.Verbose, Message = "Trusting the certificate to: {0}.")] - public void TrustCertificateStart(string certificate) => WriteEvent(29, certificate); + // Event 29 - Verbose + public void TrustCertificateStart(string certificate) => + _logger.LogDebug("Trusting the certificate to: {Certificate}.", certificate); - [Event(30, Level = EventLevel.Verbose, Message = "Finished trusting the certificate.")] - public void TrustCertificateEnd() => WriteEvent(30); + // Event 30 - Verbose + public void TrustCertificateEnd() => + _logger.LogDebug("Finished trusting the certificate."); - [Event(31, Level = EventLevel.Error, Message = "An error has occurred while trusting the certificate: {0}.")] - public void TrustCertificateError(string error) => WriteEvent(31, error); + // Event 31 - Error + public void TrustCertificateError(string error) => + _logger.LogError("An error has occurred while trusting the certificate: {Error}.", error); - [Event(32, Level = EventLevel.Verbose, Message = "Running the trust command {0}.")] - public void MacOSTrustCommandStart(string command) => WriteEvent(32, command); + // Event 32 - Verbose + public void MacOSTrustCommandStart(string command) => + _logger.LogDebug("Running the trust command {Command}.", command); - [Event(33, Level = EventLevel.Verbose, Message = "Finished running the trust command.")] - public void MacOSTrustCommandEnd() => WriteEvent(33); + // Event 33 - Verbose + public void MacOSTrustCommandEnd() => + _logger.LogDebug("Finished running the trust command."); - [Event(34, Level = EventLevel.Warning, Message = "An error has occurred while running the trust command: {0}.")] - public void MacOSTrustCommandError(int exitCode) => WriteEvent(34, exitCode); + // Event 34 - Warning + public void MacOSTrustCommandError(int exitCode) => + _logger.LogWarning("An error has occurred while running the trust command: {ExitCode}.", exitCode); - [Event(35, Level = EventLevel.Verbose, Message = "Running the remove trust command for {0}.")] - public void MacOSRemoveCertificateTrustRuleStart(string certificate) => WriteEvent(35, certificate); + // Event 35 - Verbose + public void MacOSRemoveCertificateTrustRuleStart(string certificate) => + _logger.LogDebug("Running the remove trust command for {Certificate}.", certificate); - [Event(36, Level = EventLevel.Verbose, Message = "Finished running the remove trust command.")] - public void MacOSRemoveCertificateTrustRuleEnd() => WriteEvent(36); + // Event 36 - Verbose + public void MacOSRemoveCertificateTrustRuleEnd() => + _logger.LogDebug("Finished running the remove trust command."); - [Event(37, Level = EventLevel.Warning, Message = "An error has occurred while running the remove trust command: {0}.")] - public void MacOSRemoveCertificateTrustRuleError(int exitCode) => WriteEvent(37, exitCode); + // Event 37 - Warning + public void MacOSRemoveCertificateTrustRuleError(int exitCode) => + _logger.LogWarning("An error has occurred while running the remove trust command: {ExitCode}.", exitCode); - [Event(38, Level = EventLevel.Verbose, Message = "The certificate is not trusted: {0}.")] - public void MacOSCertificateUntrusted(string certificate) => WriteEvent(38, certificate); + // Event 38 - Verbose + public void MacOSCertificateUntrusted(string certificate) => + _logger.LogDebug("The certificate is not trusted: {Certificate}.", certificate); - [Event(39, Level = EventLevel.Verbose, Message = "Removing the certificate from the keychain {0} {1}.")] - public void MacOSRemoveCertificateFromKeyChainStart(string keyChain, string certificate) => WriteEvent(39, keyChain, certificate); + // Event 39 - Verbose + public void MacOSRemoveCertificateFromKeyChainStart(string keyChain, string certificate) => + _logger.LogDebug("Removing the certificate from the keychain {KeyChain} {Certificate}.", keyChain, certificate); - [Event(40, Level = EventLevel.Verbose, Message = "Finished removing the certificate from the keychain.")] - public void MacOSRemoveCertificateFromKeyChainEnd() => WriteEvent(40); + // Event 40 - Verbose + public void MacOSRemoveCertificateFromKeyChainEnd() => + _logger.LogDebug("Finished removing the certificate from the keychain."); - [Event(41, Level = EventLevel.Warning, Message = "An error has occurred while running the remove trust command: {0}.")] - public void MacOSRemoveCertificateFromKeyChainError(int exitCode) => WriteEvent(41, exitCode); + // Event 41 - Warning + public void MacOSRemoveCertificateFromKeyChainError(int exitCode) => + _logger.LogWarning("An error has occurred while running the remove trust command: {ExitCode}.", exitCode); - [Event(42, Level = EventLevel.Verbose, Message = "Removing the certificate from the user store {0}.")] - public void RemoveCertificateFromUserStoreStart(string certificate) => WriteEvent(42, certificate); + // Event 42 - Verbose + public void RemoveCertificateFromUserStoreStart(string certificate) => + _logger.LogDebug("Removing the certificate from the user store {Certificate}.", certificate); - [Event(43, Level = EventLevel.Verbose, Message = "Finished removing the certificate from the user store.")] - public void RemoveCertificateFromUserStoreEnd() => WriteEvent(43); + // Event 43 - Verbose + public void RemoveCertificateFromUserStoreEnd() => + _logger.LogDebug("Finished removing the certificate from the user store."); - [Event(44, Level = EventLevel.Error, Message = "An error has occurred while removing the certificate from the user store: {0}.")] - public void RemoveCertificateFromUserStoreError(string error) => WriteEvent(44, error); + // Event 44 - Error + public void RemoveCertificateFromUserStoreError(string error) => + _logger.LogError("An error has occurred while removing the certificate from the user store: {Error}.", error); - [Event(45, Level = EventLevel.Verbose, Message = "Adding certificate to the trusted root certification authority store.")] - public void WindowsAddCertificateToRootStore() => WriteEvent(45); + // Event 45 - Verbose + public void WindowsAddCertificateToRootStore() => + _logger.LogDebug("Adding certificate to the trusted root certification authority store."); - [Event(46, Level = EventLevel.Verbose, Message = "The certificate is already trusted.")] - public void WindowsCertificateAlreadyTrusted() => WriteEvent(46); + // Event 46 - Verbose + public void WindowsCertificateAlreadyTrusted() => + _logger.LogDebug("The certificate is already trusted."); - [Event(47, Level = EventLevel.Verbose, Message = "Trusting the certificate was cancelled by the user.")] - public void WindowsCertificateTrustCanceled() => WriteEvent(47); + // Event 47 - Verbose + public void WindowsCertificateTrustCanceled() => + _logger.LogDebug("Trusting the certificate was cancelled by the user."); - [Event(48, Level = EventLevel.Verbose, Message = "Removing the certificate from the trusted root certification authority store.")] - public void WindowsRemoveCertificateFromRootStoreStart() => WriteEvent(48); + // Event 48 - Verbose + public void WindowsRemoveCertificateFromRootStoreStart() => + _logger.LogDebug("Removing the certificate from the trusted root certification authority store."); - [Event(49, Level = EventLevel.Verbose, Message = "Finished removing the certificate from the trusted root certification authority store.")] - public void WindowsRemoveCertificateFromRootStoreEnd() => WriteEvent(49); + // Event 49 - Verbose + public void WindowsRemoveCertificateFromRootStoreEnd() => + _logger.LogDebug("Finished removing the certificate from the trusted root certification authority store."); - [Event(50, Level = EventLevel.Verbose, Message = "The certificate was not trusted.")] - public void WindowsRemoveCertificateFromRootStoreNotFound() => WriteEvent(50); + // Event 50 - Verbose + public void WindowsRemoveCertificateFromRootStoreNotFound() => + _logger.LogDebug("The certificate was not trusted."); - [Event(51, Level = EventLevel.Verbose, Message = "Correcting the the certificate state for '{0}'.")] - public void CorrectCertificateStateStart(string certificate) => WriteEvent(51, certificate); + // Event 51 - Verbose + public void CorrectCertificateStateStart(string certificate) => + _logger.LogDebug("Correcting the the certificate state for '{Certificate}'.", certificate); - [Event(52, Level = EventLevel.Verbose, Message = "Finished correcting the certificate state.")] - public void CorrectCertificateStateEnd() => WriteEvent(52); + // Event 52 - Verbose + public void CorrectCertificateStateEnd() => + _logger.LogDebug("Finished correcting the certificate state."); - [Event(53, Level = EventLevel.Error, Message = "An error has occurred while correcting the certificate state: {0}.")] - public void CorrectCertificateStateError(string error) => WriteEvent(53, error); + // Event 53 - Error + public void CorrectCertificateStateError(string error) => + _logger.LogError("An error has occurred while correcting the certificate state: {Error}.", error); - [Event(54, Level = EventLevel.Verbose, Message = "Importing the certificate {1} to the keychain '{0}'.")] - internal void MacOSAddCertificateToKeyChainStart(string keychain, string certificate) => WriteEvent(54, keychain, certificate); + // Event 54 - Verbose + internal void MacOSAddCertificateToKeyChainStart(string keychain, string certificate) => + _logger.LogDebug("Importing the certificate {Certificate} to the keychain '{Keychain}'.", certificate, keychain); - [Event(55, Level = EventLevel.Verbose, Message = "Finished importing the certificate to the keychain.")] - internal void MacOSAddCertificateToKeyChainEnd() => WriteEvent(55); + // Event 55 - Verbose + internal void MacOSAddCertificateToKeyChainEnd() => + _logger.LogDebug("Finished importing the certificate to the keychain."); - [Event(56, Level = EventLevel.Error, Message = "An error has occurred while importing the certificate to the keychain: {0}, {1}")] - internal void MacOSAddCertificateToKeyChainError(int exitCode, string output) => WriteEvent(56, exitCode, output); + // Event 56 - Error + internal void MacOSAddCertificateToKeyChainError(int exitCode, string output) => + _logger.LogError("An error has occurred while importing the certificate to the keychain: {ExitCode}, {Output}", exitCode, output); - [Event(57, Level = EventLevel.Verbose, Message = "Writing the certificate to: {0}.")] - public void WritePemKeyToDisk(string path) => WriteEvent(57, path); + // Event 57 - Verbose + public void WritePemKeyToDisk(string path) => + _logger.LogDebug("Writing the certificate to: {Path}.", path); - [Event(58, Level = EventLevel.Error, Message = "An error has occurred while writing the certificate to disk: {0}.")] - public void WritePemKeyToDiskError(string error) => WriteEvent(58, error); + // Event 58 - Error + public void WritePemKeyToDiskError(string error) => + _logger.LogError("An error has occurred while writing the certificate to disk: {Error}.", error); - [Event(59, Level = EventLevel.Error, Message = "The file '{0}' does not exist.")] - internal void ImportCertificateMissingFile(string certificatePath) => WriteEvent(59, certificatePath); + // Event 59 - Error + internal void ImportCertificateMissingFile(string certificatePath) => + _logger.LogError("The file '{CertificatePath}' does not exist.", certificatePath); - [Event(60, Level = EventLevel.Error, Message = "One or more HTTPS certificates exist '{0}'.")] - internal void ImportCertificateExistingCertificates(string certificateDescription) => WriteEvent(60, certificateDescription); + // Event 60 - Error + internal void ImportCertificateExistingCertificates(string certificateDescription) => + _logger.LogError("One or more HTTPS certificates exist '{CertificateDescription}'.", certificateDescription); - [Event(61, Level = EventLevel.Verbose, Message = "Loading certificate from path '{0}'.")] - internal void LoadCertificateStart(string certificatePath) => WriteEvent(61, certificatePath); + // Event 61 - Verbose + internal void LoadCertificateStart(string certificatePath) => + _logger.LogDebug("Loading certificate from path '{CertificatePath}'.", certificatePath); - [Event(62, Level = EventLevel.Verbose, Message = "The certificate '{0}' has been loaded successfully.")] - internal void LoadCertificateEnd(string description) => WriteEvent(62, description); + // Event 62 - Verbose + internal void LoadCertificateEnd(string description) => + _logger.LogDebug("The certificate '{Description}' has been loaded successfully.", description); - [Event(63, Level = EventLevel.Error, Message = "An error has occurred while loading the certificate from disk: {0}.")] - internal void LoadCertificateError(string error) => WriteEvent(63, error); + // Event 63 - Error + internal void LoadCertificateError(string error) => + _logger.LogError("An error has occurred while loading the certificate from disk: {Error}.", error); - [Event(64, Level = EventLevel.Error, Message = "The provided certificate '{0}' is not a valid ASP.NET Core HTTPS development certificate.")] - internal void NoHttpsDevelopmentCertificate(string description) => WriteEvent(64, description); + // Event 64 - Error + internal void NoHttpsDevelopmentCertificate(string description) => + _logger.LogError("The provided certificate '{Description}' is not a valid ASP.NET Core HTTPS development certificate.", description); - [Event(65, Level = EventLevel.Verbose, Message = "The certificate is already trusted.")] - public void MacOSCertificateAlreadyTrusted() => WriteEvent(65); + // Event 65 - Verbose + public void MacOSCertificateAlreadyTrusted() => + _logger.LogDebug("The certificate is already trusted."); - [Event(66, Level = EventLevel.Verbose, Message = "Saving the certificate {1} to the user profile folder '{0}'.")] - internal void MacOSAddCertificateToUserProfileDirStart(string directory, string certificate) => WriteEvent(66, directory, certificate); + // Event 66 - Verbose + internal void MacOSAddCertificateToUserProfileDirStart(string directory, string certificate) => + _logger.LogDebug("Saving the certificate {Certificate} to the user profile folder '{Directory}'.", certificate, directory); - [Event(67, Level = EventLevel.Verbose, Message = "Finished saving the certificate to the user profile folder.")] - internal void MacOSAddCertificateToUserProfileDirEnd() => WriteEvent(67); + // Event 67 - Verbose + internal void MacOSAddCertificateToUserProfileDirEnd() => + _logger.LogDebug("Finished saving the certificate to the user profile folder."); - [Event(68, Level = EventLevel.Error, Message = "An error has occurred while saving certificate '{0}' in the user profile folder: {1}.")] - internal void MacOSAddCertificateToUserProfileDirError(string certificateThumbprint, string errorMessage) => WriteEvent(68, certificateThumbprint, errorMessage); + // Event 68 - Error + internal void MacOSAddCertificateToUserProfileDirError(string certificateThumbprint, string errorMessage) => + _logger.LogError("An error has occurred while saving certificate '{CertificateThumbprint}' in the user profile folder: {ErrorMessage}.", certificateThumbprint, errorMessage); - [Event(69, Level = EventLevel.Error, Message = "An error has occurred while removing certificate '{0}' from the user profile folder: {1}.")] - internal void MacOSRemoveCertificateFromUserProfileDirError(string certificateThumbprint, string errorMessage) => WriteEvent(69, certificateThumbprint, errorMessage); + // Event 69 - Error + internal void MacOSRemoveCertificateFromUserProfileDirError(string certificateThumbprint, string errorMessage) => + _logger.LogError("An error has occurred while removing certificate '{CertificateThumbprint}' from the user profile folder: {ErrorMessage}.", certificateThumbprint, errorMessage); - [Event(70, Level = EventLevel.Error, Message = "The file '{0}' is not a valid certificate.")] - internal void MacOSFileIsNotAValidCertificate(string path) => WriteEvent(70, path); + // Event 70 - Error + internal void MacOSFileIsNotAValidCertificate(string path) => + _logger.LogError("The file '{Path}' is not a valid certificate.", path); - [Event(71, Level = EventLevel.Warning, Message = "The on-disk store directory was not found.")] - internal void MacOSDiskStoreDoesNotExist() => WriteEvent(71); + // Event 71 - Warning + internal void MacOSDiskStoreDoesNotExist() => + _logger.LogWarning("The on-disk store directory was not found."); - [Event(72, Level = EventLevel.Verbose, Message = "Reading OpenSSL trusted certificates location from {0}.")] - internal void UnixOpenSslCertificateDirectoryOverridePresent(string nssDbOverrideVariableName) => WriteEvent(72, nssDbOverrideVariableName); + // Event 72 - Verbose + internal void UnixOpenSslCertificateDirectoryOverridePresent(string nssDbOverrideVariableName) => + _logger.LogDebug("Reading OpenSSL trusted certificates location from {NssDbOverrideVariableName}.", nssDbOverrideVariableName); - [Event(73, Level = EventLevel.Verbose, Message = "Reading NSS database locations from {0}.")] - internal void UnixNssDbOverridePresent(string environmentVariable) => WriteEvent(73, environmentVariable); + // Event 73 - Verbose + internal void UnixNssDbOverridePresent(string environmentVariable) => + _logger.LogDebug("Reading NSS database locations from {EnvironmentVariable}.", environmentVariable); - // Recoverable - just don't use it. - [Event(74, Level = EventLevel.Warning, Message = "The NSS database '{0}' provided via {1} does not exist.")] - internal void UnixNssDbDoesNotExist(string nssDb, string environmentVariable) => WriteEvent(74, nssDb, environmentVariable); + // Event 74 - Warning + internal void UnixNssDbDoesNotExist(string nssDb, string environmentVariable) => + _logger.LogWarning("The NSS database '{NssDb}' provided via {EnvironmentVariable} does not exist.", nssDb, environmentVariable); - [Event(75, Level = EventLevel.Warning, Message = "The certificate is not trusted by .NET. This will likely affect System.Net.Http.HttpClient.")] - internal void UnixNotTrustedByDotnet() => WriteEvent(75); + // Event 75 - Warning + internal void UnixNotTrustedByDotnet() => + _logger.LogWarning("The certificate is not trusted by .NET. This will likely affect System.Net.Http.HttpClient."); - [Event(76, Level = EventLevel.Warning, Message = "The certificate is not trusted by OpenSSL. Ensure that the {0} environment variable is set correctly.")] - internal void UnixNotTrustedByOpenSsl(string envVarName) => WriteEvent(76, envVarName); + // Event 76 - Warning + internal void UnixNotTrustedByOpenSsl(string envVarName) => + _logger.LogWarning("The certificate is not trusted by OpenSSL. Ensure that the {EnvVarName} environment variable is set correctly.", envVarName); - [Event(77, Level = EventLevel.Warning, Message = "The certificate is not trusted in the NSS database in '{0}'. This will likely affect the {1} family of browsers.")] - internal void UnixNotTrustedByNss(string path, string browser) => WriteEvent(77, path, browser); + // Event 77 - Warning + internal void UnixNotTrustedByNss(string path, string browser) => + _logger.LogWarning("The certificate is not trusted in the NSS database in '{Path}'. This will likely affect the {Browser} family of browsers.", path, browser); - // If there's no home directory, there are no NSS DBs to check (barring an override), but this isn't strictly a problem. - [Event(78, Level = EventLevel.Verbose, Message = "Home directory '{0}' does not exist. Unable to discover NSS databases for user '{1}'. This will likely affect browsers.")] - internal void UnixHomeDirectoryDoesNotExist(string homeDirectory, string username) => WriteEvent(78, homeDirectory, username); + // Event 78 - Verbose + internal void UnixHomeDirectoryDoesNotExist(string homeDirectory, string username) => + _logger.LogDebug("Home directory '{HomeDirectory}' does not exist. Unable to discover NSS databases for user '{Username}'. This will likely affect browsers.", homeDirectory, username); - // Checking the system-wide OpenSSL directory is only used to make output more helpful - don't warn if it fails. - [Event(79, Level = EventLevel.Verbose, Message = "OpenSSL reported its directory in an unexpected format.")] - internal void UnixOpenSslVersionParsingFailed() => WriteEvent(79); + // Event 79 - Verbose + internal void UnixOpenSslVersionParsingFailed() => + _logger.LogDebug("OpenSSL reported its directory in an unexpected format."); - // Checking the system-wide OpenSSL directory is only used to make output more helpful - don't warn if it fails. - [Event(80, Level = EventLevel.Verbose, Message = "Unable to determine the OpenSSL directory.")] - internal void UnixOpenSslVersionFailed() => WriteEvent(80); + // Event 80 - Verbose + internal void UnixOpenSslVersionFailed() => + _logger.LogDebug("Unable to determine the OpenSSL directory."); - // Checking the system-wide OpenSSL directory is only used to make output more helpful - don't warn if it fails. - [Event(81, Level = EventLevel.Verbose, Message = "Unable to determine the OpenSSL directory: {0}.")] - internal void UnixOpenSslVersionException(string exceptionMessage) => WriteEvent(81, exceptionMessage); + // Event 81 - Verbose + internal void UnixOpenSslVersionException(string exceptionMessage) => + _logger.LogDebug("Unable to determine the OpenSSL directory: {ExceptionMessage}.", exceptionMessage); - // We'll continue on to NSS DB, but leaving the OpenSSL hash files in a bad state is a real problem. - [Event(82, Level = EventLevel.Error, Message = "Unable to compute the hash of certificate {0}. OpenSSL trust is likely in an inconsistent state.")] - internal void UnixOpenSslHashFailed(string certificatePath) => WriteEvent(82, certificatePath); + // Event 82 - Error + internal void UnixOpenSslHashFailed(string certificatePath) => + _logger.LogError("Unable to compute the hash of certificate {CertificatePath}. OpenSSL trust is likely in an inconsistent state.", certificatePath); - // We'll continue on to NSS DB, but leaving the OpenSSL hash files in a bad state is a real problem. - [Event(83, Level = EventLevel.Error, Message = "Unable to compute the certificate hash: {0}. OpenSSL trust is likely in an inconsistent state.")] - internal void UnixOpenSslHashException(string certificatePath, string exceptionMessage) => WriteEvent(83, certificatePath, exceptionMessage); + // Event 83 - Error + internal void UnixOpenSslHashException(string certificatePath, string exceptionMessage) => + _logger.LogError("Unable to compute the certificate hash: {CertificatePath}. OpenSSL trust is likely in an inconsistent state. {ExceptionMessage}", certificatePath, exceptionMessage); - // We'll continue on to NSS DB, but leaving the OpenSSL hash files in a bad state is a real problem. - [Event(84, Level = EventLevel.Error, Message = "Unable to update certificate '{0}' in the OpenSSL trusted certificate hash collection - {2} certificates have the hash {1}.")] - internal void UnixOpenSslRehashTooManyHashes(string fullName, string hash, int maxHashCollisions) => WriteEvent(84, fullName, hash, maxHashCollisions); + // Event 84 - Error + internal void UnixOpenSslRehashTooManyHashes(string fullName, string hash, int maxHashCollisions) => + _logger.LogError("Unable to update certificate '{FullName}' in the OpenSSL trusted certificate hash collection - {MaxHashCollisions} certificates have the hash {Hash}.", fullName, maxHashCollisions, hash); - // We'll continue on to NSS DB, but leaving the OpenSSL hash files in a bad state is a real problem. - [Event(85, Level = EventLevel.Error, Message = "Unable to update the OpenSSL trusted certificate hash collection: {0}. " + - "Manually rehashing may help. See https://aka.ms/dev-certs-trust for more information.")] // This should recommend manually running c_rehash. - internal void UnixOpenSslRehashException(string exceptionMessage) => WriteEvent(85, exceptionMessage); + // Event 85 - Error + internal void UnixOpenSslRehashException(string exceptionMessage) => + _logger.LogError("Unable to update the OpenSSL trusted certificate hash collection: {ExceptionMessage}. Manually rehashing may help. See https://aka.ms/dev-certs-trust for more information.", exceptionMessage); - [Event(86, Level = EventLevel.Warning, Message = "Failed to trust the certificate in .NET: {0}.")] - internal void UnixDotnetTrustException(string exceptionMessage) => WriteEvent(86, exceptionMessage); + // Event 86 - Warning + internal void UnixDotnetTrustException(string exceptionMessage) => + _logger.LogWarning("Failed to trust the certificate in .NET: {ExceptionMessage}.", exceptionMessage); - [Event(87, Level = EventLevel.Verbose, Message = "Trusted the certificate in .NET.")] - internal void UnixDotnetTrustSucceeded() => WriteEvent(87); + // Event 87 - Verbose + internal void UnixDotnetTrustSucceeded() => + _logger.LogDebug("Trusted the certificate in .NET."); - [Event(88, Level = EventLevel.Warning, Message = "Clients that validate certificate trust using OpenSSL will not trust the certificate.")] - internal void UnixOpenSslTrustFailed() => WriteEvent(88); + // Event 88 - Warning + internal void UnixOpenSslTrustFailed() => + _logger.LogWarning("Clients that validate certificate trust using OpenSSL will not trust the certificate."); - [Event(89, Level = EventLevel.Verbose, Message = "Trusted the certificate in OpenSSL.")] - internal void UnixOpenSslTrustSucceeded() => WriteEvent(89); + // Event 89 - Verbose + internal void UnixOpenSslTrustSucceeded() => + _logger.LogDebug("Trusted the certificate in OpenSSL."); - [Event(90, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers.")] - internal void UnixNssDbTrustFailed(string path, string browser) => WriteEvent(90, path, browser); + // Event 90 - Warning + internal void UnixNssDbTrustFailed(string path, string browser) => + _logger.LogWarning("Failed to trust the certificate in the NSS database in '{Path}'. This will likely affect the {Browser} family of browsers.", path, browser); - [Event(91, Level = EventLevel.Verbose, Message = "Trusted the certificate in the NSS database in '{0}'.")] - internal void UnixNssDbTrustSucceeded(string path) => WriteEvent(91, path); + // Event 91 - Verbose + internal void UnixNssDbTrustSucceeded(string path) => + _logger.LogDebug("Trusted the certificate in the NSS database in '{Path}'.", path); - [Event(92, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in .NET: {0}.")] - internal void UnixDotnetUntrustException(string exceptionMessage) => WriteEvent(92, exceptionMessage); + // Event 92 - Warning + internal void UnixDotnetUntrustException(string exceptionMessage) => + _logger.LogWarning("Failed to untrust the certificate in .NET: {ExceptionMessage}.", exceptionMessage); - [Event(93, Level = EventLevel.Warning, Message = "Failed to untrust the certificate in OpenSSL.")] - internal void UnixOpenSslUntrustFailed() => WriteEvent(93); + // Event 93 - Warning + internal void UnixOpenSslUntrustFailed() => + _logger.LogWarning("Failed to untrust the certificate in OpenSSL."); - [Event(94, Level = EventLevel.Verbose, Message = "Untrusted the certificate in OpenSSL.")] - internal void UnixOpenSslUntrustSucceeded() => WriteEvent(94); + // Event 94 - Verbose + internal void UnixOpenSslUntrustSucceeded() => + _logger.LogDebug("Untrusted the certificate in OpenSSL."); - [Event(95, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}'.")] - internal void UnixNssDbUntrustFailed(string path) => WriteEvent(95, path); + // Event 95 - Warning + internal void UnixNssDbUntrustFailed(string path) => + _logger.LogWarning("Failed to remove the certificate from the NSS database in '{Path}'.", path); - [Event(96, Level = EventLevel.Verbose, Message = "Removed the certificate from the NSS database in '{0}'.")] - internal void UnixNssDbUntrustSucceeded(string path) => WriteEvent(96, path); + // Event 96 - Verbose + internal void UnixNssDbUntrustSucceeded(string path) => + _logger.LogDebug("Removed the certificate from the NSS database in '{Path}'.", path); - [Event(97, Level = EventLevel.Warning, Message = "The certificate is only partially trusted - some clients will not accept it.")] - internal void UnixTrustPartiallySucceeded() => WriteEvent(97); + // Event 97 - Warning + internal void UnixTrustPartiallySucceeded() => + _logger.LogWarning("The certificate is only partially trusted - some clients will not accept it."); - [Event(98, Level = EventLevel.Warning, Message = "Failed to look up the certificate in the NSS database in '{0}': {1}.")] - internal void UnixNssDbCheckException(string path, string exceptionMessage) => WriteEvent(98, path, exceptionMessage); + // Event 98 - Warning + internal void UnixNssDbCheckException(string path, string exceptionMessage) => + _logger.LogWarning("Failed to look up the certificate in the NSS database in '{Path}': {ExceptionMessage}.", path, exceptionMessage); - [Event(99, Level = EventLevel.Warning, Message = "Failed to add the certificate to the NSS database in '{0}': {1}.")] - internal void UnixNssDbAdditionException(string path, string exceptionMessage) => WriteEvent(99, path, exceptionMessage); + // Event 99 - Warning + internal void UnixNssDbAdditionException(string path, string exceptionMessage) => + _logger.LogWarning("Failed to add the certificate to the NSS database in '{Path}': {ExceptionMessage}.", path, exceptionMessage); - [Event(100, Level = EventLevel.Warning, Message = "Failed to remove the certificate from the NSS database in '{0}': {1}.")] - internal void UnixNssDbRemovalException(string path, string exceptionMessage) => WriteEvent(100, path, exceptionMessage); + // Event 100 - Warning + internal void UnixNssDbRemovalException(string path, string exceptionMessage) => + _logger.LogWarning("Failed to remove the certificate from the NSS database in '{Path}': {ExceptionMessage}.", path, exceptionMessage); - [Event(101, Level = EventLevel.Warning, Message = "Failed to find the Firefox profiles in directory '{0}': {1}.")] - internal void UnixFirefoxProfileEnumerationException(string firefoxDirectory, string message) => WriteEvent(101, firefoxDirectory, message); + // Event 101 - Warning + internal void UnixFirefoxProfileEnumerationException(string firefoxDirectory, string message) => + _logger.LogWarning("Failed to find the Firefox profiles in directory '{FirefoxDirectory}': {Message}.", firefoxDirectory, message); - [Event(102, Level = EventLevel.Verbose, Message = "No Firefox profiles found in directory '{0}'.")] - internal void UnixNoFirefoxProfilesFound(string firefoxDirectory) => WriteEvent(102, firefoxDirectory); + // Event 102 - Verbose + internal void UnixNoFirefoxProfilesFound(string firefoxDirectory) => + _logger.LogDebug("No Firefox profiles found in directory '{FirefoxDirectory}'.", firefoxDirectory); - [Event(103, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the NSS database in '{0}'. This will likely affect the {1} family of browsers. " + - "This likely indicates that the database already contains an entry for the certificate under a different name. Please remove it and try again.")] - internal void UnixNssDbTrustFailedWithProbableConflict(string path, string browser) => WriteEvent(103, path, browser); + // Event 103 - Warning + internal void UnixNssDbTrustFailedWithProbableConflict(string path, string browser) => + _logger.LogWarning("Failed to trust the certificate in the NSS database in '{Path}'. This will likely affect the {Browser} family of browsers. This likely indicates that the database already contains an entry for the certificate under a different name. Please remove it and try again.", path, browser); - // This may be annoying, since anyone setting the variable for un/trust will likely leave it set for --check. - // However, it seems important to warn users who set it specifically for --check. - [Event(104, Level = EventLevel.Warning, Message = "The {0} environment variable is set but will not be consumed while checking trust.")] - internal void UnixOpenSslCertificateDirectoryOverrideIgnored(string openSslCertDirectoryOverrideVariableName) => WriteEvent(104, openSslCertDirectoryOverrideVariableName); + // Event 104 - Warning + internal void UnixOpenSslCertificateDirectoryOverrideIgnored(string openSslCertDirectoryOverrideVariableName) => + _logger.LogWarning("The {OpenSslCertDirectoryOverrideVariableName} environment variable is set but will not be consumed while checking trust.", openSslCertDirectoryOverrideVariableName); - [Event(105, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for updating certificate trust in OpenSSL.")] - internal void UnixMissingOpenSslCommand(string openSslCommand) => WriteEvent(105, openSslCommand); + // Event 105 - Warning + internal void UnixMissingOpenSslCommand(string openSslCommand) => + _logger.LogWarning("The {OpenSslCommand} command is unavailable. It is required for updating certificate trust in OpenSSL.", openSslCommand); - [Event(106, Level = EventLevel.Warning, Message = "The {0} command is unavailable. It is required for querying and updating NSS databases, which are chiefly used to trust certificates in browsers.")] - internal void UnixMissingCertUtilCommand(string certUtilCommand) => WriteEvent(106, certUtilCommand); + // Event 106 - Warning + internal void UnixMissingCertUtilCommand(string certUtilCommand) => + _logger.LogWarning("The {CertUtilCommand} command is unavailable. It is required for querying and updating NSS databases, which are chiefly used to trust certificates in browsers.", certUtilCommand); - [Event(107, Level = EventLevel.Verbose, Message = "Untrusting the certificate in OpenSSL was skipped since '{0}' does not exist.")] - internal void UnixOpenSslUntrustSkipped(string certPath) => WriteEvent(107, certPath); + // Event 107 - Verbose + internal void UnixOpenSslUntrustSkipped(string certPath) => + _logger.LogDebug("Untrusting the certificate in OpenSSL was skipped since '{CertPath}' does not exist.", certPath); - [Event(108, Level = EventLevel.Warning, Message = "Failed to delete certificate file '{0}': {1}.")] - internal void UnixCertificateFileDeletionException(string certPath, string exceptionMessage) => WriteEvent(108, certPath, exceptionMessage); + // Event 108 - Warning + internal void UnixCertificateFileDeletionException(string certPath, string exceptionMessage) => + _logger.LogWarning("Failed to delete certificate file '{CertPath}': {ExceptionMessage}.", certPath, exceptionMessage); - [Event(109, Level = EventLevel.Error, Message = "Unable to export the certificate since '{0}' already exists. Please remove it.")] - internal void UnixNotOverwritingCertificate(string certPath) => WriteEvent(109, certPath); + // Event 109 - Error + internal void UnixNotOverwritingCertificate(string certPath) => + _logger.LogError("Unable to export the certificate since '{CertPath}' already exists. Please remove it.", certPath); - [Event(110, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + - "For example, `export {2}=\"{0}:{1}\"`. " + - "See https://aka.ms/dev-certs-trust for more information.")] - internal void UnixSuggestSettingEnvironmentVariable(string certDir, string openSslDir, string envVarName) => WriteEvent(110, certDir, openSslDir, envVarName); + // Event 110 - LogAlways (Info) + internal void UnixSuggestSettingEnvironmentVariable(string certDir, string openSslDir, string envVarName) => + _logger.LogInformation("For OpenSSL trust to take effect, '{CertDir}' must be listed in the {EnvVarName} environment variable. For example, `export {EnvVarName}=\"{CertDir}:{OpenSslDir}\"`. See https://aka.ms/dev-certs-trust for more information.", certDir, envVarName, envVarName, certDir, openSslDir); - [Event(111, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {2} environment variable. " + - "See https://aka.ms/dev-certs-trust for more information.")] - internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => WriteEvent(111, certDir, envVarName); + // Event 111 - LogAlways (Info) + internal void UnixSuggestSettingEnvironmentVariableWithoutExample(string certDir, string envVarName) => + _logger.LogInformation("For OpenSSL trust to take effect, '{CertDir}' must be listed in the {EnvVarName} environment variable. See https://aka.ms/dev-certs-trust for more information.", certDir, envVarName); - [Event(112, Level = EventLevel.Warning, Message = "Directory '{0}' may be readable by other users.")] - internal void DirectoryPermissionsNotSecure(string directoryPath) => WriteEvent(112, directoryPath); + // Event 112 - Warning + internal void DirectoryPermissionsNotSecure(string directoryPath) => + _logger.LogWarning("Directory '{DirectoryPath}' may be readable by other users.", directoryPath); - [Event(113, Level = EventLevel.Verbose, Message = "The certificate directory '{0}' is already included in the {1} environment variable.")] - internal void UnixOpenSslCertificateDirectoryAlreadyConfigured(string certDir, string envVarName) => WriteEvent(113, certDir, envVarName); + // Event 113 - Verbose + internal void UnixOpenSslCertificateDirectoryAlreadyConfigured(string certDir, string envVarName) => + _logger.LogDebug("The certificate directory '{CertDir}' is already included in the {EnvVarName} environment variable.", certDir, envVarName); - [Event(114, Level = EventLevel.LogAlways, Message = "For OpenSSL trust to take effect, '{0}' must be listed in the {1} environment variable. " + - "For example, `export {1}=\"{0}:${1}\"`. " + - "See https://aka.ms/dev-certs-trust for more information.")] - internal void UnixSuggestAppendingToEnvironmentVariable(string certDir, string envVarName) => WriteEvent(114, certDir, envVarName); + // Event 114 - LogAlways (Info) + internal void UnixSuggestAppendingToEnvironmentVariable(string certDir, string envVarName) => + _logger.LogInformation("For OpenSSL trust to take effect, '{CertDir}' must be listed in the {EnvVarName} environment variable. For example, `export {EnvVarName}=\"{CertDir}:${EnvVarName}\"`. See https://aka.ms/dev-certs-trust for more information.", certDir, envVarName, envVarName, certDir, envVarName); - [Event(115, Level = EventLevel.Verbose, Message = "Successfully trusted the certificate in the Windows certificate store via WSL.")] - internal void WslWindowsTrustSucceeded() => WriteEvent(115); + // Event 115 - Verbose + internal void WslWindowsTrustSucceeded() => + _logger.LogDebug("Successfully trusted the certificate in the Windows certificate store via WSL."); - [Event(116, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the Windows certificate store via WSL.")] - internal void WslWindowsTrustFailed() => WriteEvent(116); + // Event 116 - Warning + internal void WslWindowsTrustFailed() => + _logger.LogWarning("Failed to trust the certificate in the Windows certificate store via WSL."); - [Event(117, Level = EventLevel.Warning, Message = "Failed to trust the certificate in the Windows certificate store via WSL: {0}.")] - internal void WslWindowsTrustException(string exceptionMessage) => WriteEvent(117, exceptionMessage); + // Event 117 - Warning + internal void WslWindowsTrustException(string exceptionMessage) => + _logger.LogWarning("Failed to trust the certificate in the Windows certificate store via WSL: {ExceptionMessage}.", exceptionMessage); - [Event(118, Level = EventLevel.Verbose, Message = "Meets minimum version certificates: {0}")] - public void DescribeMinimumVersionCertificates(string meetsMinimumVersionCertificates) => WriteEvent(118, meetsMinimumVersionCertificates); + // Event 118 - Verbose + public void DescribeMinimumVersionCertificates(string meetsMinimumVersionCertificates) => + _logger.LogDebug("Meets minimum version certificates: {MeetsMinimumVersionCertificates}", meetsMinimumVersionCertificates); - [Event(119, Level = EventLevel.Verbose, Message = "Below minimum version certificates: {0}")] - public void DescribeBelowMinimumVersionCertificates(string belowMinimumVersionCertificates) => WriteEvent(119, belowMinimumVersionCertificates); + // Event 119 - Verbose + public void DescribeBelowMinimumVersionCertificates(string belowMinimumVersionCertificates) => + _logger.LogDebug("Below minimum version certificates: {BelowMinimumVersionCertificates}", belowMinimumVersionCertificates); } internal sealed class UserCancelledTrustException : Exception diff --git a/src/Aspire.Managed/CertificateGeneration/CertificatePurpose.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificatePurpose.cs similarity index 100% rename from src/Aspire.Managed/CertificateGeneration/CertificatePurpose.cs rename to src/Aspire.Cli/Certificates/CertificateGeneration/CertificatePurpose.cs diff --git a/src/Aspire.Managed/CertificateGeneration/EnsureCertificateResult.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/EnsureCertificateResult.cs similarity index 100% rename from src/Aspire.Managed/CertificateGeneration/EnsureCertificateResult.cs rename to src/Aspire.Cli/Certificates/CertificateGeneration/EnsureCertificateResult.cs diff --git a/src/Aspire.Managed/CertificateGeneration/ImportCertificateResult.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/ImportCertificateResult.cs similarity index 100% rename from src/Aspire.Managed/CertificateGeneration/ImportCertificateResult.cs rename to src/Aspire.Cli/Certificates/CertificateGeneration/ImportCertificateResult.cs diff --git a/src/Aspire.Managed/CertificateGeneration/MacOSCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs similarity index 100% rename from src/Aspire.Managed/CertificateGeneration/MacOSCertificateManager.cs rename to src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs diff --git a/src/Aspire.Managed/CertificateGeneration/UnixCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs similarity index 100% rename from src/Aspire.Managed/CertificateGeneration/UnixCertificateManager.cs rename to src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs diff --git a/src/Aspire.Managed/CertificateGeneration/WindowsCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs similarity index 100% rename from src/Aspire.Managed/CertificateGeneration/WindowsCertificateManager.cs rename to src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs diff --git a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs new file mode 100644 index 00000000000..4f6a60175b7 --- /dev/null +++ b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs @@ -0,0 +1,100 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Cryptography.X509Certificates; +using Aspire.Cli.DotNet; +using Microsoft.AspNetCore.Certificates.Generation; +using Microsoft.Extensions.Logging; + +namespace Aspire.Cli.Certificates; + +/// +/// Certificate tool runner that uses the native CertificateManager directly (no subprocess needed). +/// +internal sealed class NativeCertificateToolRunner(ILogger logger) : ICertificateToolRunner +{ + public Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( + DotNetCliRunnerInvocationOptions options, + CancellationToken cancellationToken) + { + CertificateManager.Log = new CertificateManager.CertificateManagerLogger(logger); + + var availableCertificates = CertificateManager.Instance.ListCertificates( + StoreName.My, StoreLocation.CurrentUser, isValid: true); + + var now = DateTimeOffset.Now; + var certInfos = availableCertificates.Select(cert => + { + var mgr = CertificateManager.Instance; + var status = mgr.CheckCertificateState(cert); + var trustLevel = status.Success ? mgr.GetTrustLevel(cert).ToString() : "Invalid"; + + return new DevCertInfo + { + Thumbprint = cert.Thumbprint, + Subject = cert.Subject, + SubjectAlternativeNames = GetSanExtension(cert), + Version = CertificateManager.GetCertificateVersion(cert), + ValidityNotBefore = cert.NotBefore, + ValidityNotAfter = cert.NotAfter, + IsHttpsDevelopmentCertificate = CertificateManager.IsHttpsDevelopmentCertificate(cert), + IsExportable = mgr.IsExportable(cert), + TrustLevel = trustLevel + }; + }).ToList(); + + var validCerts = certInfos + .Where(c => c.IsHttpsDevelopmentCertificate && c.ValidityNotBefore <= now && now <= c.ValidityNotAfter) + .OrderByDescending(c => c.Version) + .ToList(); + + var highestVersionedCert = validCerts.FirstOrDefault(); + + var result = new CertificateTrustResult + { + HasCertificates = validCerts.Count > 0, + TrustLevel = highestVersionedCert?.TrustLevel, + Certificates = certInfos + }; + + return Task.FromResult((0, (CertificateTrustResult?)result)); + } + + public Task TrustHttpCertificateAsync( + DotNetCliRunnerInvocationOptions options, + CancellationToken cancellationToken) + { + CertificateManager.Log = new CertificateManager.CertificateManagerLogger(logger); + + var now = DateTimeOffset.Now; + var result = CertificateManager.Instance.EnsureAspNetCoreHttpsDevelopmentCertificate( + now, now.Add(TimeSpan.FromDays(365)), + trust: true); + + return Task.FromResult(result switch + { + EnsureCertificateResult.Succeeded or + EnsureCertificateResult.ValidCertificatePresent or + EnsureCertificateResult.ExistingHttpsCertificateTrusted or + EnsureCertificateResult.NewHttpsCertificateTrusted => 0, + EnsureCertificateResult.UserCancelledTrustStep => 5, + _ => 4 // ErrorTrustingTheCertificate + }); + } + + private static string[]? GetSanExtension(X509Certificate2 cert) + { + var dnsNames = new List(); + foreach (var extension in cert.Extensions) + { + if (extension is X509SubjectAlternativeNameExtension sanExtension) + { + foreach (var dns in sanExtension.EnumerateDnsNames()) + { + dnsNames.Add(dns); + } + } + } + return dnsNames.Count > 0 ? dnsNames.ToArray() : null; + } +} diff --git a/src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs deleted file mode 100644 index 922a7eaef5b..00000000000 --- a/src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs +++ /dev/null @@ -1,155 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Diagnostics; -using System.Text; -using System.Text.Json; -using Aspire.Cli.DotNet; -using Microsoft.Extensions.Logging; - -namespace Aspire.Cli.Certificates; - -/// -/// Certificate tool runner that uses the global dotnet SDK's dev-certs command. -/// -internal sealed class SdkCertificateToolRunner(ILogger logger) : ICertificateToolRunner -{ - public async Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( - DotNetCliRunnerInvocationOptions options, - CancellationToken cancellationToken) - { - var outputBuilder = new StringBuilder(); - - var startInfo = new ProcessStartInfo("dotnet") - { - WorkingDirectory = Environment.CurrentDirectory, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - startInfo.ArgumentList.Add("dev-certs"); - startInfo.ArgumentList.Add("https"); - startInfo.ArgumentList.Add("--check-trust-machine-readable"); - - using var process = new Process { StartInfo = startInfo }; - - process.OutputDataReceived += (sender, e) => - { - if (e.Data is not null) - { - outputBuilder.AppendLine(e.Data); - options.StandardOutputCallback?.Invoke(e.Data); - } - }; - - process.ErrorDataReceived += (sender, e) => - { - if (e.Data is not null) - { - options.StandardErrorCallback?.Invoke(e.Data); - } - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - await process.WaitForExitAsync(cancellationToken); - - var exitCode = process.ExitCode; - - // Parse the JSON output - try - { - var jsonOutput = outputBuilder.ToString().Trim(); - if (string.IsNullOrEmpty(jsonOutput)) - { - return (exitCode, new CertificateTrustResult - { - HasCertificates = false, - TrustLevel = null, - Certificates = [] - }); - } - - var certificates = JsonSerializer.Deserialize(jsonOutput, Aspire.Cli.JsonSourceGenerationContext.Default.ListDevCertInfo); - if (certificates is null || certificates.Count == 0) - { - return (exitCode, new CertificateTrustResult - { - HasCertificates = false, - TrustLevel = null, - Certificates = [] - }); - } - - // Find the highest versioned valid certificate - var now = DateTimeOffset.Now; - var validCertificates = certificates - .Where(c => c.IsHttpsDevelopmentCertificate && c.ValidityNotBefore <= now && now <= c.ValidityNotAfter) - .OrderByDescending(c => c.Version) - .ToList(); - - var highestVersionedCert = validCertificates.FirstOrDefault(); - var trustLevel = highestVersionedCert?.TrustLevel; - - return (exitCode, new CertificateTrustResult - { - HasCertificates = validCertificates.Count > 0, - TrustLevel = trustLevel, - Certificates = certificates - }); - } - catch (JsonException ex) - { - logger.LogDebug(ex, "Failed to parse dev-certs machine-readable output"); - return (exitCode, null); - } - } - - public async Task TrustHttpCertificateAsync( - DotNetCliRunnerInvocationOptions options, - CancellationToken cancellationToken) - { - var startInfo = new ProcessStartInfo("dotnet") - { - WorkingDirectory = Environment.CurrentDirectory, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - startInfo.ArgumentList.Add("dev-certs"); - startInfo.ArgumentList.Add("https"); - startInfo.ArgumentList.Add("--trust"); - - using var process = new Process { StartInfo = startInfo }; - - process.OutputDataReceived += (sender, e) => - { - if (e.Data is not null) - { - options.StandardOutputCallback?.Invoke(e.Data); - } - }; - - process.ErrorDataReceived += (sender, e) => - { - if (e.Data is not null) - { - options.StandardErrorCallback?.Invoke(e.Data); - } - }; - - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - await process.WaitForExitAsync(cancellationToken); - - return process.ExitCode; - } -} diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index ed1ef2f2206..8107da63f6b 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -222,18 +222,11 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTelemetryServices(); builder.Services.AddTransient(); - // Register certificate tool runner - bundle uses aspire-managed dev-certs, SDK mode uses global dotnet + // Register certificate tool runner - uses native CertificateManager directly (no subprocess needed) builder.Services.AddSingleton(sp => { var loggerFactory = sp.GetRequiredService(); - if (sp.GetRequiredService().IsBundle) - { - return new BundleCertificateToolRunner( - sp.GetRequiredService(), - loggerFactory.CreateLogger()); - } - - return new SdkCertificateToolRunner(loggerFactory.CreateLogger()); + return new NativeCertificateToolRunner(loggerFactory.CreateLogger()); }); builder.Services.AddTransient(); diff --git a/src/Aspire.Managed/Aspire.Managed.csproj b/src/Aspire.Managed/Aspire.Managed.csproj index 15a372e06f3..f6cef5ec606 100644 --- a/src/Aspire.Managed/Aspire.Managed.csproj +++ b/src/Aspire.Managed/Aspire.Managed.csproj @@ -12,8 +12,7 @@ false false - - $(NoWarn);CS1591;SYSLIB0057;IDE1006 + $(NoWarn);CS1591 true diff --git a/src/Aspire.Managed/DevCerts/DevCertsCommand.cs b/src/Aspire.Managed/DevCerts/DevCertsCommand.cs deleted file mode 100644 index 6640fd967f8..00000000000 --- a/src/Aspire.Managed/DevCerts/DevCertsCommand.cs +++ /dev/null @@ -1,434 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Runtime.InteropServices; -using System.Security.Cryptography.X509Certificates; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Certificates.Generation; - -namespace Aspire.Managed.DevCerts; - -/// -/// Implements the dev-certs subcommand for aspire-managed, providing full parity -/// with dotnet dev-certs https without requiring the .NET SDK on PATH. -/// -internal static class DevCertsCommand -{ - // Exit codes — matches dotnet dev-certs for compatibility. - private const int CriticalError = -1; - private const int Success = 0; - private const int ErrorCreatingTheCertificate = 1; - private const int ErrorSavingTheCertificate = 2; - private const int ErrorExportingTheCertificate = 3; - private const int ErrorTrustingTheCertificate = 4; - private const int ErrorUserCancelledTrustPrompt = 5; - private const int ErrorNoValidCertificateFound = 6; - private const int ErrorCertificateNotTrusted = 7; - private const int ErrorCleaningUpCertificates = 8; - private const int InvalidCertificateState = 9; - private const int InvalidKeyExportFormat = 10; - private const int ErrorImportingCertificate = 11; - private const int MissingCertificateFile = 12; - private const int FailedToLoadCertificate = 13; - private const int NoDevelopmentHttpsCertificate = 14; - private const int ExistingCertificatesPresent = 15; - - public static readonly TimeSpan HttpsCertificateValidity = TimeSpan.FromDays(365); - - public static int Run(string[] args) - { - try - { - // Parse args without a CLI framework — simple flag matching. - var flags = new HashSet(StringComparer.OrdinalIgnoreCase); - string? exportPath = null; - string? password = null; - string? format = null; - string? importPath = null; - - for (var i = 0; i < args.Length; i++) - { - switch (args[i]) - { - case "-ep" or "--export-path": - exportPath = GetNextArg(args, ref i); - break; - case "-p" or "--password": - password = GetNextArg(args, ref i); - break; - case "--format": - format = GetNextArg(args, ref i); - break; - case "-i" or "--import": - importPath = GetNextArg(args, ref i); - break; - default: - flags.Add(args[i]); - break; - } - } - - var hasCheck = flags.Contains("--check") || flags.Contains("-c"); - var hasTrust = flags.Contains("--trust") || flags.Contains("-t"); - var hasClean = flags.Contains("--clean"); - var hasCheckJson = flags.Contains("--check-trust-machine-readable"); - var hasNoPassword = flags.Contains("--no-password") || flags.Contains("-np"); - var hasVerbose = flags.Contains("--verbose") || flags.Contains("-v"); - var hasQuiet = flags.Contains("--quiet") || flags.Contains("-q"); - var hasHelp = flags.Contains("--help") || flags.Contains("-h"); - - if (hasHelp) - { - ShowHelp(); - return Success; - } - - // Route to the appropriate handler. - if (hasCheckJson) - { - return CheckHttpsCertificateJsonOutput(); - } - - if (hasCheck) - { - return CheckHttpsCertificate(hasTrust, hasVerbose); - } - - if (hasClean) - { - var cleanResult = CleanHttpsCertificates(); - if (cleanResult != Success || importPath is null) - { - return cleanResult; - } - - return ImportCertificate(importPath, password); - } - - return EnsureHttpsCertificate(exportPath, password, hasNoPassword, hasTrust, format); - } - catch - { - return CriticalError; - } - } - - private static string? GetNextArg(string[] args, ref int index) - { - if (index + 1 < args.Length) - { - return args[++index]; - } - - return null; - } - - private static void ShowHelp() - { - Console.WriteLine(""" - Usage: aspire-managed dev-certs [options] - - Options: - --check, -c Check for the existence of the certificate - --check-trust-machine-readable Check trust status and output JSON - --trust, -t Trust the certificate - --clean Remove all HTTPS development certificates - --import, -i Import a certificate (use with --clean) - --export-path, -ep Export the certificate to a file - --password, -p Password for export/import - --no-password, -np Export PEM key without password - --format Export format (default: Pfx) - --verbose, -v Display verbose output - --quiet, -q Display warnings and errors only - --help, -h Show this help - """); - } - - private static int CheckHttpsCertificateJsonOutput() - { - var availableCertificates = CertificateManager.Instance.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); - var certReports = availableCertificates.Select(CertificateReport.FromX509Certificate2).ToList(); - Console.WriteLine(JsonSerializer.Serialize(certReports, DevCertsJsonContext.Default.ListCertificateReport)); - return Success; - } - - private static int CheckHttpsCertificate(bool checkTrust, bool verbose) - { - var certificateManager = CertificateManager.Instance; - var certificates = certificateManager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true); - - if (certificates.Count == 0) - { - Console.WriteLine("No valid certificate found."); - return ErrorNoValidCertificateFound; - } - - var validCertificates = new List(); - foreach (var certificate in certificates) - { - var status = certificateManager.CheckCertificateState(certificate); - if (!status.Success) - { - Console.Error.WriteLine(status.FailureMessage); - return InvalidCertificateState; - } - validCertificates.Add(certificate); - } - - if (checkTrust) - { - var trustedCertificates = certificates - .Where(cert => certificateManager.GetTrustLevel(cert) == CertificateManager.TrustLevel.Full) - .ToList(); - - if (trustedCertificates.Count == 0) - { - Console.WriteLine($"The following certificates were found, but none of them is trusted: {CertificateManager.ToCertificateDescription(certificates)}"); - if (!verbose) - { - Console.WriteLine("Run the command with --verbose for more details."); - } - return ErrorCertificateNotTrusted; - } - - ReportCertificates(trustedCertificates, "trusted"); - } - else - { - ReportCertificates(validCertificates, "valid"); - Console.WriteLine("Run the command with both --check and --trust options to ensure that the certificate is not only valid but also trusted."); - } - - return Success; - } - - private static void ReportCertificates(IReadOnlyList certificates, string certificateState) - { - Console.WriteLine(certificates.Count switch - { - 1 => $"A {certificateState} certificate was found: {CertificateManager.GetDescription(certificates[0])}", - _ => $"{certificates.Count} {certificateState} certificates were found: {CertificateManager.ToCertificateDescription(certificates)}" - }); - } - - private static int CleanHttpsCertificates() - { - var manager = CertificateManager.Instance; - try - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Console.WriteLine("Cleaning HTTPS development certificates from the machine. A prompt might get " + - "displayed to confirm the removal of some of the certificates."); - } - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - Console.WriteLine("Cleaning HTTPS development certificates from the machine. This operation might " + - "require elevated privileges. If that is the case, a prompt for credentials will be displayed."); - } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - Console.WriteLine("Cleaning HTTPS development certificates from the machine. You may wish to update the " + - "SSL_CERT_DIR environment variable. " + - "See https://aka.ms/dev-certs-trust for more information."); - } - - manager.CleanupHttpsCertificates(); - Console.WriteLine("HTTPS development certificates successfully removed from the machine."); - return Success; - } - catch (Exception e) - { - Console.Error.WriteLine("There was an error trying to clean HTTPS development certificates on this machine."); - Console.Error.WriteLine(e.Message); - return ErrorCleaningUpCertificates; - } - } - - private static int ImportCertificate(string importPath, string? password) - { - if (password is null) - { - Console.Error.WriteLine("Password is required when importing a certificate."); - return CriticalError; - } - - var manager = CertificateManager.Instance; - try - { - var result = manager.ImportCertificate(importPath, password); - return result switch - { - ImportCertificateResult.Succeeded => PrintAndReturn("The certificate was successfully imported.", Success), - ImportCertificateResult.CertificateFileMissing => PrintErrorAndReturn($"The certificate file '{importPath}' does not exist.", MissingCertificateFile), - ImportCertificateResult.InvalidCertificate => PrintErrorAndReturn($"The provided certificate file '{importPath}' is not a valid PFX file or the password is incorrect.", FailedToLoadCertificate), - ImportCertificateResult.NoDevelopmentHttpsCertificate => PrintErrorAndReturn($"The certificate at '{importPath}' is not a valid ASP.NET Core HTTPS development certificate.", NoDevelopmentHttpsCertificate), - ImportCertificateResult.ExistingCertificatesPresent => PrintErrorAndReturn("There are one or more ASP.NET Core HTTPS development certificates present in the environment. Remove them before importing the given certificate.", ExistingCertificatesPresent), - ImportCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore => PrintErrorAndReturn("There was an error saving the HTTPS developer certificate to the current user personal certificate store.", ErrorSavingTheCertificate), - _ => Success - }; - } - catch (Exception exception) - { - Console.Error.WriteLine($"An unexpected error occurred: {exception}"); - return ErrorImportingCertificate; - } - } - - private static int EnsureHttpsCertificate(string? exportPath, string? password, bool noPassword, bool trust, string? exportFormat) - { - var now = DateTimeOffset.Now; - var manager = CertificateManager.Instance; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - var certificates = manager.ListCertificates(StoreName.My, StoreLocation.CurrentUser, isValid: true, exportPath is not null); - foreach (var certificate in certificates) - { - var status = manager.CheckCertificateState(certificate); - if (!status.Success) - { - Console.Error.WriteLine("One or more certificates might be in an invalid state. We will try to access the certificate key " + - "for each certificate and as a result you might be prompted one or more times to enter " + - "your password to access the user keychain. " + - "When that happens, select 'Always Allow' to grant access to the certificate key in the future."); - } - break; - } - } - - if (trust) - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - Console.Error.WriteLine("Trusting the HTTPS development certificate was requested. If the certificate is not " + - "already trusted we will run the following command:" + Environment.NewLine + - "'security add-trusted-cert -p basic -p ssl -k <> <>'" + - Environment.NewLine + "This command might prompt you for your password to install the certificate " + - "on the keychain. To undo these changes: 'security remove-trusted-cert <>'" + Environment.NewLine); - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - Console.Error.WriteLine("Trusting the HTTPS development certificate was requested. A confirmation prompt will be displayed " + - "if the certificate was not previously trusted. Click yes on the prompt to trust the certificate."); - } - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - Console.Error.WriteLine("Trusting the HTTPS development certificate was requested. " + - "Trust is per-user and may require additional configuration. " + - "See https://aka.ms/dev-certs-trust for more information."); - } - } - - var format = CertificateKeyExportFormat.Pfx; - if (exportFormat is not null && !Enum.TryParse(exportFormat, ignoreCase: true, out format)) - { - Console.Error.WriteLine($"Unknown key format '{exportFormat}'."); - return InvalidKeyExportFormat; - } - - var result = manager.EnsureAspNetCoreHttpsDevelopmentCertificate( - now, - now.Add(HttpsCertificateValidity), - exportPath, - trust, - password is not null || (noPassword && format == CertificateKeyExportFormat.Pem), - password, - exportFormat is not null ? format : CertificateKeyExportFormat.Pfx); - - return result switch - { - EnsureCertificateResult.Succeeded => PrintAndReturn("The HTTPS developer certificate was generated successfully.", Success), - EnsureCertificateResult.ValidCertificatePresent => PrintAndReturn("A valid HTTPS certificate is already present.", Success), - EnsureCertificateResult.ErrorCreatingTheCertificate => PrintErrorAndReturn("There was an error creating the HTTPS developer certificate.", ErrorCreatingTheCertificate), - EnsureCertificateResult.ErrorSavingTheCertificateIntoTheCurrentUserPersonalStore => PrintErrorAndReturn("There was an error saving the HTTPS developer certificate to the current user personal certificate store.", ErrorSavingTheCertificate), - EnsureCertificateResult.ErrorExportingTheCertificate or EnsureCertificateResult.ErrorExportingTheCertificateToNonExistentDirectory => PrintErrorAndReturn("There was an error exporting the HTTPS developer certificate to a file.", ErrorExportingTheCertificate), - EnsureCertificateResult.PartiallyFailedToTrustTheCertificate => PrintErrorAndReturn("There was an error trusting the HTTPS developer certificate. It will be trusted by some clients but not by others.", ErrorTrustingTheCertificate), - EnsureCertificateResult.FailedToTrustTheCertificate => PrintErrorAndReturn("There was an error trusting the HTTPS developer certificate.", ErrorTrustingTheCertificate), - EnsureCertificateResult.UserCancelledTrustStep => PrintErrorAndReturn("The user cancelled the trust step.", ErrorUserCancelledTrustPrompt), - EnsureCertificateResult.ExistingHttpsCertificateTrusted => PrintAndReturn("Successfully trusted the existing HTTPS certificate.", Success), - EnsureCertificateResult.NewHttpsCertificateTrusted => PrintAndReturn("Successfully created and trusted a new HTTPS certificate.", Success), - _ => PrintErrorAndReturn("Something went wrong. The HTTPS developer certificate could not be created.", CriticalError) - }; - } - - private static int PrintAndReturn(string message, int exitCode) - { - Console.WriteLine(message); - return exitCode; - } - - private static int PrintErrorAndReturn(string message, int exitCode) - { - Console.Error.WriteLine(message); - return exitCode; - } -} - -/// -/// JSON-serializable certificate report matching the dotnet dev-certs https --check-trust-machine-readable output format. -/// -internal sealed class CertificateReport -{ - public string? Thumbprint { get; init; } - public string? Subject { get; init; } - public List? X509SubjectAlternativeNameExtension { get; init; } - public int Version { get; init; } - public DateTime ValidityNotBefore { get; init; } - public DateTime ValidityNotAfter { get; init; } - public bool IsHttpsDevelopmentCertificate { get; init; } - public bool IsExportable { get; init; } - public string? TrustLevel { get; init; } - - public static CertificateReport FromX509Certificate2(X509Certificate2 cert) - { - var certificateManager = CertificateManager.Instance; - var status = certificateManager.CheckCertificateState(cert); - string statusString; - if (!status.Success) - { - statusString = "Invalid"; - } - else - { - var trustStatus = certificateManager.GetTrustLevel(cert); - statusString = trustStatus.ToString(); - } - - return new CertificateReport - { - Thumbprint = cert.Thumbprint, - Subject = cert.Subject, - X509SubjectAlternativeNameExtension = GetSanExtension(cert), - Version = CertificateManager.GetCertificateVersion(cert), - ValidityNotBefore = cert.NotBefore, - ValidityNotAfter = cert.NotAfter, - IsHttpsDevelopmentCertificate = CertificateManager.IsHttpsDevelopmentCertificate(cert), - IsExportable = certificateManager.IsExportable(cert), - TrustLevel = statusString - }; - - static List GetSanExtension(X509Certificate2 cert) - { - var dnsNames = new List(); - foreach (var extension in cert.Extensions) - { - if (extension is X509SubjectAlternativeNameExtension sanExtension) - { - foreach (var dns in sanExtension.EnumerateDnsNames()) - { - dnsNames.Add(dns); - } - } - } - return dnsNames; - } - } -} - -[JsonSerializable(typeof(List))] -[JsonSourceGenerationOptions(WriteIndented = true)] -internal sealed partial class DevCertsJsonContext : JsonSerializerContext; diff --git a/src/Aspire.Managed/Program.cs b/src/Aspire.Managed/Program.cs index fa46035845e..5deccea0c79 100644 --- a/src/Aspire.Managed/Program.cs +++ b/src/Aspire.Managed/Program.cs @@ -2,14 +2,12 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard; -using Aspire.Managed.DevCerts; return args switch { ["dashboard", .. var rest] => RunDashboard(rest), ["server", .. var rest] => await RunServer(rest).ConfigureAwait(false), ["nuget", .. var rest] => await RunNuGet(rest).ConfigureAwait(false), - ["dev-certs", .. var rest] => DevCertsCommand.Run(rest), _ => ShowUsage() }; @@ -38,6 +36,6 @@ static async Task RunNuGet(string[] args) static int ShowUsage() { - Console.Error.WriteLine($"Usage: {AppDomain.CurrentDomain.FriendlyName} [args...]"); + Console.Error.WriteLine($"Usage: {AppDomain.CurrentDomain.FriendlyName} [args...]"); return 1; } From c9a77955033baa81b989149ce5c4b07915abb45f Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 08:41:28 -0800 Subject: [PATCH 04/14] Remove statics from CertificateManager, use ILogger DI pattern - Replace static Instance property with Create(ILogger) factory method - Make Log an instance property initialized from constructor ILogger - Add ILogger parameter to CertificateManager and subclass constructors - NativeCertificateToolRunner takes CertificateManager via DI - Register CertificateManager as singleton in Program.cs --- .../CertificateManager.cs | 23 +++++++++-------- .../MacOSCertificateManager.cs | 11 ++++---- .../UnixCertificateManager.cs | 25 ++++++++++--------- .../WindowsCertificateManager.cs | 3 ++- .../NativeCertificateToolRunner.cs | 18 +++++-------- src/Aspire.Cli/Program.cs | 8 +++--- test_cert_init.cs | 13 ++++++++++ 7 files changed, 55 insertions(+), 46 deletions(-) create mode 100644 test_cert_init.cs diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs index 57e5967f869..31fa6d97c64 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs @@ -51,15 +51,15 @@ internal abstract class CertificateManager public const int RSAMinimumKeySizeInBits = 2048; - public static CertificateManager Instance { get; } = OperatingSystem.IsWindows() ? + public static CertificateManager Create(ILogger logger) => OperatingSystem.IsWindows() ? #pragma warning disable CA1416 // Validate platform compatibility - new WindowsCertificateManager() : + new WindowsCertificateManager(logger) : #pragma warning restore CA1416 // Validate platform compatibility OperatingSystem.IsMacOS() ? - new MacOSCertificateManager() as CertificateManager : - new UnixCertificateManager(); + new MacOSCertificateManager(logger) as CertificateManager : + new UnixCertificateManager(logger); - public static CertificateManagerLogger Log { get; set; } = new CertificateManagerLogger(); + protected CertificateManagerLogger Log { get; } // Setting to 0 means we don't append the version byte, // which is what all machines currently have. @@ -93,19 +93,20 @@ internal set public string Subject { get; } - public CertificateManager() : this(LocalhostHttpsDistinguishedName, CurrentAspNetCoreCertificateVersion, CurrentMinimumAspNetCoreCertificateVersion) + public CertificateManager(ILogger logger) : this(logger, LocalhostHttpsDistinguishedName, CurrentAspNetCoreCertificateVersion, CurrentMinimumAspNetCoreCertificateVersion) { } // For testing purposes only internal CertificateManager(string subject, int version) - : this(subject, version, version) + : this(NullLogger.Instance, subject, version, version) { } // For testing purposes only - internal CertificateManager(string subject, int generatedVersion, int minimumVersion) + internal CertificateManager(ILogger logger, string subject, int generatedVersion, int minimumVersion) { + Log = new CertificateManagerLogger(logger); Subject = subject; AspNetHttpsCertificateVersion = generatedVersion; MinimumAspNetHttpsCertificateVersion = minimumVersion; @@ -981,7 +982,7 @@ protected virtual void RemoveCertificateFromUserStoreCore(X509Certificate2 certi store.Remove(matching); } - internal static string ToCertificateDescription(IEnumerable certificates) + internal string ToCertificateDescription(IEnumerable certificates) { var list = certificates.ToList(); var certificatesDescription = list.Count switch @@ -994,8 +995,8 @@ internal static string ToCertificateDescription(IEnumerable ce return string.Join(Environment.NewLine, description); } - internal static string GetDescription(X509Certificate2 c) => - $"{c.Thumbprint} - {c.Subject} - Valid from {c.NotBefore:u} to {c.NotAfter:u} - IsHttpsDevelopmentCertificate: {IsHttpsDevelopmentCertificate(c).ToString().ToLowerInvariant()} - IsExportable: {Instance.IsExportable(c).ToString().ToLowerInvariant()}"; + internal string GetDescription(X509Certificate2 c) => + $"{c.Thumbprint} - {c.Subject} - Valid from {c.NotBefore:u} to {c.NotAfter:u} - IsHttpsDevelopmentCertificate: {IsHttpsDevelopmentCertificate(c).ToString().ToLowerInvariant()} - IsExportable: {IsExportable(c).ToString().ToLowerInvariant()}"; /// /// is not adequate for security purposes. diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs index e1a8620358e..1dff6b8e2f7 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs @@ -7,6 +7,7 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Certificates.Generation; @@ -72,7 +73,7 @@ internal sealed class MacOSCertificateManager : CertificateManager "and create a new untrusted developer certificate. " + "Use 'dotnet dev-certs https --trust' to trust the new certificate."; - public MacOSCertificateManager() + public MacOSCertificateManager(ILogger logger) : base(logger) { } @@ -194,7 +195,7 @@ protected override void RemoveCertificateFromTrustedRoots(X509Certificate2 certi } // Remove the certificate from the admin trust settings. - private static void RemoveAdminTrustRule(X509Certificate2 certificate) + private void RemoveAdminTrustRule(X509Certificate2 certificate) { Log.MacOSRemoveCertificateTrustRuleStart(GetDescription(certificate)); var certificatePath = Path.GetTempFileName(); @@ -233,7 +234,7 @@ private static void RemoveAdminTrustRule(X509Certificate2 certificate) } } - private static void RemoveCertificateFromKeychain(string keychain, X509Certificate2 certificate) + private void RemoveCertificateFromKeychain(string keychain, X509Certificate2 certificate) { var processInfo = new ProcessStartInfo( MacOSDeleteCertificateCommandLine, @@ -332,7 +333,7 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi return certificate; } - private static void SaveCertificateToUserKeychain(X509Certificate2 certificate) + private void SaveCertificateToUserKeychain(X509Certificate2 certificate) { var passwordBytes = new byte[48]; RandomNumberGenerator.Fill(passwordBytes.AsSpan()[0..35]); @@ -427,7 +428,7 @@ int IEqualityComparer.GetHashCode([DisallowNull] X509Certifica EqualityComparer.Default.GetHashCode(obj.Thumbprint); } - private static ICollection GetCertsFromDisk() + private ICollection GetCertsFromDisk() { var certsFromDisk = new List(); if (!Directory.Exists(MacOSUserHttpsCertificateLocation)) diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs index efe74c25a44..6bbae7c654f 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs @@ -7,6 +7,7 @@ using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Certificates.Generation; @@ -43,7 +44,7 @@ internal sealed partial class UnixCertificateManager : CertificateManager private HashSet? _availableCommands; - public UnixCertificateManager() + public UnixCertificateManager(ILogger logger) : base(logger) { } @@ -698,7 +699,7 @@ private static bool TrustCertificateInWindowsStore(X509Certificate2 certificate) /// /// It is the caller's responsibility to ensure that is available. /// - private static bool IsCertificateInNssDb(string nickname, NssDb nssDb) + private bool IsCertificateInNssDb(string nickname, NssDb nssDb) { // -V will validate that a cert can be used for a given purpose, in this case, server verification. // There is no corresponding -V check for the "Trusted CA" status required by Firefox, so we just check for existence. @@ -728,7 +729,7 @@ private static bool IsCertificateInNssDb(string nickname, NssDb nssDb) /// /// It is the caller's responsibility to ensure that is available. /// - private static bool TryAddCertificateToNssDb(string certificatePath, string nickname, NssDb nssDb) + private bool TryAddCertificateToNssDb(string certificatePath, string nickname, NssDb nssDb) { // Firefox doesn't seem to respected the more correct "trusted peer" (P) usage, so we use "trusted CA" (C) instead. var usage = nssDb.IsFirefox ? "C" : "P"; @@ -756,7 +757,7 @@ private static bool TryAddCertificateToNssDb(string certificatePath, string nick /// /// It is the caller's responsibility to ensure that is available. /// - private static bool TryRemoveCertificateFromNssDb(string nickname, NssDb nssDb) + private bool TryRemoveCertificateFromNssDb(string nickname, NssDb nssDb) { var startInfo = new ProcessStartInfo(CertUtilCommand, $"-d sql:{nssDb.Path} -D -n {nickname}") { @@ -783,7 +784,7 @@ private static bool TryRemoveCertificateFromNssDb(string nickname, NssDb nssDb) } } - private static IEnumerable GetFirefoxProfiles(string firefoxDirectory) + private IEnumerable GetFirefoxProfiles(string firefoxDirectory) { try { @@ -803,7 +804,7 @@ private static IEnumerable GetFirefoxProfiles(string firefoxDirectory) } } - private static string GetOpenSslCertificateDirectory(string homeDirectory) + private string GetOpenSslCertificateDirectory(string homeDirectory) { var @override = Environment.GetEnvironmentVariable(OpenSslCertDirectoryOverrideVariableName); if (!string.IsNullOrEmpty(@override)) @@ -815,7 +816,7 @@ private static string GetOpenSslCertificateDirectory(string homeDirectory) return Path.Combine(homeDirectory, ".aspnet", "dev-certs", "trust"); } - private static bool TryDeleteCertificateFile(string certPath) + private bool TryDeleteCertificateFile(string certPath) { try { @@ -829,7 +830,7 @@ private static bool TryDeleteCertificateFile(string certPath) } } - private static bool TryGetNssDbOverrides(out IReadOnlyList overrides) + private bool TryGetNssDbOverrides(out IReadOnlyList overrides) { var nssDbOverride = Environment.GetEnvironmentVariable(NssDbOverrideVariableName); if (string.IsNullOrEmpty(nssDbOverride)) @@ -860,7 +861,7 @@ private static bool TryGetNssDbOverrides(out IReadOnlyList overrides) return true; } - private static List GetNssDbs(string homeDirectory) + private List GetNssDbs(string homeDirectory) { var nssDbs = new List(); @@ -926,7 +927,7 @@ private static List GetNssDbs(string homeDirectory) /// /// It is the caller's responsibility to ensure that is available. /// - private static bool TryGetOpenSslDirectory([NotNullWhen(true)] out string? openSslDir) + private bool TryGetOpenSslDirectory([NotNullWhen(true)] out string? openSslDir) { openSslDir = null; @@ -968,7 +969,7 @@ private static bool TryGetOpenSslDirectory([NotNullWhen(true)] out string? openS /// /// It is the caller's responsibility to ensure that is available. /// - private static bool TryGetOpenSslHash(string certificatePath, [NotNullWhen(true)] out string? hash) + private bool TryGetOpenSslHash(string certificatePath, [NotNullWhen(true)] out string? hash) { hash = null; @@ -1016,7 +1017,7 @@ private static bool TryGetOpenSslHash(string certificatePath, [NotNullWhen(true) /// This is a simplified version of c_rehash from OpenSSL. Using the real one would require /// installing the OpenSSL perl tools and perl itself, which might be annoying in a container. /// - private static bool TryRehashOpenSslCertificates(string certificateDirectory) + private bool TryRehashOpenSslCertificates(string certificateDirectory) { try { diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs index 8b0bd4c9b8a..119d70e21e8 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs @@ -6,6 +6,7 @@ using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Security.Principal; +using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Certificates.Generation; @@ -14,7 +15,7 @@ internal sealed class WindowsCertificateManager : CertificateManager { private const int UserCancelledErrorCode = 1223; - public WindowsCertificateManager() + public WindowsCertificateManager(ILogger logger) : base(logger) { } diff --git a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs index 4f6a60175b7..741bcae5242 100644 --- a/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs +++ b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs @@ -4,30 +4,26 @@ using System.Security.Cryptography.X509Certificates; using Aspire.Cli.DotNet; using Microsoft.AspNetCore.Certificates.Generation; -using Microsoft.Extensions.Logging; namespace Aspire.Cli.Certificates; /// /// Certificate tool runner that uses the native CertificateManager directly (no subprocess needed). /// -internal sealed class NativeCertificateToolRunner(ILogger logger) : ICertificateToolRunner +internal sealed class NativeCertificateToolRunner(CertificateManager certificateManager) : ICertificateToolRunner { public Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { - CertificateManager.Log = new CertificateManager.CertificateManagerLogger(logger); - - var availableCertificates = CertificateManager.Instance.ListCertificates( + var availableCertificates = certificateManager.ListCertificates( StoreName.My, StoreLocation.CurrentUser, isValid: true); var now = DateTimeOffset.Now; var certInfos = availableCertificates.Select(cert => { - var mgr = CertificateManager.Instance; - var status = mgr.CheckCertificateState(cert); - var trustLevel = status.Success ? mgr.GetTrustLevel(cert).ToString() : "Invalid"; + var status = certificateManager.CheckCertificateState(cert); + var trustLevel = status.Success ? certificateManager.GetTrustLevel(cert).ToString() : "Invalid"; return new DevCertInfo { @@ -38,7 +34,7 @@ internal sealed class NativeCertificateToolRunner(ILogger TrustHttpCertificateAsync( DotNetCliRunnerInvocationOptions options, CancellationToken cancellationToken) { - CertificateManager.Log = new CertificateManager.CertificateManagerLogger(logger); - var now = DateTimeOffset.Now; - var result = CertificateManager.Instance.EnsureAspNetCoreHttpsDevelopmentCertificate( + var result = certificateManager.EnsureAspNetCoreHttpsDevelopmentCertificate( now, now.Add(TimeSpan.FromDays(365)), trust: true); diff --git a/src/Aspire.Cli/Program.cs b/src/Aspire.Cli/Program.cs index 8107da63f6b..b2f14825477 100644 --- a/src/Aspire.Cli/Program.cs +++ b/src/Aspire.Cli/Program.cs @@ -16,6 +16,7 @@ using Aspire.Cli.Caching; using Aspire.Cli.Certificates; using Aspire.Cli.Commands; +using Microsoft.AspNetCore.Certificates.Generation; using Aspire.Cli.Commands.Sdk; using Aspire.Cli.Configuration; using Aspire.Cli.Diagnostics; @@ -223,11 +224,8 @@ internal static async Task BuildApplicationAsync(string[] args, Dictionar builder.Services.AddTransient(); // Register certificate tool runner - uses native CertificateManager directly (no subprocess needed) - builder.Services.AddSingleton(sp => - { - var loggerFactory = sp.GetRequiredService(); - return new NativeCertificateToolRunner(loggerFactory.CreateLogger()); - }); + builder.Services.AddSingleton(sp => CertificateManager.Create(sp.GetRequiredService>())); + builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddSingleton(); diff --git a/test_cert_init.cs b/test_cert_init.cs new file mode 100644 index 00000000000..782311c964e --- /dev/null +++ b/test_cert_init.cs @@ -0,0 +1,13 @@ +using System; +using Microsoft.AspNetCore.Certificates.Generation; + +// Test if the circular validation in property setters causes issues during constructor +var mgr = new TestCertificateManager(); +Console.WriteLine($"Success: Version={mgr.AspNetHttpsCertificateVersion}, MinVersion={mgr.MinimumAspNetHttpsCertificateVersion}"); + +class TestCertificateManager : CertificateManager +{ + public TestCertificateManager() : base("CN=Test", 6, 4) + { + } +} From 905025697206bc543404b825196243b8dc2b9e6a Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 08:41:36 -0800 Subject: [PATCH 05/14] Remove stray test file --- test_cert_init.cs | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 test_cert_init.cs diff --git a/test_cert_init.cs b/test_cert_init.cs deleted file mode 100644 index 782311c964e..00000000000 --- a/test_cert_init.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using Microsoft.AspNetCore.Certificates.Generation; - -// Test if the circular validation in property setters causes issues during constructor -var mgr = new TestCertificateManager(); -Console.WriteLine($"Success: Version={mgr.AspNetHttpsCertificateVersion}, MinVersion={mgr.MinimumAspNetHttpsCertificateVersion}"); - -class TestCertificateManager : CertificateManager -{ - public TestCertificateManager() : base("CN=Test", 6, 4) - { - } -} From 0f27cc2e2fa57698bd86776c5ca416910aa58bb7 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 09:40:38 -0800 Subject: [PATCH 06/14] Update bundle spec for aspire-managed and native cert management --- docs/specs/bundle.md | 285 ++++++++++++++++--------------------------- 1 file changed, 106 insertions(+), 179 deletions(-) diff --git a/docs/specs/bundle.md b/docs/specs/bundle.md index 579eb94d202..af242ce0496 100644 --- a/docs/specs/bundle.md +++ b/docs/specs/bundle.md @@ -30,15 +30,11 @@ This document specifies the **Aspire Bundle**, a self-contained distribution pac The Aspire Bundle is a platform-specific archive containing the Aspire CLI and all runtime components: -- **Aspire CLI** (native AOT executable) -- **.NET Runtime** (for running managed components) -- **Pre-built AppHost Server** (for polyglot app hosts) -- **Aspire Dashboard** (no longer distributed via NuGet) +- **Aspire CLI** (native AOT executable, includes native certificate management) +- **Aspire Managed** (unified self-contained binary: Dashboard + AppHost Server + NuGet Helper) - **Developer Control Plane (DCP)** (no longer distributed via NuGet) -- **NuGet Helper Tool** (for package search and restore without SDK) -- **Dev-Certs Tool** (for HTTPS certificate management without SDK) -**Key change**: DCP and Dashboard are now bundled with the CLI installation, not downloaded as NuGet packages. This applies to **all** Aspire applications, including .NET ones. This: +**Key change**: DCP and Dashboard are now bundled with the CLI installation, not downloaded as NuGet packages. Dashboard, AppHost Server, and NuGet Helper are consolidated into a single `aspire-managed` binary that dispatches via subcommands. Certificate management is handled natively in the CLI (no subprocess needed). This: - Eliminates large NuGet package downloads on first run - Ensures version consistency between CLI and runtime components @@ -98,16 +94,19 @@ DCP and Dashboard distribution via NuGet packages causes: ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ spawns ┌───────────────────────────────────┐ │ -│ │ aspire │ ───────────────▶│ .NET RUNTIME │ │ -│ │ (Native AOT) │ │ │ │ -│ │ │ │ • Runs AppHost Server │ │ -│ │ Commands: │ │ • Runs NuGet Helper Tool │ │ -│ │ • run │ │ • Hosts Dashboard │ │ -│ │ • add │ └───────────────────────────────────┘ │ -│ │ • new │ │ │ -│ │ • publish │ ▼ │ -│ └──────┬───────┘ ┌───────────────────────────────────┐ │ -│ │ │ APPHOST SERVER │ │ +│ │ aspire │ ───────────────▶│ ASPIRE-MANAGED │ │ +│ │ (Native AOT) │ │ (self-contained single binary) │ │ +│ │ │ │ │ │ +│ │ Commands: │ │ Subcommands: │ │ +│ │ • run │ │ • dashboard (Aspire Dashboard) │ │ +│ │ • add │ │ • server (AppHost Server) │ │ +│ │ • new │ │ • nuget (NuGet operations) │ │ +│ │ • publish │ └───────────────────────────────────┘ │ +│ │ │ │ │ +│ │ Native: │ ▼ │ +│ │ • cert mgmt │ ┌───────────────────────────────────┐ │ +│ └──────┬───────┘ │ APPHOST SERVER │ │ +│ │ │ (aspire-managed server) │ │ │ │ JSON-RPC │ │ │ │ │◀────────────────────▶│ • Aspire.Hosting.* assemblies │ │ │ │ (socket) │ • RemoteHostServer endpoint │ │ @@ -118,10 +117,10 @@ DCP and Dashboard distribution via NuGet packages causes: │ │ ▼ ▼ ▼ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │ │ │ DASHBOARD │ │ DCP │ │INTEGRATIONS│ │ -│ │ │ │ │ │ │ │ │ -│ │ │ dashboard/ │ │ dcp/ │ │~/.aspire/ │ │ -│ │ └─────────────┘ └─────────────┘ │ packages/ │ │ -│ │ └────────────┘ │ +│ │ │ (aspire- │ │ │ │ │ │ +│ │ │ managed │ │ dcp/ │ │~/.aspire/ │ │ +│ │ │ dashboard) │ └─────────────┘ │ packages/ │ │ +│ │ └─────────────┘ └────────────┘ │ │ │ ▲ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ USER'S APPHOST │────────┘ │ @@ -139,11 +138,11 @@ When a user runs `aspire run` with a TypeScript app host: 1. **CLI reads project configuration** from `.aspire/settings.json` 2. **CLI discovers bundle layout** using priority-based resolution -3. **CLI downloads missing integrations** using the NuGet Helper Tool +3. **CLI downloads missing integrations** using aspire-managed's NuGet subcommand 4. **CLI generates `appsettings.json`** for the AppHost Server with integration list -5. **CLI starts AppHost Server** using the bundled .NET runtime +5. **CLI starts AppHost Server** using aspire-managed's server subcommand 6. **CLI starts guest app host** (TypeScript) which connects via JSON-RPC -7. **AppHost Server orchestrates** containers, Dashboard, and DCP +7. **AppHost Server orchestrates** containers, Dashboard (via aspire-managed dashboard), and DCP --- @@ -155,48 +154,26 @@ When a user runs `aspire run` with a TypeScript app host: aspire-{version}-{platform}/ │ ├── aspire[.exe] # Native AOT CLI (~25 MB) +│ # (includes native certificate management) │ ├── layout.json # Bundle metadata │ -├── runtime/ # .NET 10 Runtime (~106 MB) -│ ├── dotnet[.exe] # Muxer executable -│ ├── LICENSE.txt -│ ├── host/ -│ │ └── fxr/{version}/ -│ │ └── hostfxr.{dll|so|dylib} -│ └── shared/ -│ ├── Microsoft.NETCore.App/{version}/ -│ │ └── *.dll -│ └── Microsoft.AspNetCore.App/{version}/ -│ └── *.dll -│ -├── aspire-server/ # Pre-built AppHost Server (~19 MB) -│ ├── aspire-server[.exe] # Single-file executable -│ └── appsettings.json # Default config -│ -├── dashboard/ # Aspire Dashboard (~42 MB) -│ ├── aspire-dashboard[.exe] # Single-file executable -│ ├── wwwroot/ -│ └── ... +├── managed/ # Unified managed binary (~65 MB) +│ └── aspire-managed[.exe] # Self-contained single-file executable +│ # Subcommands: dashboard | server | nuget │ ├── dcp/ # Developer Control Plane (~127 MB) │ ├── dcp[.exe] # Native executable │ └── ... │ -└── tools/ # Helper tools (~5 MB) - ├── aspire-nuget/ # NuGet operations - │ ├── aspire-nuget[.exe] # Single-file executable - │ └── ... - │ - └── dev-certs/ # HTTPS certificate tool - ├── dotnet-dev-certs.dll - ├── dotnet-dev-certs.deps.json - └── dotnet-dev-certs.runtimeconfig.json +└── (no more runtime/, dashboard/, aspire-server/, tools/ directories) ``` +**Key change from previous layout**: The separate `.NET Runtime` (~106 MB), `dashboard/` (~42 MB), `aspire-server/` (~19 MB), `tools/aspire-nuget/` (~5 MB), and `tools/dev-certs/` directories have been consolidated into a single `managed/aspire-managed` self-contained binary. Certificate management has been moved natively into the CLI itself, eliminating the need for a separate dev-certs tool. + **Total Bundle Size:** -- **Unzipped:** ~323 MB -- **Zipped:** ~113 MB +- **Unzipped:** ~220 MB (down from ~323 MB — eliminated separate runtime) +- **Zipped:** ~80 MB ### layout.json Schema @@ -204,15 +181,10 @@ aspire-{version}-{platform}/ { "version": "13.2.0", "platform": "linux-x64", - "runtimeVersion": "10.0.0", "components": { "cli": "aspire", - "runtime": "runtime", - "apphostServer": "aspire-server", - "dashboard": "dashboard", - "dcp": "dcp", - "nugetHelper": "tools/aspire-nuget", - "devCerts": "tools/dev-certs" + "managed": "managed", + "dcp": "dcp" }, "builtInIntegrations": [] } @@ -261,7 +233,7 @@ The service uses a file lock (`.aspire-bundle-lock`) in the extraction directory **Extraction flow:** 1. Check for embedded `bundle.tar.gz` resource — if absent, return `NoPayload` 2. Check version marker (`.aspire-bundle-version`) — if version matches, return `AlreadyUpToDate` -3. Clean well-known layout directories (runtime, dashboard, dcp, aspire-server, tools) — preserves `bin/` +3. Clean well-known layout directories (managed, dcp) — preserves `bin/` 4. Extract payload using .NET `TarReader` with path-traversal and symlink validation 5. Set Unix file permissions from tar entry metadata (execute bit, etc.) 6. Write version marker with assembly informational version @@ -342,14 +314,11 @@ The parent directory check supports the installed layout where the CLI binary li |----------|-------------|---------| | `ASPIRE_LAYOUT_PATH` | Root of the bundle | `/opt/aspire` | | `ASPIRE_DCP_PATH` | DCP binaries location | `/opt/aspire/dcp` | -| `ASPIRE_DASHBOARD_PATH` | Dashboard executable path | `/opt/aspire/dashboard/aspire-dashboard` | -| `ASPIRE_RUNTIME_PATH` | Bundled .NET runtime directory (guest apphosts only) | `/opt/aspire/runtime` | +| `ASPIRE_MANAGED_PATH` | Aspire-managed binary path | `/opt/aspire/managed/aspire-managed` | | `ASPIRE_INTEGRATION_LIBS_PATH` | Path to integration DLLs for aspire-server assembly resolution | `/home/user/.aspire/libs` | | `ASPIRE_USE_GLOBAL_DOTNET` | Force SDK mode | `true` | | `ASPIRE_REPO_ROOT` | Dev mode (Aspire repo path, DEBUG builds only) | `/home/user/aspire` | -**Note:** `ASPIRE_RUNTIME_PATH` is only set for guest (polyglot) apphosts. .NET apphosts use the globally installed `dotnet`. - **Note:** `ASPIRE_INTEGRATION_LIBS_PATH` is set by the CLI when running guest apphosts that require additional hosting integration packages (e.g., `Aspire.Hosting.Redis`). The aspire-server uses this path to resolve integration assemblies at runtime. ### Transition Compatibility @@ -362,7 +331,7 @@ During the transition from NuGet-based to bundle-based distribution, these versi Bundle CLI ────► runs ────► .NET AppHost (new Aspire.Hosting) │ │ │ sets ASPIRE_DCP_PATH │ reads ASPIRE_DCP_PATH - │ sets ASPIRE_DASHBOARD_PATH│ reads ASPIRE_DASHBOARD_PATH + │ sets ASPIRE_MANAGED_PATH │ reads ASPIRE_MANAGED_PATH │ │ └──────────────────────────►│ Uses bundled DCP/Dashboard ✓ ``` @@ -375,7 +344,7 @@ Bundle CLI ────► runs ────► .NET AppHost (new Aspire.Hosting Bundle CLI ────► runs ────► .NET AppHost (old Aspire.Hosting) │ │ │ sets ASPIRE_DCP_PATH │ ignores (doesn't check env vars) - │ sets ASPIRE_DASHBOARD_PATH│ + │ sets ASPIRE_MANAGED_PATH │ │ │ │ │ Uses NuGet package paths ✓ ``` @@ -422,7 +391,7 @@ dotnet run ────► .NET AppHost (new Aspire.Hosting) | Component | When it discovers | What it does | |-----------|------------------|--------------| -| **CLI** | Before launching AppHost | Sets `ASPIRE_DCP_PATH`, `ASPIRE_DASHBOARD_PATH`, and `ASPIRE_RUNTIME_PATH` (guest only) env vars | +| **CLI** | Before launching AppHost | Sets `ASPIRE_DCP_PATH`, `ASPIRE_MANAGED_PATH` env vars | | **Aspire.Hosting** | At AppHost startup | Reads env vars OR does its own disk discovery OR uses NuGet | This dual-discovery approach ensures: @@ -434,13 +403,13 @@ This dual-discovery approach ensures: ## NuGet Operations -The bundle includes a managed NuGet Helper Tool that provides package search and restore functionality without requiring the .NET SDK. +The bundle includes NuGet operations via the `aspire-managed nuget` subcommand, which provides package search and restore functionality without requiring the .NET SDK. ### NuGet Helper Commands ```bash # Search for packages -{runtime}/dotnet {tools}/aspire-nuget/aspire-nuget.dll search \ +{managed}/aspire-managed nuget search \ --query "Aspire.Hosting" \ --prerelease \ --take 50 \ @@ -448,14 +417,14 @@ The bundle includes a managed NuGet Helper Tool that provides package search and --format json # Restore packages -{runtime}/dotnet {tools}/aspire-nuget/aspire-nuget.dll restore \ +{managed}/aspire-managed nuget restore \ --package "Aspire.Hosting.Redis" \ --version "13.2.0" \ --framework net10.0 \ --output ~/.aspire/packages # Create flat layout from restored packages (DLLs + XML doc files) -{runtime}/dotnet {tools}/aspire-nuget/aspire-nuget.dll layout \ +{managed}/aspire-managed nuget layout \ --assets ~/.aspire/packages/obj/project.assets.json \ --output ~/.aspire/packages/libs \ --framework net10.0 @@ -505,44 +474,39 @@ The bundle includes a managed NuGet Helper Tool that provides package search and ## Certificate Management -The bundle includes the `dotnet-dev-certs` tool for HTTPS certificate management. This enables polyglot apphosts to configure HTTPS certificates without requiring a globally-installed .NET SDK. +The CLI includes native HTTPS certificate management via ASP.NET Core's `CertificateManager` library, ported directly into the native AOT binary. This eliminates the need for a separate dev-certs tool or subprocess for certificate operations. -### Dev-Certs Tool Usage +### How It Works -```bash -# Check certificate trust status (machine-readable output) -{runtime}/dotnet {tools}/dev-certs/dotnet-dev-certs.dll https --check --trust +The `CertificateManager` from `aspnetcore/src/Shared/CertificateGeneration/` is vendored into `src/Aspire.Cli/Certificates/CertificateGeneration/`. The original `EventSource`-based logging (AOT-incompatible) has been replaced with `ILogger`, making the code fully native AOT friendly. -# Trust the development certificate (requires elevation on some platforms) -{runtime}/dotnet {tools}/dev-certs/dotnet-dev-certs.dll https --trust -``` +Platform-specific implementations handle certificate store operations: +- **Windows**: `WindowsCertificateManager` — Windows certificate store + ACLs +- **macOS**: `MacOSCertificateManager` — Keychain management via `security` CLI +- **Linux**: `UnixCertificateManager` — OpenSSL + NSS databases + .NET trust store ### Certificate Tool Abstraction -The CLI uses an `ICertificateToolRunner` abstraction to support both bundle and SDK modes: +The CLI uses an `ICertificateToolRunner` abstraction with a single implementation: -| Mode | Implementation | Usage | -|------|----------------|-------| -| Bundle | `BundleCertificateToolRunner` | Uses bundled runtime + dev-certs.dll | -| SDK | `SdkCertificateToolRunner` | Uses `dotnet dev-certs` from global SDK | +| Implementation | Description | +|----------------|-------------| +| `NativeCertificateToolRunner` | Calls `CertificateManager` directly (no subprocess) | -The appropriate implementation is selected via DI based on whether a bundle layout is detected: +The `CertificateManager` is registered as a singleton via DI, with `ILogger` injected through the constructor: ```csharp -services.AddSingleton(sp => -{ - var layout = sp.GetService(); - var devCertsPath = layout?.GetDevCertsDllPath(); - - if (devCertsPath is not null && File.Exists(devCertsPath)) - { - return new BundleCertificateToolRunner(layout!); - } - - return new SdkCertificateToolRunner(sp.GetRequiredService()); -}); +// Register CertificateManager (platform-specific) and certificate tool runner +builder.Services.AddSingleton(sp => CertificateManager.Create(sp.GetRequiredService>())); +builder.Services.AddSingleton(); ``` +### Key Operations + +- **Check trust status**: `CertificateManager.ListCertificates()` + `GetTrustLevel()` — returns structured certificate info +- **Trust certificate**: `CertificateManager.EnsureAspNetCoreHttpsDevelopmentCertificate()` — creates and trusts dev cert +- **Platform detection**: `CertificateManager.Create()` selects the right platform implementation at startup + --- ## AppHost Server @@ -569,8 +533,8 @@ When a project references integrations (e.g., `Aspire.Hosting.Redis`): ### Pre-built Mode Execution ```bash -# CLI spawns the pre-built AppHost Server -{aspire-server}/aspire-server \ +# CLI spawns the AppHost Server via aspire-managed +{managed}/aspire-managed server \ --project {user-project-path} \ --socket {socket-path} ``` @@ -691,22 +655,12 @@ The bundle installs components as siblings under `~/.aspire/`, with the CLI bina ├── .aspire-bundle-version # Version marker (hex FNV-1a hash, written after extraction) ├── layout.json # Bundle metadata (present only for bundle install) │ -├── runtime/ # Bundled .NET runtime -│ └── dotnet -│ -├── dashboard/ # Pre-built Dashboard -│ └── Aspire.Dashboard +├── managed/ # Unified managed binary (self-contained) +│ └── aspire-managed # Subcommands: dashboard | server | nuget │ ├── dcp/ # Developer Control Plane │ └── dcp │ -├── aspire-server/ # Pre-built AppHost Server (polyglot) -│ └── aspire-server -│ -├── tools/ -│ └── aspire-nuget/ # NuGet operations without SDK -│ └── aspire-nuget -│ ├── hives/ # NuGet package hives (preserved across installs) │ └── pr-{number}/ │ └── packages/ @@ -718,7 +672,8 @@ The bundle installs components as siblings under `~/.aspire/`, with the CLI bina - The CLI lives at `~/.aspire/bin/aspire` regardless of install method - With self-extracting binaries, the CLI in `bin/` contains the embedded payload; `aspire setup` extracts siblings - `.aspire-bundle-version` tracks the extracted version — extraction is skipped when hash matches -- Bundle components (`runtime/`, `dashboard/`, `dcp/`, etc.) are siblings at the `~/.aspire/` root +- `aspire-managed` is a single self-contained binary replacing separate runtime, dashboard, aspire-server, and tools directories +- Certificate management is native to the CLI (no external tool needed) - NuGet hives and settings are preserved across installations and re-extractions - `LayoutDiscovery` finds the bundle by checking the CLI's parent directory for components @@ -804,16 +759,12 @@ Downloaded integration packages are cached in: | Component | On Disk | Zipped | |-----------|---------|--------| | DCP (platform-specific) | ~286 MB | ~100 MB | -| .NET 10 Runtime (incl. ASP.NET Core) | ~200 MB | ~70 MB | -| Dashboard (framework-dependent) | ~43 MB | ~15 MB | -| CLI (native AOT) | ~22 MB | ~10 MB | -| AppHost Server (core only) | ~21 MB | ~8 MB | -| NuGet Helper (aspire-nuget) | ~5 MB | ~2 MB | -| Dev-certs Tool | ~0.1 MB | ~0.05 MB | -| **Total** | **~577 MB** | **~204 MB** | - -*AppHost Server includes core hosting only - all integrations are downloaded on-demand.* -*Dashboard is framework-dependent (not self-contained), sharing the bundled .NET runtime.* +| Aspire Managed (self-contained: Dashboard + Server + NuGet + .NET Runtime) | ~65 MB | ~25 MB | +| CLI (native AOT, includes certificate management) | ~22 MB | ~10 MB | +| **Total** | **~373 MB** | **~135 MB** | + +*Aspire Managed is a single self-contained binary that includes the .NET runtime, eliminating the need for a separate runtime directory.* +*Certificate management is handled natively in the CLI — no separate tool needed.* *Sizes vary by platform. Linux tends to be smaller than Windows.* ### Distribution Formats @@ -992,8 +943,7 @@ Tests continue to work because: | `ASPIRE_REPO_ROOT` | Development mode | Uses SDK with project references | | `ASPIRE_LAYOUT_PATH` | Bundle location | Overrides auto-detection | | `ASPIRE_DCP_PATH` | DCP override | Works in both modes | -| `ASPIRE_DASHBOARD_PATH` | Dashboard override | Works in both modes | -| `ASPIRE_RUNTIME_PATH` | .NET runtime override | For guest apphosts only | +| `ASPIRE_MANAGED_PATH` | Aspire-managed override | Works in both modes | ### Migration Path @@ -1295,13 +1245,15 @@ This section tracks the implementation progress of the bundle feature. - Framework-dependent deployment (uses bundled runtime) - [x] **Certificate management** - `src/Aspire.Cli/Certificates/` - `ICertificateToolRunner` abstraction - - `BundleCertificateToolRunner` - uses bundled runtime + dev-certs.dll - - `SdkCertificateToolRunner` - uses global `dotnet dev-certs` + - `NativeCertificateToolRunner` - calls `CertificateManager` directly (no subprocess) + - `CertificateGeneration/` - vendored from aspnetcore, EventSource replaced with ILogger +- [x] **Aspire Managed unified binary** - `src/Aspire.Managed/` + - Self-contained single binary: `aspire-managed dashboard|server|nuget` + - Replaces separate runtime, dashboard, aspire-server, and tools directories - [x] **Bundle build tooling** - `tools/CreateLayout/` - - Downloads .NET SDK and extracts runtime + dev-certs - - Copies DCP, Dashboard, aspire-server, NuGetHelper + - Builds aspire-managed as self-contained single-file binary + - Copies DCP - Generates layout.json metadata - - Enables RollForward=Major for all managed tools - `--embed-in-cli` option creates self-extracting binary - [x] **Installation scripts** - `eng/scripts/get-aspire-cli-bundle-pr.sh`, `eng/scripts/get-aspire-cli-bundle-pr.ps1` - Downloads bundle archive from PR build artifacts @@ -1344,8 +1296,9 @@ This section tracks the implementation progress of the bundle feature. | `src/Aspire.Cli/Projects/GuestAppHostProject.cs` | Main polyglot handler with bundle/SDK mode switching | | `src/Aspire.Hosting/Dcp/DcpOptions.cs` | DCP/Dashboard path resolution with env var support | | `src/Aspire.Cli/Certificates/ICertificateToolRunner.cs` | Certificate tool abstraction | -| `src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs` | Bundled dev-certs runner | -| `src/Aspire.Cli/Certificates/SdkCertificateToolRunner.cs` | SDK-based dev-certs runner | +| `src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs` | Native certificate management (no subprocess) | +| `src/Aspire.Cli/Certificates/CertificateGeneration/` | Vendored CertificateManager from aspnetcore (ILogger-based) | +| `src/Aspire.Managed/Program.cs` | Unified managed binary entry point (dashboard/server/nuget) | | `src/Shared/BundleTrailer.cs` | (Deleted) Previously held trailer read/write logic | | `src/Aspire.Cli/Bundles/IBundleService.cs` | Bundle extraction interface + result enum | | `src/Aspire.Cli/Bundles/BundleService.cs` | Centralized extraction with .NET TarReader | @@ -1361,59 +1314,33 @@ This section tracks the implementation progress of the bundle feature. The bundle is built using the `tools/CreateLayout` tool, which assembles all components into the final bundle layout. -### SDK Download Approach +### Aspire Managed Build -The bundle's .NET runtime is extracted from the official .NET SDK, which provides several advantages: - -1. **Single download**: The SDK contains the runtime, ASP.NET Core framework, and dev-certs tool -2. **Version consistency**: All components come from the same SDK release -3. **Official source**: Direct from Microsoft's build infrastructure +The `aspire-managed` binary is published as a self-contained single-file executable, which includes the .NET runtime. This eliminates the need to separately download and bundle the .NET SDK/runtime. ```text -SDK download (~200 MB) -├── dotnet.exe → runtime/dotnet.exe -├── host/ → runtime/host/ -├── shared/Microsoft.NETCore.App/10.0.x/ → runtime/shared/Microsoft.NETCore.App/ -├── shared/Microsoft.AspNetCore.App/10.0.x/ → runtime/shared/Microsoft.AspNetCore.App/ -├── sdk/10.0.x/DotnetTools/dotnet-dev-certs → tools/dev-certs/ -└── (discard: sdk/, templates/, packs/, etc.) +aspire-managed (self-contained, ~65 MB) +├── .NET 10 Runtime (embedded) +├── ASP.NET Core Framework (embedded) +├── Aspire.Dashboard (embedded) +├── Aspire.Hosting.RemoteHost / aspire-server (embedded) +├── Aspire.Cli.NuGetHelper (embedded) +└── All managed dependencies ``` -The SDK version is discovered dynamically from `https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json`. - -### RollForward Configuration - -All managed tools in the bundle are configured with `rollForward: Major` in their runtimeconfig.json files. This allows: - -- Tools built for .NET 8.0 or 9.0 to run on the bundled .NET 10+ runtime -- Maximum compatibility with older Aspire.Hosting packages -- Simpler bundle maintenance (single runtime version) - -The CreateLayout tool automatically patches all `*.runtimeconfig.json` files: - -```json -{ - "runtimeOptions": { - "rollForward": "Major", - "framework": { - "name": "Microsoft.AspNetCore.App", - "version": "8.0.0" - } - } -} -``` +**Advantages over the previous SDK-download approach:** +1. **Simpler build**: No SDK download or extraction step +2. **Smaller total size**: Single binary with tree-shaking vs full runtime directory +3. **Single file**: One binary instead of hundreds of files across multiple directories +4. **Version consistency**: All components compiled together ### Build Steps -1. **Download .NET SDK** for the target platform -2. **Extract runtime components** (muxer, host, shared frameworks) -3. **Extract dev-certs tool** from `sdk/*/DotnetTools/dotnet-dev-certs/` -4. **Build and copy managed tools** (aspire-server, aspire-dashboard, NuGetHelper) -5. **Download and copy DCP** binaries -6. **Patch runtimeconfig.json files** to enable RollForward=Major -7. **Generate layout.json** with component metadata -8. **Create archive** (tar.gz for Unix, ZIP for Windows) with `COPYFILE_DISABLE=1` to suppress macOS xattr headers -9. **Create self-extracting binary** — appends tar.gz payload + 32-byte trailer to native AOT CLI +1. **Build aspire-managed** as a self-contained single-file binary (includes .NET runtime, Dashboard, AppHost Server, NuGet Helper) +2. **Download and copy DCP** binaries +3. **Generate layout.json** with component metadata +4. **Create archive** (tar.gz for Unix, ZIP for Windows) with `COPYFILE_DISABLE=1` to suppress macOS xattr headers +5. **Create self-extracting binary** — appends tar.gz payload + 32-byte trailer to native AOT CLI ### Self-Extracting Binary Build From f9347d57f0bbc5a06df77b1b17438b0c6a3f1599 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 12:22:07 -0800 Subject: [PATCH 07/14] Address PR review feedback: fix SYSLIB0057/IDE1006 warnings, clean up comments --- eng/Bundle.proj | 3 +- eng/Versions.props | 1 - src/Aspire.Cli/Aspire.Cli.csproj | 2 +- .../CertificateManager.cs | 2 +- .../MacOSCertificateManager.cs | 40 +++++++++---------- .../UnixCertificateManager.cs | 2 +- .../WindowsCertificateManager.cs | 2 +- .../Projects/PrebuiltAppHostServer.cs | 2 +- .../Dashboard/DashboardEventHandlers.cs | 2 +- 9 files changed, 27 insertions(+), 29 deletions(-) diff --git a/eng/Bundle.proj b/eng/Bundle.proj index 4875e9ae878..626184870d4 100644 --- a/eng/Bundle.proj +++ b/eng/Bundle.proj @@ -46,8 +46,7 @@ $(VersionPrefix)-$(VersionSuffix) $(VersionPrefix)-dev - + <_VersionSuffixArg Condition="'$(VersionSuffix)' != ''">/p:VersionSuffix=$(VersionSuffix) diff --git a/eng/Versions.props b/eng/Versions.props index 048cd2e58ef..a11eff37881 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -15,7 +15,6 @@ 8.0.415 9.0.306 - 3.1.0 1.21.0 3.1.0 diff --git a/src/Aspire.Cli/Aspire.Cli.csproj b/src/Aspire.Cli/Aspire.Cli.csproj index efdf4b4233f..654c480a3a3 100644 --- a/src/Aspire.Cli/Aspire.Cli.csproj +++ b/src/Aspire.Cli/Aspire.Cli.csproj @@ -9,7 +9,7 @@ false aspire Aspire.Cli - $(NoWarn);CS1591;SYSLIB0057;IDE1006 + $(NoWarn);CS1591 true Size $(DefineConstants);CLI diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs index 31fa6d97c64..dc0b977f7f1 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs @@ -517,7 +517,7 @@ internal ImportCertificateResult ImportCertificate(string certificatePath, strin try { Log.LoadCertificateStart(certificatePath); - certificate = new X509Certificate2(certificatePath, password, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); + certificate = X509CertificateLoader.LoadPkcs12FromFile(certificatePath, password, X509KeyStorageFlags.Exportable | X509KeyStorageFlags.EphemeralKeySet); if (Log.IsEnabled()) { Log.LoadCertificateEnd(GetDescription(certificate)); diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs index 1dff6b8e2f7..cce4cc10ce9 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs @@ -22,7 +22,7 @@ internal sealed class MacOSCertificateManager : CertificateManager // User keychain. Guard with quotes when using in command lines since users may have set // their user profile (HOME) directory to a non-standard path that includes whitespace. - private static readonly string MacOSUserKeychain = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/Library/Keychains/login.keychain-db"; + private static readonly string s_macOSUserKeychain = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/Library/Keychains/login.keychain-db"; // System keychain. We no longer store certificates or create trust rules in the system // keychain, but check for their presence here so that we can clean up state left behind @@ -30,7 +30,7 @@ internal sealed class MacOSCertificateManager : CertificateManager private const string MacOSSystemKeychain = "/Library/Keychains/System.keychain"; // Well-known location on disk where dev-certs are stored. - private static readonly string MacOSUserHttpsCertificateLocation = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspnet", "dev-certs", "https"); + private static readonly string s_macOSUserHttpsCertificateLocation = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".aspnet", "dev-certs", "https"); // Verify the certificate {0} for the SSL and X.509 Basic Policy. private const string MacOSVerifyCertificateCommandLine = "security"; @@ -44,12 +44,12 @@ internal sealed class MacOSCertificateManager : CertificateManager // for the certificate will be set to be always trusted for SSL and X.509 Basic Policy. // Note: This operation will require user authentication. private const string MacOSTrustCertificateCommandLine = "security"; - private static readonly string MacOSTrustCertificateCommandLineArguments = $"add-trusted-cert -p basic -p ssl -k \"{MacOSUserKeychain}\" "; + private static readonly string s_macOSTrustCertificateCommandLineArguments = $"add-trusted-cert -p basic -p ssl -k \"{s_macOSUserKeychain}\" "; // Import a pkcs12 certificate into the user keychain using the unwrapping passphrase {1}, and // allow any application to access the imported key without warning. private const string MacOSAddCertificateToKeyChainCommandLine = "security"; - private static readonly string MacOSAddCertificateToKeyChainCommandLineArgumentsFormat = "import \"{0}\" -k \"" + MacOSUserKeychain + "\" -t cert -f pkcs12 -P {1} -A"; + private static readonly string s_macOSAddCertificateToKeyChainCommandLineArgumentsFormat = "import \"{0}\" -k \"" + s_macOSUserKeychain + "\" -t cert -f pkcs12 -P {1} -A"; // Remove a certificate from the admin trust settings. We no longer add certificates to the // admin trust settings, but need this for cleaning up certs generated by pre-.NET 7 versions @@ -99,9 +99,9 @@ protected override TrustLevel TrustCertificateCore(X509Certificate2 publicCertif ExportCertificate(publicCertificate, tmpFile, includePrivateKey: false, password: null, CertificateKeyExportFormat.Pfx); if (Log.IsEnabled()) { - Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {MacOSTrustCertificateCommandLineArguments}{tmpFile}"); + Log.MacOSTrustCommandStart($"{MacOSTrustCertificateCommandLine} {s_macOSTrustCertificateCommandLineArguments}{tmpFile}"); } - using (var process = Process.Start(MacOSTrustCertificateCommandLine, MacOSTrustCertificateCommandLineArguments + tmpFile)) + using (var process = Process.Start(MacOSTrustCertificateCommandLine, s_macOSTrustCertificateCommandLineArguments + tmpFile)) { process.WaitForExit(); if (process.ExitCode != 0) @@ -273,10 +273,10 @@ private void RemoveCertificateFromKeychain(string keychain, X509Certificate2 cer private static bool IsCertOnKeychain(string keychain, X509Certificate2 certificate) { - TimeSpan MaxRegexTimeout = TimeSpan.FromMinutes(1); + var maxRegexTimeout = TimeSpan.FromMinutes(1); const string CertificateSubjectRegex = "CN=(.*[^,]+).*"; - var subjectMatch = Regex.Match(certificate.Subject, CertificateSubjectRegex, RegexOptions.Singleline, MaxRegexTimeout); + var subjectMatch = Regex.Match(certificate.Subject, CertificateSubjectRegex, RegexOptions.Singleline, maxRegexTimeout); if (!subjectMatch.Success) { throw new InvalidOperationException($"Can't determine the subject for the certificate with subject '{certificate.Subject}'."); @@ -295,7 +295,7 @@ private static bool IsCertOnKeychain(string keychain, X509Certificate2 certifica var output = findCertificateProcess!.StandardOutput.ReadToEnd(); findCertificateProcess.WaitForExit(); - var matches = Regex.Matches(output, MacOSFindCertificateOutputRegex, RegexOptions.Multiline, MaxRegexTimeout); + var matches = Regex.Matches(output, MacOSFindCertificateOutputRegex, RegexOptions.Multiline, maxRegexTimeout); var hashes = matches.OfType().Select(m => m.Groups[1].Value).ToList(); return hashes.Any(h => string.Equals(h, certificate.Thumbprint, StringComparison.Ordinal)); @@ -314,11 +314,11 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi if (Log.IsEnabled()) { - Log.MacOSAddCertificateToUserProfileDirStart(MacOSUserKeychain, GetDescription(certificate)); + Log.MacOSAddCertificateToUserProfileDirStart(s_macOSUserKeychain, GetDescription(certificate)); } // Ensure that the directory exists before writing to the file. - CreateDirectoryWithPermissions(MacOSUserHttpsCertificateLocation); + CreateDirectoryWithPermissions(s_macOSUserHttpsCertificateLocation); File.WriteAllBytes(GetCertificateFilePath(certificate), certBytes); } @@ -344,7 +344,7 @@ private void SaveCertificateToUserKeychain(X509Certificate2 certificate) var processInfo = new ProcessStartInfo( MacOSAddCertificateToKeyChainCommandLine, - string.Format(CultureInfo.InvariantCulture, MacOSAddCertificateToKeyChainCommandLineArgumentsFormat, certificatePath, password)) + string.Format(CultureInfo.InvariantCulture, s_macOSAddCertificateToKeyChainCommandLineArgumentsFormat, certificatePath, password)) { RedirectStandardOutput = true, RedirectStandardError = true @@ -352,7 +352,7 @@ private void SaveCertificateToUserKeychain(X509Certificate2 certificate) if (Log.IsEnabled()) { - Log.MacOSAddCertificateToKeyChainStart(MacOSUserKeychain, GetDescription(certificate)); + Log.MacOSAddCertificateToKeyChainStart(s_macOSUserKeychain, GetDescription(certificate)); } using (var process = Process.Start(processInfo)) @@ -371,7 +371,7 @@ private void SaveCertificateToUserKeychain(X509Certificate2 certificate) } private static string GetCertificateFilePath(X509Certificate2 certificate) => - Path.Combine(MacOSUserHttpsCertificateLocation, $"aspnetcore-localhost-{certificate.Thumbprint}.pfx"); + Path.Combine(s_macOSUserHttpsCertificateLocation, $"aspnetcore-localhost-{certificate.Thumbprint}.pfx"); protected override IList GetCertificatesToRemove(StoreName storeName, StoreLocation storeLocation) { @@ -380,7 +380,7 @@ protected override IList GetCertificatesToRemove(StoreName sto protected override void PopulateCertificatesFromStore(X509Store store, List certificates, bool requireExportable) { - if (store.Name! == StoreName.My.ToString() && store.Location == StoreLocation.CurrentUser && Directory.Exists(MacOSUserHttpsCertificateLocation)) + if (store.Name! == StoreName.My.ToString() && store.Location == StoreLocation.CurrentUser && Directory.Exists(s_macOSUserHttpsCertificateLocation)) { var certsFromDisk = GetCertsFromDisk(); @@ -431,18 +431,18 @@ int IEqualityComparer.GetHashCode([DisallowNull] X509Certifica private ICollection GetCertsFromDisk() { var certsFromDisk = new List(); - if (!Directory.Exists(MacOSUserHttpsCertificateLocation)) + if (!Directory.Exists(s_macOSUserHttpsCertificateLocation)) { Log.MacOSDiskStoreDoesNotExist(); } else { - var certificateFiles = Directory.EnumerateFiles(MacOSUserHttpsCertificateLocation, "aspnetcore-localhost-*.pfx"); + var certificateFiles = Directory.EnumerateFiles(s_macOSUserHttpsCertificateLocation, "aspnetcore-localhost-*.pfx"); foreach (var file in certificateFiles) { try { - var certificate = new X509Certificate2(file); + var certificate = X509CertificateLoader.LoadPkcs12FromFile(file, password: null); certsFromDisk.Add(certificate); } catch (Exception) @@ -471,9 +471,9 @@ protected override void RemoveCertificateFromUserStoreCore(X509Certificate2 cert Log.MacOSRemoveCertificateFromUserProfileDirError(certificate.Thumbprint, ex.Message); } - if (IsCertOnKeychain(MacOSUserKeychain, certificate)) + if (IsCertOnKeychain(s_macOSUserKeychain, certificate)) { - RemoveCertificateFromKeychain(MacOSUserKeychain, certificate); + RemoveCertificateFromKeychain(s_macOSUserKeychain, certificate); } } diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs index 6bbae7c654f..d5efbc01cdb 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs @@ -172,7 +172,7 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi { var export = certificate.Export(X509ContentType.Pkcs12, ""); certificate.Dispose(); - certificate = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + certificate = X509CertificateLoader.LoadPkcs12(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); Array.Clear(export, 0, export.Length); using (var store = new X509Store(storeName, storeLocation)) diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs index 119d70e21e8..b06626256a0 100644 --- a/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs @@ -55,7 +55,7 @@ protected override X509Certificate2 SaveCertificateCore(X509Certificate2 certifi // key that we generated gets persisted. var export = certificate.Export(X509ContentType.Pkcs12, ""); certificate.Dispose(); - certificate = new X509Certificate2(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); + certificate = X509CertificateLoader.LoadPkcs12(export, "", X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); Array.Clear(export, 0, export.Length); certificate.FriendlyName = AspNetHttpsOidFriendlyName; diff --git a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs index 0a1df3e69a2..5b8d8f7a776 100644 --- a/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs +++ b/src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs @@ -267,7 +267,7 @@ public async Task PrepareAsync( startInfo.Environment[BundleDiscovery.DcpPathEnvVar] = dcpPath; } - // Set ASPIRE_DASHBOARD_PATH to the aspire-managed exe (DashboardEventHandlers will detect it) + // Set the dashboard path so the AppHost can locate and launch the dashboard binary var managedPath = _layout.GetManagedPath(); if (managedPath is not null) { diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index d2a140c291c..4f284e1c136 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -292,7 +292,7 @@ private void AddDashboardResource(DistributedApplicationModel model) if (BundleDiscovery.IsAspireManagedBinary(fullyQualifiedDashboardPath)) { - // aspire-managed: self-contained binary, no DOTNET_ROOT or custom runtime config needed + // aspire-managed is self-contained, run directly with "dashboard" subcommand dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, fullyQualifiedDashboardPath, dashboardWorkingDirectory ?? ""); // Prepend "dashboard" subcommand From 531d1efbea3ee90b5f7742d1a1dfbefd7c4d2f30 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 12:37:13 -0800 Subject: [PATCH 08/14] Remove old dashboard launch paths (IsSingleFileExecutable, dotnet exec, runtime config) --- .../Dashboard/DashboardEventHandlers.cs | 309 +----------------- 1 file changed, 3 insertions(+), 306 deletions(-) diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 4f284e1c136..837ab27b308 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -7,7 +7,6 @@ using System.Diagnostics; using System.Globalization; using System.Net.Sockets; -using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -39,8 +38,7 @@ internal sealed class DashboardEventHandlers(IConfiguration configuration, DcpNameGenerator nameGenerator, IHostApplicationLifetime hostApplicationLifetime, IDistributedApplicationEventing eventing, - CodespacesUrlRewriter codespaceUrlRewriter, - IFileSystemService directoryService + CodespacesUrlRewriter codespaceUrlRewriter ) : IDistributedApplicationEventingSubscriber, IAsyncDisposable { // Internal for testing @@ -48,11 +46,6 @@ IFileSystemService directoryService internal const string OtlpHttpEndpointName = "otlp-http"; internal const string McpEndpointName = "mcp"; - // Fallback defaults for framework versions and TFM - private const string FallbackTargetFrameworkMoniker = "net8.0"; - private const string FallbackNetCoreVersion = "8.0.0"; - private const string FallbackAspNetCoreVersion = "8.0.0"; - private static readonly HashSet s_suppressAutomaticConfigurationCopy = new HashSet(StringComparer.OrdinalIgnoreCase) { KnownConfigNames.DashboardCorsAllowedOrigins // Set on the dashboard's Dashboard:Otlp:Cors type @@ -60,7 +53,6 @@ IFileSystemService directoryService private Task? _dashboardLogsTask; private CancellationTokenSource? _dashboardLogsCts; - private string? _customRuntimeConfigPath; public Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken cancellationToken) { @@ -89,195 +81,6 @@ public Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken cancel return Task.CompletedTask; } - private static (string NetCoreVersion, string AspNetCoreVersion) GetAppHostFrameworkVersions() - { - try - { - // Get the entry assembly location (the AppHost) - var entryAssembly = Assembly.GetEntryAssembly(); - if (entryAssembly?.Location is null or { Length: 0 }) - { - // Fallback to process main module if entry assembly location is not available - var mainModule = Process.GetCurrentProcess().MainModule; - if (mainModule?.FileName is null) - { - // Final fallback to runtime detection if we can't find AppHost location - return GetFallbackFrameworkVersions(); - } - return GetFrameworkVersionsFromRuntimeConfig(mainModule.FileName); - } - - return GetFrameworkVersionsFromRuntimeConfig(entryAssembly.Location); - } - catch (Exception) - { - // If we can't read the AppHost's runtime config, fallback to runtime detection - return GetFallbackFrameworkVersions(); - } - } - - private static (string NetCoreVersion, string AspNetCoreVersion) GetFrameworkVersionsFromRuntimeConfig(string assemblyPath) - { - // Find the AppHost's runtimeconfig.json file - string runtimeConfigPath; - if (string.Equals(".dll", Path.GetExtension(assemblyPath), StringComparison.OrdinalIgnoreCase)) - { - runtimeConfigPath = Path.ChangeExtension(assemblyPath, ".runtimeconfig.json"); - } - else - { - // For executables, the runtime config is named after the base executable name - // Handle both Windows (.exe) and Unix (no extension) executables - var directory = Path.GetDirectoryName(assemblyPath)!; - var fileName = Path.GetFileName(assemblyPath); - var baseName = Path.GetExtension(fileName) switch - { - ".exe" => Path.GetFileNameWithoutExtension(fileName), // Windows: remove .exe - _ => fileName // Unix or other: use full filename as base - }; - runtimeConfigPath = Path.Combine(directory, $"{baseName}.runtimeconfig.json"); - } - - if (!File.Exists(runtimeConfigPath)) - { - // Fallback to runtime detection if runtime config doesn't exist - return GetFallbackFrameworkVersions(); - } - - // Parse the AppHost's runtime config to get framework versions - var configText = File.ReadAllText(runtimeConfigPath); - var configJson = JsonNode.Parse(configText)?.AsObject(); - - if (configJson is null) - { - throw new DistributedApplicationException($"Failed to parse AppHost runtime config: {runtimeConfigPath}"); - } - - string netCoreVersion = FallbackNetCoreVersion; // Default fallback - string aspNetCoreVersion = FallbackAspNetCoreVersion; // Default fallback - - if (configJson["runtimeOptions"]?.AsObject() is { } runtimeOptions && - runtimeOptions["frameworks"]?.AsArray() is { } frameworks) - { - foreach (var framework in frameworks) - { - if (framework?.AsObject() is { } frameworkObj && - frameworkObj["name"]?.GetValue() is { } name && - frameworkObj["version"]?.GetValue() is { } version) - { - switch (name) - { - case "Microsoft.NETCore.App": - netCoreVersion = version; - break; - case "Microsoft.AspNetCore.App": - aspNetCoreVersion = version; - break; - } - } - } - } - - return (netCoreVersion, aspNetCoreVersion); - } - - private static (string NetCoreVersion, string AspNetCoreVersion) GetFallbackFrameworkVersions() - { - return (FallbackNetCoreVersion, FallbackAspNetCoreVersion); - } - - private string CreateCustomRuntimeConfig(string dashboardPath) - { - // Find the dashboard runtimeconfig.json - string originalRuntimeConfig; - - if (string.Equals(".dll", Path.GetExtension(dashboardPath), StringComparison.OrdinalIgnoreCase)) - { - // Dashboard path is already a DLL - originalRuntimeConfig = Path.ChangeExtension(dashboardPath, ".runtimeconfig.json"); - } - else - { - // For executables, the runtime config is named after the base executable name - // Handle both Windows (.exe) and Unix (no extension) executables - var directory = Path.GetDirectoryName(dashboardPath)!; - var fileName = Path.GetFileName(dashboardPath); - var baseName = Path.GetExtension(fileName) switch - { - ".exe" => Path.GetFileNameWithoutExtension(fileName), // Windows: remove .exe - _ => fileName // Unix or other: use full filename as base - }; - originalRuntimeConfig = Path.Combine(directory, $"{baseName}.runtimeconfig.json"); - } - - if (!File.Exists(originalRuntimeConfig)) - { - // In test environments or when the dashboard runtime config doesn't exist, - // create a default configuration using the AppHost's framework versions - var (appHostNetCoreVersion, appHostAspNetCoreVersion) = GetAppHostFrameworkVersions(); - - var defaultConfig = new - { - runtimeOptions = new - { - tfm = FallbackTargetFrameworkMoniker, - frameworks = new[] - { - new { name = "Microsoft.NETCore.App", version = appHostNetCoreVersion }, - new { name = "Microsoft.AspNetCore.App", version = appHostAspNetCoreVersion } - } - } - }; - - var customConfigPath = directoryService.TempDirectory.CreateTempFile("runtimeconfig.json").Path; - File.WriteAllText(customConfigPath, JsonSerializer.Serialize(defaultConfig, new JsonSerializerOptions { WriteIndented = true })); - - _customRuntimeConfigPath = customConfigPath; - return customConfigPath; - } - - // Read the original runtime config - var originalConfigText = File.ReadAllText(originalRuntimeConfig); - var configJson = JsonNode.Parse(originalConfigText)?.AsObject(); - - if (configJson is null) - { - throw new DistributedApplicationException($"Failed to parse dashboard runtime config: {originalRuntimeConfig}"); - } - - // Get AppHost framework versions from its runtimeconfig.json - var (netCoreVersion, aspNetCoreVersion) = GetAppHostFrameworkVersions(); - - // Update the framework versions - if (configJson["runtimeOptions"]?.AsObject() is { } runtimeOptions && - runtimeOptions["frameworks"]?.AsArray() is { } frameworks) - { - foreach (var framework in frameworks) - { - if (framework?.AsObject() is { } frameworkObj && - frameworkObj["name"]?.GetValue() is { } name) - { - switch (name) - { - case "Microsoft.NETCore.App": - frameworkObj["version"] = netCoreVersion; - break; - case "Microsoft.AspNetCore.App": - frameworkObj["version"] = aspNetCoreVersion; - break; - } - } - } - } - - // Create a temporary file for the custom runtime config - var tempPath = directoryService.TempDirectory.CreateTempFile("runtimeconfig.json").Path; - File.WriteAllText(tempPath, configJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); - - _customRuntimeConfigPath = tempPath; - return tempPath; - } - private void AddDashboardResource(DistributedApplicationModel model) { if (dashboardOptions.Value.DashboardPath is not { } dashboardPath) @@ -288,64 +91,16 @@ private void AddDashboardResource(DistributedApplicationModel model) var fullyQualifiedDashboardPath = Path.GetFullPath(dashboardPath); var dashboardWorkingDirectory = Path.GetDirectoryName(fullyQualifiedDashboardPath); - ExecutableResource dashboardResource; + var dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, fullyQualifiedDashboardPath, dashboardWorkingDirectory ?? ""); if (BundleDiscovery.IsAspireManagedBinary(fullyQualifiedDashboardPath)) { - // aspire-managed is self-contained, run directly with "dashboard" subcommand - dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, fullyQualifiedDashboardPath, dashboardWorkingDirectory ?? ""); - - // Prepend "dashboard" subcommand + // Prepend "dashboard" subcommand for aspire-managed dashboardResource.Annotations.Add(new CommandLineArgsCallbackAnnotation(args => { args.Insert(0, "dashboard"); })); } - else if (IsSingleFileExecutable(fullyQualifiedDashboardPath)) - { - // Create custom runtime config with AppHost's framework versions - var customRuntimeConfigPath = CreateCustomRuntimeConfig(fullyQualifiedDashboardPath); - - // Single-file executable - run directly - dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, fullyQualifiedDashboardPath, dashboardWorkingDirectory ?? ""); - } - else - { - // Create custom runtime config with AppHost's framework versions - var customRuntimeConfigPath = CreateCustomRuntimeConfig(fullyQualifiedDashboardPath); - - // DLL-based deployment - find the DLL and run via dotnet exec - string dashboardDll; - if (string.Equals(".dll", Path.GetExtension(fullyQualifiedDashboardPath), StringComparison.OrdinalIgnoreCase)) - { - dashboardDll = fullyQualifiedDashboardPath; - } - else - { - // For executables with separate DLLs - var directory = Path.GetDirectoryName(fullyQualifiedDashboardPath)!; - var fileName = Path.GetFileName(fullyQualifiedDashboardPath); - var baseName = fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) - ? fileName.Substring(0, fileName.Length - 4) - : fileName; - dashboardDll = Path.Combine(directory, $"{baseName}.dll"); - } - - if (!File.Exists(dashboardDll)) - { - distributedApplicationLogger.LogError("Dashboard DLL not found: {Path}", dashboardDll); - } - - dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, "dotnet", dashboardWorkingDirectory ?? ""); - - dashboardResource.Annotations.Add(new CommandLineArgsCallbackAnnotation(args => - { - args.Add("exec"); - args.Add("--runtimeconfig"); - args.Add(customRuntimeConfigPath); - args.Add(dashboardDll); - })); - } nameGenerator.EnsureDcpInstancesPopulated(dashboardResource); @@ -909,64 +664,6 @@ public async ValueTask DisposeAsync() distributedApplicationLogger.LogError(ex, "Unexpected error while watching dashboard logs."); } } - - // Clean up the temporary runtime config file - if (_customRuntimeConfigPath is not null) - { - try - { - File.Delete(_customRuntimeConfigPath); - } - catch (Exception ex) - { - distributedApplicationLogger.LogWarning(ex, "Failed to delete temporary runtime config file: {Path}", _customRuntimeConfigPath); - } - } - } - - /// - /// Determines if the given path is a single-file executable (no accompanying DLL). - /// - private static bool IsSingleFileExecutable(string path) - { - // Single-file apps are executables without a corresponding DLL. - // On Windows the file ends with .exe; on Unix there is no reliable - // extension (e.g. "Aspire.Dashboard" has a dot but is still an executable). - // The definitive check is: executable exists on disk and there is no - // matching .dll next to it. - - if (string.Equals(".dll", Path.GetExtension(path), StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!File.Exists(path)) - { - return false; - } - - // On Unix, verify the file is executable - if (!OperatingSystem.IsWindows()) - { - var fileInfo = new FileInfo(path); - var mode = fileInfo.UnixFileMode; - if ((mode & (UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute)) == 0) - { - return false; - } - } - - // Check if there's a corresponding DLL — strip .exe on Windows, - // but on Unix the filename may contain dots (e.g. "Aspire.Dashboard"), - // so always derive the DLL name by appending .dll to the full filename. - var directory = Path.GetDirectoryName(path)!; - var fileName = Path.GetFileName(path); - var baseName = fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) - ? fileName[..^4] - : fileName; - var dllPath = Path.Combine(directory, $"{baseName}.dll"); - - return !File.Exists(dllPath); } } From 930d4e5ef62ebf11d1422d6945388072a15b8e20 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 12:41:10 -0800 Subject: [PATCH 09/14] Merge NuGetHelper into Aspire.Managed, remove separate project --- Aspire.slnx | 1 - .../Aspire.Cli.NuGetHelper.csproj | 24 -------------- src/Aspire.Cli.NuGetHelper/Program.cs | 31 ------------------- src/Aspire.Managed/Aspire.Managed.csproj | 8 ++++- .../NuGet}/Commands/LayoutCommand.cs | 2 +- .../NuGet}/Commands/RestoreCommand.cs | 2 +- .../NuGet}/Commands/SearchCommand.cs | 2 +- .../NuGet}/NuGetLogger.cs | 2 +- src/Aspire.Managed/Program.cs | 8 ++++- 9 files changed, 18 insertions(+), 62 deletions(-) delete mode 100644 src/Aspire.Cli.NuGetHelper/Aspire.Cli.NuGetHelper.csproj delete mode 100644 src/Aspire.Cli.NuGetHelper/Program.cs rename src/{Aspire.Cli.NuGetHelper => Aspire.Managed/NuGet}/Commands/LayoutCommand.cs (99%) rename src/{Aspire.Cli.NuGetHelper => Aspire.Managed/NuGet}/Commands/RestoreCommand.cs (99%) rename src/{Aspire.Cli.NuGetHelper => Aspire.Managed/NuGet}/Commands/SearchCommand.cs (99%) rename src/{Aspire.Cli.NuGetHelper => Aspire.Managed/NuGet}/NuGetLogger.cs (98%) diff --git a/Aspire.slnx b/Aspire.slnx index 9a174df70c9..15240a498ee 100644 --- a/Aspire.slnx +++ b/Aspire.slnx @@ -370,7 +370,6 @@ - diff --git a/src/Aspire.Cli.NuGetHelper/Aspire.Cli.NuGetHelper.csproj b/src/Aspire.Cli.NuGetHelper/Aspire.Cli.NuGetHelper.csproj deleted file mode 100644 index 842f7e446f9..00000000000 --- a/src/Aspire.Cli.NuGetHelper/Aspire.Cli.NuGetHelper.csproj +++ /dev/null @@ -1,24 +0,0 @@ - - - - Exe - net10.0 - enable - enable - aspire-nuget - Aspire.Cli.NuGetHelper - false - false - - false - - - - - - - - - - - diff --git a/src/Aspire.Cli.NuGetHelper/Program.cs b/src/Aspire.Cli.NuGetHelper/Program.cs deleted file mode 100644 index b2da6ba23bf..00000000000 --- a/src/Aspire.Cli.NuGetHelper/Program.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.CommandLine; -using Aspire.Cli.NuGetHelper.Commands; - -namespace Aspire.Cli.NuGetHelper; - -/// -/// NuGet Helper Tool - Provides NuGet operations for the Aspire CLI bundle. -/// This tool runs under the bundled .NET runtime and provides package search, -/// restore, and layout generation functionality without requiring the .NET SDK. -/// -public static class Program -{ - /// - /// Entry point for the NuGet Helper tool. - /// - /// Command line arguments. - /// Exit code (0 for success). - public static async Task Main(string[] args) - { - var rootCommand = new RootCommand("Aspire NuGet Helper - Package operations for Aspire CLI bundle"); - - rootCommand.Subcommands.Add(SearchCommand.Create()); - rootCommand.Subcommands.Add(RestoreCommand.Create()); - rootCommand.Subcommands.Add(LayoutCommand.Create()); - - return await rootCommand.Parse(args).InvokeAsync().ConfigureAwait(false); - } -} diff --git a/src/Aspire.Managed/Aspire.Managed.csproj b/src/Aspire.Managed/Aspire.Managed.csproj index f6cef5ec606..2226596ef83 100644 --- a/src/Aspire.Managed/Aspire.Managed.csproj +++ b/src/Aspire.Managed/Aspire.Managed.csproj @@ -24,7 +24,13 @@ - + + + + + + + diff --git a/src/Aspire.Cli.NuGetHelper/Commands/LayoutCommand.cs b/src/Aspire.Managed/NuGet/Commands/LayoutCommand.cs similarity index 99% rename from src/Aspire.Cli.NuGetHelper/Commands/LayoutCommand.cs rename to src/Aspire.Managed/NuGet/Commands/LayoutCommand.cs index 8d4f98e85b2..ff8d2419818 100644 --- a/src/Aspire.Cli.NuGetHelper/Commands/LayoutCommand.cs +++ b/src/Aspire.Managed/NuGet/Commands/LayoutCommand.cs @@ -5,7 +5,7 @@ using System.Globalization; using NuGet.ProjectModel; -namespace Aspire.Cli.NuGetHelper.Commands; +namespace Aspire.Managed.NuGet.Commands; /// /// Layout command - creates a flat DLL layout from a project.assets.json file. diff --git a/src/Aspire.Cli.NuGetHelper/Commands/RestoreCommand.cs b/src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs similarity index 99% rename from src/Aspire.Cli.NuGetHelper/Commands/RestoreCommand.cs rename to src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs index 58ad8705ba0..0507b06af52 100644 --- a/src/Aspire.Cli.NuGetHelper/Commands/RestoreCommand.cs +++ b/src/Aspire.Managed/NuGet/Commands/RestoreCommand.cs @@ -12,7 +12,7 @@ using NuGet.Protocol.Core.Types; using NuGet.Versioning; -namespace Aspire.Cli.NuGetHelper.Commands; +namespace Aspire.Managed.NuGet.Commands; /// /// Restore command - restores NuGet packages without requiring a .csproj file. diff --git a/src/Aspire.Cli.NuGetHelper/Commands/SearchCommand.cs b/src/Aspire.Managed/NuGet/Commands/SearchCommand.cs similarity index 99% rename from src/Aspire.Cli.NuGetHelper/Commands/SearchCommand.cs rename to src/Aspire.Managed/NuGet/Commands/SearchCommand.cs index d61272e5228..d738110ce88 100644 --- a/src/Aspire.Cli.NuGetHelper/Commands/SearchCommand.cs +++ b/src/Aspire.Managed/NuGet/Commands/SearchCommand.cs @@ -10,7 +10,7 @@ using NuGet.Protocol.Core.Types; using INuGetLogger = NuGet.Common.ILogger; -namespace Aspire.Cli.NuGetHelper.Commands; +namespace Aspire.Managed.NuGet.Commands; /// /// Search command - searches NuGet feeds for packages using NuGet.Protocol. diff --git a/src/Aspire.Cli.NuGetHelper/NuGetLogger.cs b/src/Aspire.Managed/NuGet/NuGetLogger.cs similarity index 98% rename from src/Aspire.Cli.NuGetHelper/NuGetLogger.cs rename to src/Aspire.Managed/NuGet/NuGetLogger.cs index ce28391a5f1..247e9f6907d 100644 --- a/src/Aspire.Cli.NuGetHelper/NuGetLogger.cs +++ b/src/Aspire.Managed/NuGet/NuGetLogger.cs @@ -5,7 +5,7 @@ using NuGetLogMessage = NuGet.Common.ILogMessage; using INuGetLogger = NuGet.Common.ILogger; -namespace Aspire.Cli.NuGetHelper; +namespace Aspire.Managed.NuGet; /// /// Console logger adapter for NuGet operations. diff --git a/src/Aspire.Managed/Program.cs b/src/Aspire.Managed/Program.cs index 5deccea0c79..ff1e24f37d7 100644 --- a/src/Aspire.Managed/Program.cs +++ b/src/Aspire.Managed/Program.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using Aspire.Dashboard; +using Aspire.Managed.NuGet.Commands; +using System.CommandLine; return args switch { @@ -31,7 +33,11 @@ static async Task RunServer(string[] args) static async Task RunNuGet(string[] args) { - return await Aspire.Cli.NuGetHelper.Program.Main(args).ConfigureAwait(false); + var rootCommand = new RootCommand("Aspire NuGet Helper - Package operations for Aspire CLI bundle"); + rootCommand.Subcommands.Add(SearchCommand.Create()); + rootCommand.Subcommands.Add(RestoreCommand.Create()); + rootCommand.Subcommands.Add(LayoutCommand.Create()); + return await rootCommand.Parse(args).InvokeAsync().ConfigureAwait(false); } static int ShowUsage() From 984b0cb5fe24134c4c870930888ae342b4ae6436 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 12:42:10 -0800 Subject: [PATCH 10/14] Update spec and docs for NuGetHelper merge into Aspire.Managed --- docs/specs/bundle.md | 10 +++++----- tools/CreateLayout/README.md | 4 +--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/specs/bundle.md b/docs/specs/bundle.md index af242ce0496..fba3c233596 100644 --- a/docs/specs/bundle.md +++ b/docs/specs/bundle.md @@ -916,7 +916,7 @@ Changes to internal CLI classes maintain backward compatibility through: 3. **Graceful degradation** - If bundle components are missing, fall back to SDK - - If NuGetHelper is unavailable, fall back to `dotnet` commands + - If NuGet operations are unavailable, fall back to `dotnet` commands - Error messages guide users to resolution ### Test Compatibility @@ -1221,7 +1221,7 @@ This section tracks the implementation progress of the bundle feature. - [x] **Layout discovery service** - `src/Aspire.Cli/Layout/LayoutDiscovery.cs` - [x] **Layout process runner** - `src/Aspire.Cli/Layout/LayoutProcessRunner.cs` - [x] **Bundle NuGet service** - `src/Aspire.Cli/NuGet/BundleNuGetService.cs` -- [x] **NuGet Helper tool** - `src/Aspire.Cli.NuGetHelper/` +- [x] **NuGet operations** - embedded in `src/Aspire.Managed/NuGet/` - [x] Search command (NuGet v3 HTTP API) - [x] Restore command (NuGet RestoreRunner) - [x] Layout command (flat DLL + XML doc layout from project.assets.json) @@ -1291,7 +1291,7 @@ This section tracks the implementation progress of the bundle feature. | `src/Aspire.Cli/Layout/LayoutDiscovery.cs` | Priority-based layout discovery (env > config > relative) | | `src/Aspire.Cli/Layout/LayoutProcessRunner.cs` | Run managed DLLs via layout's .NET runtime | | `src/Aspire.Cli/NuGet/BundleNuGetService.cs` | NuGet operations wrapper for bundle mode | -| `src/Aspire.Cli.NuGetHelper/` | Managed tool for search/restore/layout | +| `src/Aspire.Managed/NuGet/` | NuGet search/restore/layout commands (embedded in aspire-managed) | | `src/Aspire.Cli/Projects/PrebuiltAppHostServer.cs` | Bundle-mode server runner | | `src/Aspire.Cli/Projects/GuestAppHostProject.cs` | Main polyglot handler with bundle/SDK mode switching | | `src/Aspire.Hosting/Dcp/DcpOptions.cs` | DCP/Dashboard path resolution with env var support | @@ -1324,7 +1324,7 @@ aspire-managed (self-contained, ~65 MB) ├── ASP.NET Core Framework (embedded) ├── Aspire.Dashboard (embedded) ├── Aspire.Hosting.RemoteHost / aspire-server (embedded) -├── Aspire.Cli.NuGetHelper (embedded) +├── NuGet Commands (embedded) └── All managed dependencies ``` @@ -1336,7 +1336,7 @@ aspire-managed (self-contained, ~65 MB) ### Build Steps -1. **Build aspire-managed** as a self-contained single-file binary (includes .NET runtime, Dashboard, AppHost Server, NuGet Helper) +1. **Build aspire-managed** as a self-contained single-file binary (includes .NET runtime, Dashboard, AppHost Server, NuGet operations) 2. **Download and copy DCP** binaries 3. **Generate layout.json** with component metadata 4. **Create archive** (tar.gz for Unix, ZIP for Windows) with `COPYFILE_DISABLE=1` to suppress macOS xattr headers diff --git a/tools/CreateLayout/README.md b/tools/CreateLayout/README.md index 2178e18df09..012061fe33c 100644 --- a/tools/CreateLayout/README.md +++ b/tools/CreateLayout/README.md @@ -20,9 +20,7 @@ Before running CreateLayout, you must: 1. Build the Aspire solution with the required components published 2. Have the following publish outputs available in the artifacts directory: - - `Aspire.Cli.NuGetHelper` → `artifacts/bin/Aspire.Cli.NuGetHelper/{config}/{tfm}/publish/` - - `Aspire.Hosting.RemoteHost` → `artifacts/bin/Aspire.Hosting.RemoteHost/{config}/{tfm}/publish/` - - `Aspire.Dashboard` → `artifacts/bin/Aspire.Dashboard/{config}/{tfm}/publish/` + - `Aspire.Managed` → `artifacts/bin/Aspire.Managed/{config}/{tfm}/publish/` The build scripts (`./build.sh -bundle` / `./build.cmd -bundle`) handle this automatically. From 3a2668f6824b0b5ec841b7fbb291634ac13a5439 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 21:31:46 -0800 Subject: [PATCH 11/14] Fix DashboardEventHandlers constructor call in tests (removed IFileSystemService param) --- .../Dashboard/DashboardLifecycleHookTests.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index 9f28ed4d74d..5549e5de201 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -596,8 +596,7 @@ private static DashboardEventHandlers CreateHook( new DcpNameGenerator(configuration, Options.Create(new DcpOptions())), new TestHostApplicationLifetime(), eventing ?? new Hosting.Eventing.DistributedApplicationEventing(), - rewriter, - new FileSystemService(configuration) + rewriter ); } From 1b7dba64766190575cdf1b4d377092cdeef351c2 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 22:57:46 -0800 Subject: [PATCH 12/14] Fix dashboard tests: remove old dotnet exec assertions, update to match new direct-path behavior --- .../Dashboard/DashboardLifecycleHookTests.cs | 307 ------------------ .../Dashboard/DashboardResourceTests.cs | 39 +-- 2 files changed, 1 insertion(+), 345 deletions(-) diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index 5549e5de201..8163b5c1e32 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -262,313 +262,6 @@ public async Task ResourceReadyEvent_LogsDashboardUrlFromAllocatedEndpoint(strin Assert.Equal(expectedScheme, uri.Scheme); } - [Fact] - public async Task AddDashboardResource_CreatesExecutableResourceWithCustomRuntimeConfig() - { - // Arrange - var resourceLoggerService = new ResourceLoggerService(); - var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); - var configuration = new ConfigurationBuilder().Build(); - - // Create a temporary test dashboard directory with a dll and runtimeconfig.json - var tempDir = Path.GetTempFileName(); - File.Delete(tempDir); - Directory.CreateDirectory(tempDir); - - try - { - var dashboardDll = Path.Combine(tempDir, "Aspire.Dashboard.dll"); - var runtimeConfig = Path.Combine(tempDir, "Aspire.Dashboard.runtimeconfig.json"); - - // Create a mock DLL file - File.WriteAllText(dashboardDll, "mock dll content"); - - // Create a mock runtime config similar to the real one - var originalConfig = new - { - runtimeOptions = new - { - tfm = "net8.0", - rollForward = "Major", - frameworks = new[] - { - new { name = "Microsoft.NETCore.App", version = "8.0.0" }, - new { name = "Microsoft.AspNetCore.App", version = "8.0.0" } - }, - configProperties = new - { - SystemGCServer = true, - SystemGCDynamicAdaptationMode = 1, - SystemRuntimeSerializationEnableUnsafeBinaryFormatterSerialization = false - } - } - }; - - File.WriteAllText(runtimeConfig, JsonSerializer.Serialize(originalConfig, new JsonSerializerOptions { WriteIndented = true })); - - var dashboardOptions = Options.Create(new DashboardOptions { DashboardPath = dashboardDll }); - var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); - - var model = new DistributedApplicationModel(new ResourceCollection()); - - // Act - await hook.OnBeforeStartAsync(new BeforeStartEvent(new TestServiceProvider(), model), CancellationToken.None); - - // Assert - var dashboardResource = Assert.Single(model.Resources); - Assert.Equal(KnownResourceNames.AspireDashboard, dashboardResource.Name); - - var executableResource = Assert.IsType(dashboardResource); - Assert.Equal("dotnet", executableResource.Command); - - // Verify the command line arguments include exec --runtimeconfig - var argsAnnotation = executableResource.Annotations.OfType().Single(); - var args = new List(); - await argsAnnotation.Callback(new CommandLineArgsCallbackContext(args)); - - Assert.Equal(4, args.Count); - Assert.Equal("exec", args[0]); - Assert.Equal("--runtimeconfig", args[1]); - Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); - Assert.Equal(dashboardDll, args[3]); - - // Verify that the custom runtime config has been updated with current framework versions - var customConfigContent = File.ReadAllText((string)args[2]); - var customConfig = JsonSerializer.Deserialize(customConfigContent); - - var frameworks = customConfig.GetProperty("runtimeOptions").GetProperty("frameworks").EnumerateArray().ToArray(); - var netCoreFramework = frameworks.First(f => f.GetProperty("name").GetString() == "Microsoft.NETCore.App"); - var aspNetCoreFramework = frameworks.First(f => f.GetProperty("name").GetString() == "Microsoft.AspNetCore.App"); - - // The versions should be updated to match the AppHost's target framework versions - // In the test environment, the AppHost targets .NET 8.0, so the versions should be "8.0.0" - Assert.Equal("8.0.0", netCoreFramework.GetProperty("version").GetString()); - Assert.Equal("8.0.0", aspNetCoreFramework.GetProperty("version").GetString()); - } - finally - { - // Cleanup - if (Directory.Exists(tempDir)) - { - Directory.Delete(tempDir, recursive: true); - } - } - } - - [Fact] - public async Task AddDashboardResource_WithExecutablePath_CreatesCorrectArguments() - { - // Arrange - var resourceLoggerService = new ResourceLoggerService(); - var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); - var configuration = new ConfigurationBuilder().Build(); - - // Create a temporary test dashboard directory with exe, dll and runtimeconfig.json - var tempDir = Path.GetTempFileName(); - File.Delete(tempDir); - Directory.CreateDirectory(tempDir); - - try - { - var dashboardExe = Path.Combine(tempDir, "Aspire.Dashboard.exe"); - var dashboardDll = Path.Combine(tempDir, "Aspire.Dashboard.dll"); - var runtimeConfig = Path.Combine(tempDir, "Aspire.Dashboard.runtimeconfig.json"); - - // Create mock files - File.WriteAllText(dashboardExe, "mock exe content"); - File.WriteAllText(dashboardDll, "mock dll content"); - - var originalConfig = new - { - runtimeOptions = new - { - tfm = "net8.0", - rollForward = "Major", - frameworks = new[] - { - new { name = "Microsoft.NETCore.App", version = "8.0.0" }, - new { name = "Microsoft.AspNetCore.App", version = "8.0.0" } - } - } - }; - - File.WriteAllText(runtimeConfig, JsonSerializer.Serialize(originalConfig, new JsonSerializerOptions { WriteIndented = true })); - - var dashboardOptions = Options.Create(new DashboardOptions { DashboardPath = dashboardExe }); - var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); - - var model = new DistributedApplicationModel(new ResourceCollection()); - - // Act - await hook.OnBeforeStartAsync(new BeforeStartEvent(new TestServiceProvider(), model), CancellationToken.None); - - // Assert - var dashboardResource = Assert.Single(model.Resources); - var executableResource = Assert.IsType(dashboardResource); - Assert.Equal("dotnet", executableResource.Command); - - var argsAnnotation = executableResource.Annotations.OfType().Single(); - var args = new List(); - await argsAnnotation.Callback(new CommandLineArgsCallbackContext(args)); - - Assert.Equal(4, args.Count); - Assert.Equal("exec", args[0]); - Assert.Equal("--runtimeconfig", args[1]); - Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); - Assert.Equal(dashboardDll, args[3]); // Should point to the DLL, not the EXE - } - finally - { - // Cleanup - if (Directory.Exists(tempDir)) - { - Directory.Delete(tempDir, recursive: true); - } - } - } - - [Fact] - public async Task AddDashboardResource_WithUnixExecutablePath_CreatesCorrectArguments() - { - // Arrange - var resourceLoggerService = new ResourceLoggerService(); - var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); - var configuration = new ConfigurationBuilder().Build(); - - // Create a temporary test dashboard directory with Unix executable (no extension), dll and runtimeconfig.json - var tempDir = Path.GetTempFileName(); - File.Delete(tempDir); - Directory.CreateDirectory(tempDir); - - try - { - var dashboardExe = Path.Combine(tempDir, "Aspire.Dashboard"); // No extension for Unix - var dashboardDll = Path.Combine(tempDir, "Aspire.Dashboard.dll"); - var runtimeConfig = Path.Combine(tempDir, "Aspire.Dashboard.runtimeconfig.json"); - - // Create mock files - File.WriteAllText(dashboardExe, "mock exe content"); - File.WriteAllText(dashboardDll, "mock dll content"); - - var originalConfig = new - { - runtimeOptions = new - { - tfm = "net8.0", - rollForward = "Major", - frameworks = new[] - { - new { name = "Microsoft.NETCore.App", version = "8.0.0" }, - new { name = "Microsoft.AspNetCore.App", version = "8.0.0" } - } - } - }; - - File.WriteAllText(runtimeConfig, JsonSerializer.Serialize(originalConfig, new JsonSerializerOptions { WriteIndented = true })); - - var dashboardOptions = Options.Create(new DashboardOptions { DashboardPath = dashboardExe }); - var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); - - var model = new DistributedApplicationModel(new ResourceCollection()); - - // Act - await hook.OnBeforeStartAsync(new BeforeStartEvent(new TestServiceProvider(), model), CancellationToken.None); - - // Assert - var dashboardResource = Assert.Single(model.Resources); - var executableResource = Assert.IsType(dashboardResource); - Assert.Equal("dotnet", executableResource.Command); - - var argsAnnotation = executableResource.Annotations.OfType().Single(); - var args = new List(); - await argsAnnotation.Callback(new CommandLineArgsCallbackContext(args)); - - Assert.Equal(4, args.Count); - Assert.Equal("exec", args[0]); - Assert.Equal("--runtimeconfig", args[1]); - Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); - Assert.Equal(dashboardDll, args[3]); // Should point to the DLL, not the EXE - } - finally - { - // Cleanup - if (Directory.Exists(tempDir)) - { - Directory.Delete(tempDir, recursive: true); - } - } - } - - [Fact] - public async Task AddDashboardResource_WithDirectDllPath_CreatesCorrectArguments() - { - // Arrange - var resourceLoggerService = new ResourceLoggerService(); - var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); - var configuration = new ConfigurationBuilder().Build(); - - // Create a temporary test dashboard directory with direct dll and runtimeconfig.json - var tempDir = Path.GetTempFileName(); - File.Delete(tempDir); - Directory.CreateDirectory(tempDir); - - try - { - var dashboardDll = Path.Combine(tempDir, "Aspire.Dashboard.dll"); - var runtimeConfig = Path.Combine(tempDir, "Aspire.Dashboard.runtimeconfig.json"); - - // Create mock files - File.WriteAllText(dashboardDll, "mock dll content"); - - var originalConfig = new - { - runtimeOptions = new - { - tfm = "net8.0", - rollForward = "Major", - frameworks = new[] - { - new { name = "Microsoft.NETCore.App", version = "8.0.0" }, - new { name = "Microsoft.AspNetCore.App", version = "8.0.0" } - } - } - }; - - File.WriteAllText(runtimeConfig, JsonSerializer.Serialize(originalConfig, new JsonSerializerOptions { WriteIndented = true })); - - var dashboardOptions = Options.Create(new DashboardOptions { DashboardPath = dashboardDll }); - var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); - - var model = new DistributedApplicationModel(new ResourceCollection()); - - // Act - await hook.OnBeforeStartAsync(new BeforeStartEvent(new TestServiceProvider(), model), CancellationToken.None); - - // Assert - var dashboardResource = Assert.Single(model.Resources); - var executableResource = Assert.IsType(dashboardResource); - Assert.Equal("dotnet", executableResource.Command); - - var argsAnnotation = executableResource.Annotations.OfType().Single(); - var args = new List(); - await argsAnnotation.Callback(new CommandLineArgsCallbackContext(args)); - - Assert.Equal(4, args.Count); - Assert.Equal("exec", args[0]); - Assert.Equal("--runtimeconfig", args[1]); - Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); - Assert.Equal(dashboardDll, args[3]); // Should point to the same DLL, not modify it - } - finally - { - // Cleanup - if (Directory.Exists(tempDir)) - { - Directory.Delete(tempDir, recursive: true); - } - } - } - private static DashboardEventHandlers CreateHook( ResourceLoggerService resourceLoggerService, ResourceNotificationService resourceNotificationService, diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs index f04e285721a..7808d289f26 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -47,13 +47,9 @@ public async Task DashboardIsAutomaticallyAddedAsHiddenResource(string showDashb var dashboard = Assert.Single(model.Resources.OfType()); var initialSnapshot = Assert.Single(dashboard.Annotations.OfType()); - var args = await ArgumentEvaluator.GetArgumentListAsync(dashboard).DefaultTimeout(); - Assert.NotNull(dashboard); Assert.Equal("aspire-dashboard", dashboard.Name); - // dotnet exec --runtimeconfig .dll - Assert.Equal("dotnet", dashboard.Command); - Assert.Equal(args[3], $"{dashboardPath}.dll"); + Assert.Equal(dashboardPath, dashboard.Command); Assert.True(initialSnapshot.InitialSnapshot.IsHidden); } @@ -232,39 +228,6 @@ public async Task DashboardDoesNotAddResource_ConfiguresMcpEndpoint(int expected Assert.Equal($"http://localhost:{expectedPort}", config.Single(e => e.Key == DashboardConfigNames.DashboardMcpUrlName.EnvVarName).Value); } - [Fact] - public async Task DashboardWithDllPathLaunchesDotnet() - { - using var builder = TestDistributedApplicationBuilder.Create( - options => options.DisableDashboard = false, - testOutputHelper: testOutputHelper); - - var dashboardPath = Path.GetFullPath("dashboard.dll"); - - builder.Services.Configure(o => - { - o.DashboardPath = dashboardPath; - }); - - var app = builder.Build(); - - await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout(); - - var model = app.Services.GetRequiredService(); - - var dashboard = Assert.Single(model.Resources.OfType()); - - var args = await ArgumentEvaluator.GetArgumentListAsync(dashboard).DefaultTimeout(); - - Assert.NotNull(dashboard); - Assert.Equal("aspire-dashboard", dashboard.Name); - Assert.Equal("dotnet", dashboard.Command); - Assert.Equal("exec", args[0]); - Assert.Equal("--runtimeconfig", args[1]); - Assert.EndsWith(".json", args[2]); // Generated temp runtimeconfig.json path - Assert.Equal(dashboardPath, args[3]); - } - [Theory] [InlineData(KnownConfigNames.DashboardOtlpGrpcEndpointUrl)] [InlineData(KnownConfigNames.Legacy.DashboardOtlpGrpcEndpointUrl)] From ff714e0dd80ff6e499eb45ccf956aaeb2c42b064 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Wed, 11 Feb 2026 23:09:33 -0800 Subject: [PATCH 13/14] Restore non-bundle dashboard path (dotnet exec), keep bundle path (aspire-managed) --- .../Dashboard/DashboardEventHandlers.cs | 236 +++++++++++++++++- .../Dashboard/DashboardLifecycleHookTests.cs | 3 +- .../Dashboard/DashboardResourceTests.cs | 38 ++- 3 files changed, 272 insertions(+), 5 deletions(-) diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index 837ab27b308..cf20a5ac0e7 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Globalization; using System.Net.Sockets; +using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; @@ -38,7 +39,8 @@ internal sealed class DashboardEventHandlers(IConfiguration configuration, DcpNameGenerator nameGenerator, IHostApplicationLifetime hostApplicationLifetime, IDistributedApplicationEventing eventing, - CodespacesUrlRewriter codespaceUrlRewriter + CodespacesUrlRewriter codespaceUrlRewriter, + IFileSystemService directoryService ) : IDistributedApplicationEventingSubscriber, IAsyncDisposable { // Internal for testing @@ -46,6 +48,11 @@ CodespacesUrlRewriter codespaceUrlRewriter internal const string OtlpHttpEndpointName = "otlp-http"; internal const string McpEndpointName = "mcp"; + // Fallback defaults for framework versions and TFM + private const string FallbackTargetFrameworkMoniker = "net8.0"; + private const string FallbackNetCoreVersion = "8.0.0"; + private const string FallbackAspNetCoreVersion = "8.0.0"; + private static readonly HashSet s_suppressAutomaticConfigurationCopy = new HashSet(StringComparer.OrdinalIgnoreCase) { KnownConfigNames.DashboardCorsAllowedOrigins // Set on the dashboard's Dashboard:Otlp:Cors type @@ -53,6 +60,7 @@ CodespacesUrlRewriter codespaceUrlRewriter private Task? _dashboardLogsTask; private CancellationTokenSource? _dashboardLogsCts; + private string? _customRuntimeConfigPath; public Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken cancellationToken) { @@ -81,6 +89,178 @@ public Task OnBeforeStartAsync(BeforeStartEvent @event, CancellationToken cancel return Task.CompletedTask; } + private static (string NetCoreVersion, string AspNetCoreVersion) GetAppHostFrameworkVersions() + { + try + { + // Get the entry assembly location (the AppHost) + var entryAssembly = Assembly.GetEntryAssembly(); + if (entryAssembly?.Location is null or { Length: 0 }) + { + // Fallback to process main module if entry assembly location is not available + var mainModule = Process.GetCurrentProcess().MainModule; + if (mainModule?.FileName is null) + { + return GetFallbackFrameworkVersions(); + } + return GetFrameworkVersionsFromRuntimeConfig(mainModule.FileName); + } + + return GetFrameworkVersionsFromRuntimeConfig(entryAssembly.Location); + } + catch (Exception) + { + return GetFallbackFrameworkVersions(); + } + } + + private static (string NetCoreVersion, string AspNetCoreVersion) GetFrameworkVersionsFromRuntimeConfig(string assemblyPath) + { + string runtimeConfigPath; + if (string.Equals(".dll", Path.GetExtension(assemblyPath), StringComparison.OrdinalIgnoreCase)) + { + runtimeConfigPath = Path.ChangeExtension(assemblyPath, ".runtimeconfig.json"); + } + else + { + var directory = Path.GetDirectoryName(assemblyPath)!; + var fileName = Path.GetFileName(assemblyPath); + var baseName = Path.GetExtension(fileName) switch + { + ".exe" => Path.GetFileNameWithoutExtension(fileName), + _ => fileName + }; + runtimeConfigPath = Path.Combine(directory, $"{baseName}.runtimeconfig.json"); + } + + if (!File.Exists(runtimeConfigPath)) + { + return GetFallbackFrameworkVersions(); + } + + var configText = File.ReadAllText(runtimeConfigPath); + var configJson = JsonNode.Parse(configText)?.AsObject(); + + if (configJson is null) + { + throw new DistributedApplicationException($"Failed to parse AppHost runtime config: {runtimeConfigPath}"); + } + + string netCoreVersion = FallbackNetCoreVersion; + string aspNetCoreVersion = FallbackAspNetCoreVersion; + + if (configJson["runtimeOptions"]?.AsObject() is { } runtimeOptions && + runtimeOptions["frameworks"]?.AsArray() is { } frameworks) + { + foreach (var framework in frameworks) + { + if (framework?.AsObject() is { } frameworkObj && + frameworkObj["name"]?.GetValue() is { } name && + frameworkObj["version"]?.GetValue() is { } version) + { + switch (name) + { + case "Microsoft.NETCore.App": + netCoreVersion = version; + break; + case "Microsoft.AspNetCore.App": + aspNetCoreVersion = version; + break; + } + } + } + } + + return (netCoreVersion, aspNetCoreVersion); + } + + private static (string NetCoreVersion, string AspNetCoreVersion) GetFallbackFrameworkVersions() + { + return (FallbackNetCoreVersion, FallbackAspNetCoreVersion); + } + + private string CreateCustomRuntimeConfig(string dashboardPath) + { + string originalRuntimeConfig; + + if (string.Equals(".dll", Path.GetExtension(dashboardPath), StringComparison.OrdinalIgnoreCase)) + { + originalRuntimeConfig = Path.ChangeExtension(dashboardPath, ".runtimeconfig.json"); + } + else + { + var directory = Path.GetDirectoryName(dashboardPath)!; + var fileName = Path.GetFileName(dashboardPath); + var baseName = Path.GetExtension(fileName) switch + { + ".exe" => Path.GetFileNameWithoutExtension(fileName), + _ => fileName + }; + originalRuntimeConfig = Path.Combine(directory, $"{baseName}.runtimeconfig.json"); + } + + if (!File.Exists(originalRuntimeConfig)) + { + var (appHostNetCoreVersion, appHostAspNetCoreVersion) = GetAppHostFrameworkVersions(); + + var defaultConfig = new + { + runtimeOptions = new + { + tfm = FallbackTargetFrameworkMoniker, + frameworks = new[] + { + new { name = "Microsoft.NETCore.App", version = appHostNetCoreVersion }, + new { name = "Microsoft.AspNetCore.App", version = appHostAspNetCoreVersion } + } + } + }; + + var customConfigPath = directoryService.TempDirectory.CreateTempFile("runtimeconfig.json").Path; + File.WriteAllText(customConfigPath, JsonSerializer.Serialize(defaultConfig, new JsonSerializerOptions { WriteIndented = true })); + + _customRuntimeConfigPath = customConfigPath; + return customConfigPath; + } + + var originalConfigText = File.ReadAllText(originalRuntimeConfig); + var configJson = JsonNode.Parse(originalConfigText)?.AsObject(); + + if (configJson is null) + { + throw new DistributedApplicationException($"Failed to parse dashboard runtime config: {originalRuntimeConfig}"); + } + + var (netCoreVersion, aspNetCoreVersion) = GetAppHostFrameworkVersions(); + + if (configJson["runtimeOptions"]?.AsObject() is { } runtimeOptions && + runtimeOptions["frameworks"]?.AsArray() is { } frameworks) + { + foreach (var framework in frameworks) + { + if (framework?.AsObject() is { } frameworkObj && + frameworkObj["name"]?.GetValue() is { } name) + { + switch (name) + { + case "Microsoft.NETCore.App": + frameworkObj["version"] = netCoreVersion; + break; + case "Microsoft.AspNetCore.App": + frameworkObj["version"] = aspNetCoreVersion; + break; + } + } + } + } + + var tempPath = directoryService.TempDirectory.CreateTempFile("runtimeconfig.json").Path; + File.WriteAllText(tempPath, configJson.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); + + _customRuntimeConfigPath = tempPath; + return tempPath; + } + private void AddDashboardResource(DistributedApplicationModel model) { if (dashboardOptions.Value.DashboardPath is not { } dashboardPath) @@ -91,16 +271,53 @@ private void AddDashboardResource(DistributedApplicationModel model) var fullyQualifiedDashboardPath = Path.GetFullPath(dashboardPath); var dashboardWorkingDirectory = Path.GetDirectoryName(fullyQualifiedDashboardPath); - var dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, fullyQualifiedDashboardPath, dashboardWorkingDirectory ?? ""); + ExecutableResource dashboardResource; if (BundleDiscovery.IsAspireManagedBinary(fullyQualifiedDashboardPath)) { - // Prepend "dashboard" subcommand for aspire-managed + // aspire-managed is self-contained, run directly with "dashboard" subcommand + dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, fullyQualifiedDashboardPath, dashboardWorkingDirectory ?? ""); + dashboardResource.Annotations.Add(new CommandLineArgsCallbackAnnotation(args => { args.Insert(0, "dashboard"); })); } + else + { + // Non-bundle: run via dotnet exec with custom runtime config + var customRuntimeConfigPath = CreateCustomRuntimeConfig(fullyQualifiedDashboardPath); + + string dashboardDll; + if (string.Equals(".dll", Path.GetExtension(fullyQualifiedDashboardPath), StringComparison.OrdinalIgnoreCase)) + { + dashboardDll = fullyQualifiedDashboardPath; + } + else + { + var directory = Path.GetDirectoryName(fullyQualifiedDashboardPath)!; + var fileName = Path.GetFileName(fullyQualifiedDashboardPath); + var baseName = fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) + ? fileName.Substring(0, fileName.Length - 4) + : fileName; + dashboardDll = Path.Combine(directory, $"{baseName}.dll"); + } + + if (!File.Exists(dashboardDll)) + { + distributedApplicationLogger.LogError("Dashboard DLL not found: {Path}", dashboardDll); + } + + dashboardResource = new ExecutableResource(KnownResourceNames.AspireDashboard, "dotnet", dashboardWorkingDirectory ?? ""); + + dashboardResource.Annotations.Add(new CommandLineArgsCallbackAnnotation(args => + { + args.Add("exec"); + args.Add("--runtimeconfig"); + args.Add(customRuntimeConfigPath); + args.Add(dashboardDll); + })); + } nameGenerator.EnsureDcpInstancesPopulated(dashboardResource); @@ -664,6 +881,19 @@ public async ValueTask DisposeAsync() distributedApplicationLogger.LogError(ex, "Unexpected error while watching dashboard logs."); } } + + // Clean up the temporary runtime config file + if (_customRuntimeConfigPath is not null) + { + try + { + File.Delete(_customRuntimeConfigPath); + } + catch (Exception ex) + { + distributedApplicationLogger.LogWarning(ex, "Failed to delete temporary runtime config file: {Path}", _customRuntimeConfigPath); + } + } } } diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index 8163b5c1e32..c45be2fb176 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -289,7 +289,8 @@ private static DashboardEventHandlers CreateHook( new DcpNameGenerator(configuration, Options.Create(new DcpOptions())), new TestHostApplicationLifetime(), eventing ?? new Hosting.Eventing.DistributedApplicationEventing(), - rewriter + rewriter, + new FileSystemService(configuration) ); } diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs index 7808d289f26..ddf7ac4354e 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -47,9 +47,12 @@ public async Task DashboardIsAutomaticallyAddedAsHiddenResource(string showDashb var dashboard = Assert.Single(model.Resources.OfType()); var initialSnapshot = Assert.Single(dashboard.Annotations.OfType()); + var args = await ArgumentEvaluator.GetArgumentListAsync(dashboard).DefaultTimeout(); + Assert.NotNull(dashboard); Assert.Equal("aspire-dashboard", dashboard.Name); - Assert.Equal(dashboardPath, dashboard.Command); + Assert.Equal("dotnet", dashboard.Command); + Assert.Equal(args[3], $"{dashboardPath}.dll"); Assert.True(initialSnapshot.InitialSnapshot.IsHidden); } @@ -228,6 +231,39 @@ public async Task DashboardDoesNotAddResource_ConfiguresMcpEndpoint(int expected Assert.Equal($"http://localhost:{expectedPort}", config.Single(e => e.Key == DashboardConfigNames.DashboardMcpUrlName.EnvVarName).Value); } + [Fact] + public async Task DashboardWithDllPathLaunchesDotnet() + { + using var builder = TestDistributedApplicationBuilder.Create( + options => options.DisableDashboard = false, + testOutputHelper: testOutputHelper); + + var dashboardPath = Path.GetFullPath("dashboard.dll"); + + builder.Services.Configure(o => + { + o.DashboardPath = dashboardPath; + }); + + var app = builder.Build(); + + await app.ExecuteBeforeStartHooksAsync(default).DefaultTimeout(); + + var model = app.Services.GetRequiredService(); + + var dashboard = Assert.Single(model.Resources.OfType()); + + var args = await ArgumentEvaluator.GetArgumentListAsync(dashboard).DefaultTimeout(); + + Assert.NotNull(dashboard); + Assert.Equal("aspire-dashboard", dashboard.Name); + Assert.Equal("dotnet", dashboard.Command); + Assert.Equal("exec", args[0]); + Assert.Equal("--runtimeconfig", args[1]); + Assert.EndsWith(".json", args[2]); + Assert.Equal(dashboardPath, args[3]); + } + [Theory] [InlineData(KnownConfigNames.DashboardOtlpGrpcEndpointUrl)] [InlineData(KnownConfigNames.Legacy.DashboardOtlpGrpcEndpointUrl)] From a33d43cf31e4eb8e76b2616e83b48d32fbb0ec84 Mon Sep 17 00:00:00 2001 From: David Fowler Date: Thu, 12 Feb 2026 00:02:06 -0800 Subject: [PATCH 14/14] Restore dashboard lifecycle hook tests for non-bundle dotnet exec path --- .../Dashboard/DashboardLifecycleHookTests.cs | 295 ++++++++++++++++++ 1 file changed, 295 insertions(+) diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index c45be2fb176..9046e7b7585 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -262,6 +262,301 @@ public async Task ResourceReadyEvent_LogsDashboardUrlFromAllocatedEndpoint(strin Assert.Equal(expectedScheme, uri.Scheme); } + [Fact] + public async Task AddDashboardResource_CreatesExecutableResourceWithCustomRuntimeConfig() + { + // Arrange + var resourceLoggerService = new ResourceLoggerService(); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + var configuration = new ConfigurationBuilder().Build(); + + // Create a temporary test dashboard directory with a dll and runtimeconfig.json + var tempDir = Path.GetTempFileName(); + File.Delete(tempDir); + Directory.CreateDirectory(tempDir); + + try + { + var dashboardDll = Path.Combine(tempDir, "Aspire.Dashboard.dll"); + var runtimeConfig = Path.Combine(tempDir, "Aspire.Dashboard.runtimeconfig.json"); + + // Create a mock DLL file + File.WriteAllText(dashboardDll, "mock dll content"); + + // Create a mock runtime config similar to the real one + var originalConfig = new + { + runtimeOptions = new + { + tfm = "net8.0", + rollForward = "Major", + frameworks = new[] + { + new { name = "Microsoft.NETCore.App", version = "8.0.0" }, + new { name = "Microsoft.AspNetCore.App", version = "8.0.0" } + }, + configProperties = new + { + SystemGCServer = true, + SystemGCDynamicAdaptationMode = 1, + SystemRuntimeSerializationEnableUnsafeBinaryFormatterSerialization = false + } + } + }; + + File.WriteAllText(runtimeConfig, JsonSerializer.Serialize(originalConfig, new JsonSerializerOptions { WriteIndented = true })); + + var dashboardOptions = Options.Create(new DashboardOptions { DashboardPath = dashboardDll }); + var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); + + var model = new DistributedApplicationModel(new ResourceCollection()); + + // Act + await hook.OnBeforeStartAsync(new BeforeStartEvent(new TestServiceProvider(), model), CancellationToken.None); + + // Assert + var dashboardResource = Assert.Single(model.Resources); + Assert.Equal(KnownResourceNames.AspireDashboard, dashboardResource.Name); + + var executableResource = Assert.IsType(dashboardResource); + Assert.Equal("dotnet", executableResource.Command); + + // Verify the command line arguments include exec --runtimeconfig + var argsAnnotation = executableResource.Annotations.OfType().Single(); + var args = new List(); + await argsAnnotation.Callback(new CommandLineArgsCallbackContext(args)); + + Assert.Equal(4, args.Count); + Assert.Equal("exec", args[0]); + Assert.Equal("--runtimeconfig", args[1]); + Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); + Assert.Equal(dashboardDll, args[3]); + + // Verify that the custom runtime config has been updated with current framework versions + var customConfigContent = File.ReadAllText((string)args[2]); + var customConfig = JsonSerializer.Deserialize(customConfigContent); + + var frameworks = customConfig.GetProperty("runtimeOptions").GetProperty("frameworks").EnumerateArray().ToArray(); + var netCoreFramework = frameworks.First(f => f.GetProperty("name").GetString() == "Microsoft.NETCore.App"); + var aspNetCoreFramework = frameworks.First(f => f.GetProperty("name").GetString() == "Microsoft.AspNetCore.App"); + + Assert.Equal("8.0.0", netCoreFramework.GetProperty("version").GetString()); + Assert.Equal("8.0.0", aspNetCoreFramework.GetProperty("version").GetString()); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public async Task AddDashboardResource_WithExecutablePath_CreatesCorrectArguments() + { + // Arrange + var resourceLoggerService = new ResourceLoggerService(); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + var configuration = new ConfigurationBuilder().Build(); + + var tempDir = Path.GetTempFileName(); + File.Delete(tempDir); + Directory.CreateDirectory(tempDir); + + try + { + var dashboardExe = Path.Combine(tempDir, "Aspire.Dashboard.exe"); + var dashboardDll = Path.Combine(tempDir, "Aspire.Dashboard.dll"); + var runtimeConfig = Path.Combine(tempDir, "Aspire.Dashboard.runtimeconfig.json"); + + File.WriteAllText(dashboardExe, "mock exe content"); + File.WriteAllText(dashboardDll, "mock dll content"); + + var originalConfig = new + { + runtimeOptions = new + { + tfm = "net8.0", + rollForward = "Major", + frameworks = new[] + { + new { name = "Microsoft.NETCore.App", version = "8.0.0" }, + new { name = "Microsoft.AspNetCore.App", version = "8.0.0" } + } + } + }; + + File.WriteAllText(runtimeConfig, JsonSerializer.Serialize(originalConfig, new JsonSerializerOptions { WriteIndented = true })); + + var dashboardOptions = Options.Create(new DashboardOptions { DashboardPath = dashboardExe }); + var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); + + var model = new DistributedApplicationModel(new ResourceCollection()); + + // Act + await hook.OnBeforeStartAsync(new BeforeStartEvent(new TestServiceProvider(), model), CancellationToken.None); + + // Assert + var dashboardResource = Assert.Single(model.Resources); + var executableResource = Assert.IsType(dashboardResource); + Assert.Equal("dotnet", executableResource.Command); + + var argsAnnotation = executableResource.Annotations.OfType().Single(); + var args = new List(); + await argsAnnotation.Callback(new CommandLineArgsCallbackContext(args)); + + Assert.Equal(4, args.Count); + Assert.Equal("exec", args[0]); + Assert.Equal("--runtimeconfig", args[1]); + Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); + Assert.Equal(dashboardDll, args[3]); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public async Task AddDashboardResource_WithUnixExecutablePath_CreatesCorrectArguments() + { + // Arrange + var resourceLoggerService = new ResourceLoggerService(); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + var configuration = new ConfigurationBuilder().Build(); + + var tempDir = Path.GetTempFileName(); + File.Delete(tempDir); + Directory.CreateDirectory(tempDir); + + try + { + var dashboardExe = Path.Combine(tempDir, "Aspire.Dashboard"); + var dashboardDll = Path.Combine(tempDir, "Aspire.Dashboard.dll"); + var runtimeConfig = Path.Combine(tempDir, "Aspire.Dashboard.runtimeconfig.json"); + + File.WriteAllText(dashboardExe, "mock exe content"); + File.WriteAllText(dashboardDll, "mock dll content"); + + var originalConfig = new + { + runtimeOptions = new + { + tfm = "net8.0", + rollForward = "Major", + frameworks = new[] + { + new { name = "Microsoft.NETCore.App", version = "8.0.0" }, + new { name = "Microsoft.AspNetCore.App", version = "8.0.0" } + } + } + }; + + File.WriteAllText(runtimeConfig, JsonSerializer.Serialize(originalConfig, new JsonSerializerOptions { WriteIndented = true })); + + var dashboardOptions = Options.Create(new DashboardOptions { DashboardPath = dashboardExe }); + var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); + + var model = new DistributedApplicationModel(new ResourceCollection()); + + // Act + await hook.OnBeforeStartAsync(new BeforeStartEvent(new TestServiceProvider(), model), CancellationToken.None); + + // Assert + var dashboardResource = Assert.Single(model.Resources); + var executableResource = Assert.IsType(dashboardResource); + Assert.Equal("dotnet", executableResource.Command); + + var argsAnnotation = executableResource.Annotations.OfType().Single(); + var args = new List(); + await argsAnnotation.Callback(new CommandLineArgsCallbackContext(args)); + + Assert.Equal(4, args.Count); + Assert.Equal("exec", args[0]); + Assert.Equal("--runtimeconfig", args[1]); + Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); + Assert.Equal(dashboardDll, args[3]); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + + [Fact] + public async Task AddDashboardResource_WithDirectDllPath_CreatesCorrectArguments() + { + // Arrange + var resourceLoggerService = new ResourceLoggerService(); + var resourceNotificationService = ResourceNotificationServiceTestHelpers.Create(); + var configuration = new ConfigurationBuilder().Build(); + + var tempDir = Path.GetTempFileName(); + File.Delete(tempDir); + Directory.CreateDirectory(tempDir); + + try + { + var dashboardDll = Path.Combine(tempDir, "Aspire.Dashboard.dll"); + var runtimeConfig = Path.Combine(tempDir, "Aspire.Dashboard.runtimeconfig.json"); + + File.WriteAllText(dashboardDll, "mock dll content"); + + var originalConfig = new + { + runtimeOptions = new + { + tfm = "net8.0", + rollForward = "Major", + frameworks = new[] + { + new { name = "Microsoft.NETCore.App", version = "8.0.0" }, + new { name = "Microsoft.AspNetCore.App", version = "8.0.0" } + } + } + }; + + File.WriteAllText(runtimeConfig, JsonSerializer.Serialize(originalConfig, new JsonSerializerOptions { WriteIndented = true })); + + var dashboardOptions = Options.Create(new DashboardOptions { DashboardPath = dashboardDll }); + var hook = CreateHook(resourceLoggerService, resourceNotificationService, configuration, dashboardOptions: dashboardOptions); + + var model = new DistributedApplicationModel(new ResourceCollection()); + + // Act + await hook.OnBeforeStartAsync(new BeforeStartEvent(new TestServiceProvider(), model), CancellationToken.None); + + // Assert + var dashboardResource = Assert.Single(model.Resources); + var executableResource = Assert.IsType(dashboardResource); + Assert.Equal("dotnet", executableResource.Command); + + var argsAnnotation = executableResource.Annotations.OfType().Single(); + var args = new List(); + await argsAnnotation.Callback(new CommandLineArgsCallbackContext(args)); + + Assert.Equal(4, args.Count); + Assert.Equal("exec", args[0]); + Assert.Equal("--runtimeconfig", args[1]); + Assert.True(File.Exists((string)args[2]), "Custom runtime config file should exist"); + Assert.Equal(dashboardDll, args[3]); + } + finally + { + if (Directory.Exists(tempDir)) + { + Directory.Delete(tempDir, recursive: true); + } + } + } + private static DashboardEventHandlers CreateHook( ResourceLoggerService resourceLoggerService, ResourceNotificationService resourceNotificationService,