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/docs/specs/bundle.md b/docs/specs/bundle.md index 579eb94d202..fba3c233596 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 @@ -965,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 @@ -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 @@ -1271,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) @@ -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 @@ -1339,13 +1291,14 @@ 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 | | `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) +├── NuGet Commands (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 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 +5. **Create self-extracting binary** — appends tar.gz payload + 32-byte trailer to native AOT CLI ### Self-Extracting Binary Build diff --git a/eng/Bundle.proj b/eng/Bundle.proj index e671888fea6..626184870d4 100644 --- a/eng/Bundle.proj +++ b/eng/Bundle.proj @@ -1,20 +1,19 @@ @@ -23,32 +22,33 @@ 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 +67,7 @@ - + @@ -89,10 +89,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 +96,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 +116,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..a11eff37881 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -15,8 +15,6 @@ 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.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.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 deleted file mode 100644 index bf0469f8364..00000000000 --- a/src/Aspire.Cli/Certificates/BundleCertificateToolRunner.cs +++ /dev/null @@ -1,195 +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.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. -/// -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) - { - WorkingDirectory = Environment.CurrentDirectory, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - // Use ArgumentList to prevent command injection - startInfo.ArgumentList.Add(devCertsPath); - 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 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) - { - WorkingDirectory = Environment.CurrentDirectory, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardOutput = true, - RedirectStandardError = true - }; - - // Use ArgumentList to prevent command injection - startInfo.ArgumentList.Add(devCertsPath); - 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; - } -} \ No newline at end of file diff --git a/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateExportFormat.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateExportFormat.cs new file mode 100644 index 00000000000..70705da5fce --- /dev/null +++ b/src/Aspire.Cli/Certificates/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.Cli/Certificates/CertificateGeneration/CertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs new file mode 100644 index 00000000000..dc0b977f7f1 --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificateManager.cs @@ -0,0 +1,1552 @@ +// 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.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 + +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 Create(ILogger logger) => OperatingSystem.IsWindows() ? +#pragma warning disable CA1416 // Validate platform compatibility + new WindowsCertificateManager(logger) : +#pragma warning restore CA1416 // Validate platform compatibility + OperatingSystem.IsMacOS() ? + new MacOSCertificateManager(logger) as CertificateManager : + new UnixCertificateManager(logger); + + protected CertificateManagerLogger Log { get; } + + // 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(ILogger logger) : this(logger, LocalhostHttpsDistinguishedName, CurrentAspNetCoreCertificateVersion, CurrentMinimumAspNetCoreCertificateVersion) + { + } + + // For testing purposes only + internal CertificateManager(string subject, int version) + : this(NullLogger.Instance, subject, version, version) + { + } + + // For testing purposes only + internal CertificateManager(ILogger logger, string subject, int generatedVersion, int minimumVersion) + { + Log = new CertificateManagerLogger(logger); + 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 = X509CertificateLoader.LoadPkcs12FromFile(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) + { + 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) + { + 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) + { + 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) + { + 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) + { + 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 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 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. + /// + 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; + } + + internal sealed class CertificateManagerLogger + { + 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 - Verbose + public void DescribeFoundCertificates(string matchingCertificates) => + _logger.LogDebug("Found certificates: {MatchingCertificates}", matchingCertificates); + + // Event 3 - Verbose + public void CheckCertificatesValidity() => + _logger.LogDebug("Checking certificates validity"); + + // Event 4 - Verbose + public void DescribeValidCertificates(string validCertificates) => + _logger.LogDebug("Valid certificates: {ValidCertificates}", validCertificates); + + // Event 5 - Verbose + public void DescribeInvalidCertificates(string invalidCertificates) => + _logger.LogDebug("Invalid certificates: {InvalidCertificates}", invalidCertificates); + + // Event 6 - Verbose + public void ListCertificatesEnd() => + _logger.LogDebug("Finished listing certificates."); + + // Event 7 - Error + public void ListCertificatesError(string e) => + _logger.LogError("An error occurred while listing the certificates: {Error}", e); + + // Event 8 - Verbose + public void FilteredCertificates(string filteredCertificates) => + _logger.LogDebug("Filtered certificates: {FilteredCertificates}", filteredCertificates); + + // Event 9 - Verbose + public void ExcludedCertificates(string excludedCertificates) => + _logger.LogDebug("Excluded certificates: {ExcludedCertificates}", excludedCertificates); + + // Event 14 - Verbose + public void ValidCertificatesFound(string certificates) => + _logger.LogDebug("Valid certificates: {Certificates}", certificates); + + // Event 15 - Verbose + public void SelectedCertificate(string certificate) => + _logger.LogDebug("Selected certificate: {Certificate}", certificate); + + // Event 16 - Verbose + public void NoValidCertificatesFound() => + _logger.LogDebug("No valid certificates found."); + + // Event 17 - Verbose + public void CreateDevelopmentCertificateStart() => + _logger.LogDebug("Generating HTTPS development certificate."); + + // Event 18 - Verbose + public void CreateDevelopmentCertificateEnd() => + _logger.LogDebug("Finished generating HTTPS development certificate."); + + // Event 19 - Error + public void CreateDevelopmentCertificateError(string e) => + _logger.LogError("An error has occurred generating the certificate: {Error}.", e); + + // 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 - Verbose + public void SaveCertificateInStoreEnd() => + _logger.LogDebug("Finished saving certificate to the store."); + + // Event 22 - Error + public void SaveCertificateInStoreError(string e) => + _logger.LogError("An error has occurred saving the certificate: {Error}.", e); + + // 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 - Verbose + public void NoPasswordForCertificate() => + _logger.LogDebug("Exporting certificate with private key but no password."); + + // Event 25 - Verbose + public void CreateExportCertificateDirectory(string path) => + _logger.LogDebug("Creating directory {Path}.", path); + + // Event 26 - Error + public void ExportCertificateError(string error) => + _logger.LogError("An error has occurred while exporting the certificate: {Error}.", error); + + // Event 27 - Verbose + public void WriteCertificateToDisk(string path) => + _logger.LogDebug("Writing the certificate to: {Path}.", path); + + // Event 28 - Error + public void WriteCertificateToDiskError(string error) => + _logger.LogError("An error has occurred while writing the certificate to disk: {Error}.", error); + + // Event 29 - Verbose + public void TrustCertificateStart(string certificate) => + _logger.LogDebug("Trusting the certificate to: {Certificate}.", certificate); + + // Event 30 - Verbose + public void TrustCertificateEnd() => + _logger.LogDebug("Finished trusting the certificate."); + + // Event 31 - Error + public void TrustCertificateError(string error) => + _logger.LogError("An error has occurred while trusting the certificate: {Error}.", error); + + // Event 32 - Verbose + public void MacOSTrustCommandStart(string command) => + _logger.LogDebug("Running the trust command {Command}.", command); + + // Event 33 - Verbose + public void MacOSTrustCommandEnd() => + _logger.LogDebug("Finished running the trust command."); + + // Event 34 - Warning + public void MacOSTrustCommandError(int exitCode) => + _logger.LogWarning("An error has occurred while running the trust command: {ExitCode}.", exitCode); + + // Event 35 - Verbose + public void MacOSRemoveCertificateTrustRuleStart(string certificate) => + _logger.LogDebug("Running the remove trust command for {Certificate}.", certificate); + + // Event 36 - Verbose + public void MacOSRemoveCertificateTrustRuleEnd() => + _logger.LogDebug("Finished running the remove trust command."); + + // Event 37 - Warning + public void MacOSRemoveCertificateTrustRuleError(int exitCode) => + _logger.LogWarning("An error has occurred while running the remove trust command: {ExitCode}.", exitCode); + + // Event 38 - Verbose + public void MacOSCertificateUntrusted(string certificate) => + _logger.LogDebug("The certificate is not trusted: {Certificate}.", 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 - Verbose + public void MacOSRemoveCertificateFromKeyChainEnd() => + _logger.LogDebug("Finished removing the certificate from the keychain."); + + // Event 41 - Warning + public void MacOSRemoveCertificateFromKeyChainError(int exitCode) => + _logger.LogWarning("An error has occurred while running the remove trust command: {ExitCode}.", exitCode); + + // Event 42 - Verbose + public void RemoveCertificateFromUserStoreStart(string certificate) => + _logger.LogDebug("Removing the certificate from the user store {Certificate}.", certificate); + + // Event 43 - Verbose + public void RemoveCertificateFromUserStoreEnd() => + _logger.LogDebug("Finished removing the certificate from the user store."); + + // 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 - Verbose + public void WindowsAddCertificateToRootStore() => + _logger.LogDebug("Adding certificate to the trusted root certification authority store."); + + // Event 46 - Verbose + public void WindowsCertificateAlreadyTrusted() => + _logger.LogDebug("The certificate is already trusted."); + + // Event 47 - Verbose + public void WindowsCertificateTrustCanceled() => + _logger.LogDebug("Trusting the certificate was cancelled by the user."); + + // Event 48 - Verbose + public void WindowsRemoveCertificateFromRootStoreStart() => + _logger.LogDebug("Removing the certificate from the trusted root certification authority store."); + + // Event 49 - Verbose + public void WindowsRemoveCertificateFromRootStoreEnd() => + _logger.LogDebug("Finished removing the certificate from the trusted root certification authority store."); + + // Event 50 - Verbose + public void WindowsRemoveCertificateFromRootStoreNotFound() => + _logger.LogDebug("The certificate was not trusted."); + + // Event 51 - Verbose + public void CorrectCertificateStateStart(string certificate) => + _logger.LogDebug("Correcting the the certificate state for '{Certificate}'.", certificate); + + // Event 52 - Verbose + public void CorrectCertificateStateEnd() => + _logger.LogDebug("Finished correcting the certificate state."); + + // Event 53 - Error + public void CorrectCertificateStateError(string error) => + _logger.LogError("An error has occurred while correcting the certificate state: {Error}.", error); + + // Event 54 - Verbose + internal void MacOSAddCertificateToKeyChainStart(string keychain, string certificate) => + _logger.LogDebug("Importing the certificate {Certificate} to the keychain '{Keychain}'.", certificate, keychain); + + // Event 55 - Verbose + internal void MacOSAddCertificateToKeyChainEnd() => + _logger.LogDebug("Finished importing the certificate to the keychain."); + + // 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 - Verbose + public void WritePemKeyToDisk(string path) => + _logger.LogDebug("Writing the certificate to: {Path}.", path); + + // Event 58 - Error + public void WritePemKeyToDiskError(string error) => + _logger.LogError("An error has occurred while writing the certificate to disk: {Error}.", error); + + // Event 59 - Error + internal void ImportCertificateMissingFile(string certificatePath) => + _logger.LogError("The file '{CertificatePath}' does not exist.", certificatePath); + + // Event 60 - Error + internal void ImportCertificateExistingCertificates(string certificateDescription) => + _logger.LogError("One or more HTTPS certificates exist '{CertificateDescription}'.", certificateDescription); + + // Event 61 - Verbose + internal void LoadCertificateStart(string certificatePath) => + _logger.LogDebug("Loading certificate from path '{CertificatePath}'.", certificatePath); + + // Event 62 - Verbose + internal void LoadCertificateEnd(string description) => + _logger.LogDebug("The certificate '{Description}' has been loaded successfully.", description); + + // Event 63 - Error + internal void LoadCertificateError(string error) => + _logger.LogError("An error has occurred while loading the certificate from disk: {Error}.", error); + + // 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 - Verbose + public void MacOSCertificateAlreadyTrusted() => + _logger.LogDebug("The certificate is already trusted."); + + // 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 - Verbose + internal void MacOSAddCertificateToUserProfileDirEnd() => + _logger.LogDebug("Finished saving the certificate to the user profile folder."); + + // 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 - 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 - Error + internal void MacOSFileIsNotAValidCertificate(string path) => + _logger.LogError("The file '{Path}' is not a valid certificate.", path); + + // Event 71 - Warning + internal void MacOSDiskStoreDoesNotExist() => + _logger.LogWarning("The on-disk store directory was not found."); + + // Event 72 - Verbose + internal void UnixOpenSslCertificateDirectoryOverridePresent(string nssDbOverrideVariableName) => + _logger.LogDebug("Reading OpenSSL trusted certificates location from {NssDbOverrideVariableName}.", nssDbOverrideVariableName); + + // Event 73 - Verbose + internal void UnixNssDbOverridePresent(string environmentVariable) => + _logger.LogDebug("Reading NSS database locations from {EnvironmentVariable}.", 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 - Warning + internal void UnixNotTrustedByDotnet() => + _logger.LogWarning("The certificate is not trusted by .NET. This will likely affect System.Net.Http.HttpClient."); + + // 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 - 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); + + // 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); + + // Event 79 - Verbose + internal void UnixOpenSslVersionParsingFailed() => + _logger.LogDebug("OpenSSL reported its directory in an unexpected format."); + + // Event 80 - Verbose + internal void UnixOpenSslVersionFailed() => + _logger.LogDebug("Unable to determine the OpenSSL directory."); + + // Event 81 - Verbose + internal void UnixOpenSslVersionException(string exceptionMessage) => + _logger.LogDebug("Unable to determine the OpenSSL directory: {ExceptionMessage}.", exceptionMessage); + + // 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); + + // 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); + + // 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); + + // 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 - Warning + internal void UnixDotnetTrustException(string exceptionMessage) => + _logger.LogWarning("Failed to trust the certificate in .NET: {ExceptionMessage}.", exceptionMessage); + + // Event 87 - Verbose + internal void UnixDotnetTrustSucceeded() => + _logger.LogDebug("Trusted the certificate in .NET."); + + // Event 88 - Warning + internal void UnixOpenSslTrustFailed() => + _logger.LogWarning("Clients that validate certificate trust using OpenSSL will not trust the certificate."); + + // Event 89 - Verbose + internal void UnixOpenSslTrustSucceeded() => + _logger.LogDebug("Trusted the certificate in OpenSSL."); + + // 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 - Verbose + internal void UnixNssDbTrustSucceeded(string path) => + _logger.LogDebug("Trusted the certificate in the NSS database in '{Path}'.", path); + + // Event 92 - Warning + internal void UnixDotnetUntrustException(string exceptionMessage) => + _logger.LogWarning("Failed to untrust the certificate in .NET: {ExceptionMessage}.", exceptionMessage); + + // Event 93 - Warning + internal void UnixOpenSslUntrustFailed() => + _logger.LogWarning("Failed to untrust the certificate in OpenSSL."); + + // Event 94 - Verbose + internal void UnixOpenSslUntrustSucceeded() => + _logger.LogDebug("Untrusted the certificate in OpenSSL."); + + // Event 95 - Warning + internal void UnixNssDbUntrustFailed(string path) => + _logger.LogWarning("Failed to remove the certificate from the NSS database in '{Path}'.", path); + + // Event 96 - Verbose + internal void UnixNssDbUntrustSucceeded(string path) => + _logger.LogDebug("Removed the certificate from the NSS database in '{Path}'.", path); + + // Event 97 - Warning + internal void UnixTrustPartiallySucceeded() => + _logger.LogWarning("The certificate is only partially trusted - some clients will not accept it."); + + // 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 - 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 - 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 - Warning + internal void UnixFirefoxProfileEnumerationException(string firefoxDirectory, string message) => + _logger.LogWarning("Failed to find the Firefox profiles in directory '{FirefoxDirectory}': {Message}.", firefoxDirectory, message); + + // Event 102 - Verbose + internal void UnixNoFirefoxProfilesFound(string firefoxDirectory) => + _logger.LogDebug("No Firefox profiles found in directory '{FirefoxDirectory}'.", firefoxDirectory); + + // 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); + + // 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 - 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 - 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 - Verbose + internal void UnixOpenSslUntrustSkipped(string certPath) => + _logger.LogDebug("Untrusting the certificate in OpenSSL was skipped since '{CertPath}' does not exist.", certPath); + + // Event 108 - Warning + internal void UnixCertificateFileDeletionException(string certPath, string exceptionMessage) => + _logger.LogWarning("Failed to delete certificate file '{CertPath}': {ExceptionMessage}.", certPath, exceptionMessage); + + // 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 - 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 - 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 - Warning + internal void DirectoryPermissionsNotSecure(string directoryPath) => + _logger.LogWarning("Directory '{DirectoryPath}' may be readable by other users.", directoryPath); + + // 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 - 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 - Verbose + internal void WslWindowsTrustSucceeded() => + _logger.LogDebug("Successfully trusted the certificate in the Windows certificate store via WSL."); + + // Event 116 - Warning + internal void WslWindowsTrustFailed() => + _logger.LogWarning("Failed to trust the certificate in the Windows certificate store via WSL."); + + // 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 - Verbose + public void DescribeMinimumVersionCertificates(string meetsMinimumVersionCertificates) => + _logger.LogDebug("Meets minimum version certificates: {MeetsMinimumVersionCertificates}", meetsMinimumVersionCertificates); + + // Event 119 - Verbose + public void DescribeBelowMinimumVersionCertificates(string belowMinimumVersionCertificates) => + _logger.LogDebug("Below minimum version certificates: {BelowMinimumVersionCertificates}", 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.Cli/Certificates/CertificateGeneration/CertificatePurpose.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/CertificatePurpose.cs new file mode 100644 index 00000000000..7abe411dbd8 --- /dev/null +++ b/src/Aspire.Cli/Certificates/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.Cli/Certificates/CertificateGeneration/EnsureCertificateResult.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/EnsureCertificateResult.cs new file mode 100644 index 00000000000..5c28eaca306 --- /dev/null +++ b/src/Aspire.Cli/Certificates/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.Cli/Certificates/CertificateGeneration/ImportCertificateResult.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/ImportCertificateResult.cs new file mode 100644 index 00000000000..53706d8ce88 --- /dev/null +++ b/src/Aspire.Cli/Certificates/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.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs new file mode 100644 index 00000000000..cce4cc10ce9 --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/MacOSCertificateManager.cs @@ -0,0 +1,497 @@ +// 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; +using Microsoft.Extensions.Logging; + +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 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 + // 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 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"; + 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 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 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 + // 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(ILogger logger) : base(logger) + { + } + + 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} {s_macOSTrustCertificateCommandLineArguments}{tmpFile}"); + } + using (var process = Process.Start(MacOSTrustCertificateCommandLine, s_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 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 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) + { + var 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(s_macOSUserKeychain, GetDescription(certificate)); + } + + // Ensure that the directory exists before writing to the file. + CreateDirectoryWithPermissions(s_macOSUserHttpsCertificateLocation); + + File.WriteAllBytes(GetCertificateFilePath(certificate), certBytes); + } + catch (Exception ex) + { + Log.MacOSAddCertificateToUserProfileDirError(certificate.Thumbprint, ex.Message); + } + + Log.MacOSAddCertificateToKeyChainEnd(); + Log.MacOSAddCertificateToUserProfileDirEnd(); + + return certificate; + } + + private 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, s_macOSAddCertificateToKeyChainCommandLineArgumentsFormat, certificatePath, password)) + { + RedirectStandardOutput = true, + RedirectStandardError = true + }; + + if (Log.IsEnabled()) + { + Log.MacOSAddCertificateToKeyChainStart(s_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(s_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(s_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 ICollection GetCertsFromDisk() + { + var certsFromDisk = new List(); + if (!Directory.Exists(s_macOSUserHttpsCertificateLocation)) + { + Log.MacOSDiskStoreDoesNotExist(); + } + else + { + var certificateFiles = Directory.EnumerateFiles(s_macOSUserHttpsCertificateLocation, "aspnetcore-localhost-*.pfx"); + foreach (var file in certificateFiles) + { + try + { + var certificate = X509CertificateLoader.LoadPkcs12FromFile(file, password: null); + 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(s_macOSUserKeychain, certificate)) + { + RemoveCertificateFromKeychain(s_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.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs new file mode 100644 index 00000000000..d5efbc01cdb --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/UnixCertificateManager.cs @@ -0,0 +1,1088 @@ +// 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; +using Microsoft.Extensions.Logging; + +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(ILogger logger) : base(logger) + { + } + + 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 = X509CertificateLoader.LoadPkcs12(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 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 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 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 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 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 bool TryDeleteCertificateFile(string certPath) + { + try + { + File.Delete(certPath); + return true; + } + catch (Exception ex) + { + Log.UnixCertificateFileDeletionException(certPath, ex.Message); + return false; + } + } + + private 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 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 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 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 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.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs b/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs new file mode 100644 index 00000000000..b06626256a0 --- /dev/null +++ b/src/Aspire.Cli/Certificates/CertificateGeneration/WindowsCertificateManager.cs @@ -0,0 +1,166 @@ +// 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; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Certificates.Generation; + +[SupportedOSPlatform("windows")] +internal sealed class WindowsCertificateManager : CertificateManager +{ + private const int UserCancelledErrorCode = 1223; + + public WindowsCertificateManager(ILogger logger) : base(logger) + { + } + + // 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 = X509CertificateLoader.LoadPkcs12(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.Cli/Certificates/NativeCertificateToolRunner.cs b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs new file mode 100644 index 00000000000..741bcae5242 --- /dev/null +++ b/src/Aspire.Cli/Certificates/NativeCertificateToolRunner.cs @@ -0,0 +1,94 @@ +// 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; + +namespace Aspire.Cli.Certificates; + +/// +/// Certificate tool runner that uses the native CertificateManager directly (no subprocess needed). +/// +internal sealed class NativeCertificateToolRunner(CertificateManager certificateManager) : ICertificateToolRunner +{ + public Task<(int ExitCode, CertificateTrustResult? Result)> CheckHttpCertificateMachineReadableAsync( + DotNetCliRunnerInvocationOptions options, + CancellationToken cancellationToken) + { + var availableCertificates = certificateManager.ListCertificates( + StoreName.My, StoreLocation.CurrentUser, isValid: true); + + var now = DateTimeOffset.Now; + var certInfos = availableCertificates.Select(cert => + { + var status = certificateManager.CheckCertificateState(cert); + var trustLevel = status.Success ? certificateManager.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 = certificateManager.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) + { + var now = DateTimeOffset.Now; + var result = certificateManager.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/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..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; @@ -222,22 +223,9 @@ 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 - builder.Services.AddSingleton(sp => - { - var loggerFactory = sp.GetRequiredService(); - var bundleService = sp.GetRequiredService(); - - if (bundleService.IsBundle) - { - return new BundleCertificateToolRunner( - bundleService, - loggerFactory.CreateLogger()); - } - - // Fall back to SDK-based runner - return new SdkCertificateToolRunner(loggerFactory.CreateLogger()); - }); + // Register certificate tool runner - uses native CertificateManager directly (no subprocess needed) + builder.Services.AddSingleton(sp => CertificateManager.Create(sp.GetRequiredService>())); + builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddSingleton(); 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..5b8d8f7a776 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 the dashboard path so the AppHost can locate and launch the dashboard binary + 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..cf20a5ac0e7 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -101,7 +101,6 @@ private static (string NetCoreVersion, string AspNetCoreVersion) GetAppHostFrame 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); @@ -111,14 +110,12 @@ private static (string NetCoreVersion, string AspNetCoreVersion) GetAppHostFrame } 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)) { @@ -126,25 +123,21 @@ private static (string NetCoreVersion, string AspNetCoreVersion) GetFrameworkVer } 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 + ".exe" => Path.GetFileNameWithoutExtension(fileName), + _ => fileName }; 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(); @@ -153,8 +146,8 @@ private static (string NetCoreVersion, string AspNetCoreVersion) GetFrameworkVer throw new DistributedApplicationException($"Failed to parse AppHost runtime config: {runtimeConfigPath}"); } - string netCoreVersion = FallbackNetCoreVersion; // Default fallback - string aspNetCoreVersion = FallbackAspNetCoreVersion; // Default fallback + string netCoreVersion = FallbackNetCoreVersion; + string aspNetCoreVersion = FallbackAspNetCoreVersion; if (configJson["runtimeOptions"]?.AsObject() is { } runtimeOptions && runtimeOptions["frameworks"]?.AsArray() is { } frameworks) @@ -188,32 +181,26 @@ private static (string NetCoreVersion, string AspNetCoreVersion) GetFallbackFram 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 + ".exe" => Path.GetFileNameWithoutExtension(fileName), + _ => fileName }; 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 @@ -236,7 +223,6 @@ private string CreateCustomRuntimeConfig(string dashboardPath) return customConfigPath; } - // Read the original runtime config var originalConfigText = File.ReadAllText(originalRuntimeConfig); var configJson = JsonNode.Parse(originalConfigText)?.AsObject(); @@ -245,10 +231,8 @@ private string CreateCustomRuntimeConfig(string dashboardPath) 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) { @@ -270,7 +254,6 @@ private string CreateCustomRuntimeConfig(string dashboardPath) } } - // 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 })); @@ -288,35 +271,23 @@ 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 is self-contained, run directly with "dashboard" subcommand 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)) + + 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 { - // DLL-based deployment - find the DLL and run via dotnet exec + // 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)) { @@ -324,7 +295,6 @@ private void AddDashboardResource(DistributedApplicationModel model) } else { - // For executables with separate DLLs var directory = Path.GetDirectoryName(fullyQualifiedDashboardPath)!; var fileName = Path.GetFileName(fullyQualifiedDashboardPath); var baseName = fileName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) @@ -338,8 +308,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 => { @@ -926,51 +895,6 @@ public async ValueTask DisposeAsync() } } } - - /// - /// 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); - } } internal sealed class DashboardLogMessage diff --git a/src/Aspire.Managed/Aspire.Managed.csproj b/src/Aspire.Managed/Aspire.Managed.csproj new file mode 100644 index 00000000000..2226596ef83 --- /dev/null +++ b/src/Aspire.Managed/Aspire.Managed.csproj @@ -0,0 +1,49 @@ + + + + Exe + net10.0 + enable + enable + aspire-managed + + + false + false + false + + $(NoWarn);CS1591 + + + true + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000000..ff1e24f37d7 --- /dev/null +++ b/src/Aspire.Managed/Program.cs @@ -0,0 +1,47 @@ +// 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; +using Aspire.Managed.NuGet.Commands; +using System.CommandLine; + +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) +{ + 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() +{ + 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/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs index 9f28ed4d74d..9046e7b7585 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardLifecycleHookTests.cs @@ -340,14 +340,11 @@ public async Task AddDashboardResource_CreatesExecutableResourceWithCustomRuntim 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); @@ -363,7 +360,6 @@ public async Task AddDashboardResource_WithExecutablePath_CreatesCorrectArgument 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); @@ -374,7 +370,6 @@ public async Task AddDashboardResource_WithExecutablePath_CreatesCorrectArgument 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"); @@ -415,11 +410,10 @@ public async Task AddDashboardResource_WithExecutablePath_CreatesCorrectArgument 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 + Assert.Equal(dashboardDll, args[3]); } finally { - // Cleanup if (Directory.Exists(tempDir)) { Directory.Delete(tempDir, recursive: true); @@ -435,18 +429,16 @@ public async Task AddDashboardResource_WithUnixExecutablePath_CreatesCorrectArgu 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 dashboardExe = Path.Combine(tempDir, "Aspire.Dashboard"); 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"); @@ -487,11 +479,10 @@ public async Task AddDashboardResource_WithUnixExecutablePath_CreatesCorrectArgu 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 + Assert.Equal(dashboardDll, args[3]); } finally { - // Cleanup if (Directory.Exists(tempDir)) { Directory.Delete(tempDir, recursive: true); @@ -507,7 +498,6 @@ public async Task AddDashboardResource_WithDirectDllPath_CreatesCorrectArguments 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); @@ -517,7 +507,6 @@ public async Task AddDashboardResource_WithDirectDllPath_CreatesCorrectArguments 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 @@ -557,11 +546,10 @@ public async Task AddDashboardResource_WithDirectDllPath_CreatesCorrectArguments 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 + Assert.Equal(dashboardDll, args[3]); } finally { - // Cleanup if (Directory.Exists(tempDir)) { Directory.Delete(tempDir, recursive: true); diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs index f04e285721a..ddf7ac4354e 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -51,7 +51,6 @@ public async Task DashboardIsAutomaticallyAddedAsHiddenResource(string showDashb 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.True(initialSnapshot.InitialSnapshot.IsHidden); @@ -261,7 +260,7 @@ public async Task DashboardWithDllPathLaunchesDotnet() 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.EndsWith(".json", args[2]); Assert.Equal(dashboardPath, args[3]); } 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(" ")) 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.