diff --git a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs index 4cf1298e102..51098e2dca9 100644 --- a/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs +++ b/src/Aspire.Dashboard/Components/Layout/MainLayout.razor.cs @@ -200,7 +200,7 @@ private bool ShouldShowUnsecuredMcpMessage() private bool ShouldShowUnsecuredApiMessage() { // Only show warning if API is enabled and unsecured - return Options.CurrentValue.Api.Enabled == true && + return Options.CurrentValue.Api.Enabled.GetValueOrDefault() && Options.CurrentValue.Api.AuthMode == ApiAuthMode.Unsecured; } diff --git a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs index 7a14a80af5e..a91ee1d6187 100644 --- a/src/Aspire.Dashboard/Configuration/DashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/DashboardOptions.cs @@ -150,7 +150,7 @@ public sealed class ApiOptions /// /// Gets or sets whether the Telemetry HTTP API is enabled. /// When false, the /api/telemetry/* endpoints are not registered. - /// Defaults to true. + /// Defaults to false. /// public bool? Enabled { get; set; } diff --git a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs index b166dfda5b4..6f982a8b29a 100644 --- a/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs +++ b/src/Aspire.Dashboard/Configuration/PostConfigureDashboardOptions.cs @@ -87,6 +87,8 @@ public void PostConfigure(string? name, DashboardOptions options) options.AI.Disabled = _configuration.GetBool(DashboardConfigNames.DashboardAIDisabledName.ConfigKey); + options.Api.Enabled ??= _configuration.GetBool(DashboardConfigNames.DashboardAspireApiEnabledName.ConfigKey); + // Normalize API keys: Api is canonical, falls back to Mcp if not set. // Api -> Mcp fallback only (not bidirectional). if (string.IsNullOrEmpty(options.Mcp.PrimaryApiKey) && !string.IsNullOrEmpty(options.Api.PrimaryApiKey)) diff --git a/src/Aspire.Dashboard/DashboardEndpointsBuilder.cs b/src/Aspire.Dashboard/DashboardEndpointsBuilder.cs index 194547390d5..6b38baa1b7a 100644 --- a/src/Aspire.Dashboard/DashboardEndpointsBuilder.cs +++ b/src/Aspire.Dashboard/DashboardEndpointsBuilder.cs @@ -108,8 +108,8 @@ public static void MapDashboardMcp(this IEndpointRouteBuilder endpoints, Dashboa public static void MapTelemetryApi(this IEndpointRouteBuilder endpoints, DashboardOptions dashboardOptions) { - // Check if API is disabled (defaults to enabled if not specified) - if (dashboardOptions.Api.Enabled == false) + // Check if API is enabled (defaults to disabled if not specified) + if (!dashboardOptions.Api.Enabled.GetValueOrDefault()) { return; } diff --git a/src/Aspire.Dashboard/DashboardWebApplication.cs b/src/Aspire.Dashboard/DashboardWebApplication.cs index 2be63142af4..65733abc012 100644 --- a/src/Aspire.Dashboard/DashboardWebApplication.cs +++ b/src/Aspire.Dashboard/DashboardWebApplication.cs @@ -413,7 +413,7 @@ public DashboardWebApplication( // Only show API security warning if API is enabled and unsecured // API runs on the frontend endpoint (no separate accessor needed) - if (_dashboardOptionsMonitor.CurrentValue.Api.Enabled == true && + if (_dashboardOptionsMonitor.CurrentValue.Api.Enabled.GetValueOrDefault() && _dashboardOptionsMonitor.CurrentValue.Api.AuthMode == ApiAuthMode.Unsecured) { _logger.LogWarning("Dashboard API is unsecured. Untrusted apps can access sensitive telemetry data."); diff --git a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs index a991993ff92..a1dada521be 100644 --- a/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs +++ b/src/Aspire.Hosting/Dashboard/DashboardEventHandlers.cs @@ -639,6 +639,9 @@ internal async Task ConfigureEnvironmentVariables(EnvironmentCallbackContext con context.EnvironmentVariables[DashboardConfigNames.DashboardApiAuthModeName.EnvVarName] = "Unsecured"; } + // Enable dashboard API + context.EnvironmentVariables[DashboardConfigNames.DashboardAspireApiEnabledName.EnvVarName] = "true"; + // Configure dashboard to show CLI MCP instructions when running with an AppHost (not in standalone mode) context.EnvironmentVariables[DashboardConfigNames.DashboardMcpUseCliMcpName.EnvVarName] = "true"; diff --git a/src/Shared/DashboardConfigNames.cs b/src/Shared/DashboardConfigNames.cs index 80abe566137..d8ef0d782cc 100644 --- a/src/Shared/DashboardConfigNames.cs +++ b/src/Shared/DashboardConfigNames.cs @@ -14,6 +14,7 @@ internal static class DashboardConfigNames public static readonly ConfigName DashboardConfigFilePathName = new(KnownConfigNames.DashboardConfigFilePath); public static readonly ConfigName DashboardFileConfigDirectoryName = new(KnownConfigNames.DashboardFileConfigDirectory); public static readonly ConfigName DashboardAIDisabledName = new(KnownConfigNames.DashboardAIDisabled); + public static readonly ConfigName DashboardAspireApiEnabledName = new(KnownConfigNames.DashboardApiEnabled); public static readonly ConfigName ResourceServiceUrlName = new(KnownConfigNames.ResourceServiceEndpointUrl); public static readonly ConfigName ForwardedHeaders = new(KnownConfigNames.DashboardForwardedHeadersEnabled); diff --git a/src/Shared/KnownConfigNames.cs b/src/Shared/KnownConfigNames.cs index d1b8af9fe23..57d3f2dbe54 100644 --- a/src/Shared/KnownConfigNames.cs +++ b/src/Shared/KnownConfigNames.cs @@ -18,6 +18,7 @@ internal static class KnownConfigNames public const string DashboardConfigFilePath = "ASPIRE_DASHBOARD_CONFIG_FILE_PATH"; public const string DashboardFileConfigDirectory = "ASPIRE_DASHBOARD_FILE_CONFIG_DIRECTORY"; public const string DashboardAIDisabled = "ASPIRE_DASHBOARD_AI_DISABLED"; + public const string DashboardApiEnabled = "ASPIRE_DASHBOARD_API_ENABLED"; public const string DashboardForwardedHeadersEnabled = "ASPIRE_DASHBOARD_FORWARDEDHEADERS_ENABLED"; public const string ShowDashboardResources = "ASPIRE_SHOW_DASHBOARD_RESOURCES"; diff --git a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs index 7e081925a4e..a245a157b94 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/FrontendBrowserTokenAuthTests.cs @@ -212,6 +212,11 @@ public async Task LogOutput_NoToken_GeneratedTokenLogged() Assert.Equal(LogLevel.Warning, w.LogLevel); }, w => + { + Assert.Equal("Dashboard API is unsecured. Untrusted apps can access sensitive telemetry data.", LogTestHelpers.GetValue(w, "{OriginalFormat}")); + Assert.Equal(LogLevel.Warning, w.LogLevel); + }, + w => { Assert.Equal("Login to the dashboard at {DashboardLoginUrl}", LogTestHelpers.GetValue(w, "{OriginalFormat}")); diff --git a/tests/Aspire.Dashboard.Tests/Integration/IntegrationTestHelpers.cs b/tests/Aspire.Dashboard.Tests/Integration/IntegrationTestHelpers.cs index 4a69f505db3..4a002981404 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/IntegrationTestHelpers.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/IntegrationTestHelpers.cs @@ -64,6 +64,7 @@ public static DashboardWebApplication CreateDashboardWebApplication( [DashboardConfigNames.DashboardMcpUrlName.ConfigKey] = "http://127.0.0.1:0", [DashboardConfigNames.DashboardOtlpAuthModeName.ConfigKey] = nameof(OtlpAuthMode.Unsecured), [DashboardConfigNames.DashboardFrontendAuthModeName.ConfigKey] = nameof(FrontendAuthMode.Unsecured), + [DashboardConfigNames.DashboardApiEnabledName.ConfigKey] = "true", // Allow the requirement of HTTPS communication with the OpenIdConnect authority to be relaxed during tests. ["Authentication:Schemes:OpenIdConnect:RequireHttpsMetadata"] = "false" }; diff --git a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs index c0473235abe..4b000997ff9 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/StartupTests.cs @@ -725,6 +725,11 @@ public async Task LogOutput_DynamicPort_PortResolvedInLogs() { Assert.Equal("MCP server is unsecured. Untrusted apps can access sensitive information.", LogTestHelpers.GetValue(w, "{OriginalFormat}")); Assert.Equal(LogLevel.Warning, w.LogLevel); + }, + w => + { + Assert.Equal("Dashboard API is unsecured. Untrusted apps can access sensitive telemetry data.", LogTestHelpers.GetValue(w, "{OriginalFormat}")); + Assert.Equal(LogLevel.Warning, w.LogLevel); }); } @@ -765,6 +770,11 @@ public async Task LogOutput_NoOtlpEndpoints_NoOtlpLogs() { Assert.Equal("MCP server is unsecured. Untrusted apps can access sensitive information.", LogTestHelpers.GetValue(w, "{OriginalFormat}")); Assert.Equal(LogLevel.Warning, w.LogLevel); + }, + w => + { + Assert.Equal("Dashboard API is unsecured. Untrusted apps can access sensitive telemetry data.", LogTestHelpers.GetValue(w, "{OriginalFormat}")); + Assert.Equal(LogLevel.Warning, w.LogLevel); }); } @@ -790,6 +800,53 @@ public async Task LogOutput_McpDisabled_NoMcpWarningLog() Assert.DoesNotContain(l, w => LogTestHelpers.GetValue(w, "{OriginalFormat}")?.ToString()?.Contains("MCP server is unsecured") == true); } + [Theory] + [InlineData(null, HttpStatusCode.NotFound)] + [InlineData(true, HttpStatusCode.OK)] + [InlineData(false, HttpStatusCode.NotFound)] + public async Task ApiEnabled_ReturnsExpectedStatusAndWarning(bool? enabled, HttpStatusCode expectedStatusCode) + { + const string ApiUnsecuredWarning = "Dashboard API is unsecured. Untrusted apps can access sensitive telemetry data."; + + // Arrange + var testSink = new TestSink(); + await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(testOutputHelper, + additionalConfiguration: config => + { + if (enabled is not null) + { + config[DashboardConfigNames.DashboardApiEnabledName.ConfigKey] = enabled.Value.ToString(); + } + else + { + config.Remove(DashboardConfigNames.DashboardApiEnabledName.ConfigKey); + } + }, + testSink: testSink); + await app.StartAsync().DefaultTimeout(); + + using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.FrontendSingleEndPointAccessor().EndPoint}"); + + // Act + var response = await httpClient.GetAsync("/api/telemetry/spans").DefaultTimeout(); + + // Assert + Assert.Equal(expectedStatusCode, response.StatusCode); + + var warnings = testSink.Writes + .Where(w => w.LoggerName == typeof(DashboardWebApplication).FullName && w.LogLevel >= LogLevel.Warning) + .ToList(); + + if (enabled == true) + { + Assert.Contains(warnings, w => LogTestHelpers.GetValue(w, "{OriginalFormat}")?.ToString() == ApiUnsecuredWarning); + } + else + { + Assert.DoesNotContain(warnings, w => LogTestHelpers.GetValue(w, "{OriginalFormat}")?.ToString() == ApiUnsecuredWarning); + } + } + [Fact] public async Task LogOutput_LocalhostAddress_LocalhostInLogOutput() { @@ -885,6 +942,11 @@ await ServerRetryHelper.BindPortsWithRetry(async ports => { Assert.Equal("MCP server is unsecured. Untrusted apps can access sensitive information.", LogTestHelpers.GetValue(w, "{OriginalFormat}")); Assert.Equal(LogLevel.Warning, w.LogLevel); + }, + w => + { + Assert.Equal("Dashboard API is unsecured. Untrusted apps can access sensitive telemetry data.", LogTestHelpers.GetValue(w, "{OriginalFormat}")); + Assert.Equal(LogLevel.Warning, w.LogLevel); }); } diff --git a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs index 21a6c745617..15d6995796c 100644 --- a/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs +++ b/tests/Aspire.Dashboard.Tests/Integration/TelemetryApiTests.cs @@ -181,25 +181,6 @@ public async Task GetSpans_WithWrongApiKey_ReturnsUnauthorized() Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } - [Fact] - public async Task GetSpans_ApiDisabled_Returns404() - { - // Arrange - disable the Telemetry API explicitly - await using var app = IntegrationTestHelpers.CreateDashboardWebApplication(_testOutputHelper, config => - { - config[DashboardConfigNames.DashboardApiEnabledName.ConfigKey] = "false"; - }); - await app.StartAsync().DefaultTimeout(); - - using var httpClient = IntegrationTestHelpers.CreateHttpClient($"http://{app.FrontendSingleEndPointAccessor().EndPoint}"); - - // Act - var response = await httpClient.GetAsync("/api/telemetry/spans").DefaultTimeout(); - - // Assert - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - } - [Fact] public async Task GetLogs_UnsecuredMode_Returns200() { diff --git a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs index cb948fc505c..f84aa3eb2c5 100644 --- a/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs +++ b/tests/Aspire.Hosting.Tests/Dashboard/DashboardResourceTests.cs @@ -119,6 +119,11 @@ public async Task DashboardDoesNotAddResource_ConfiguresExistingDashboard(string .ToList(); Assert.Collection(config, + e => + { + Assert.Equal(KnownConfigNames.DashboardApiEnabled, e.Key); + Assert.Equal("true", e.Value); + }, e => { Assert.Equal(KnownConfigNames.DashboardMcpEndpointUrl, e.Key);