Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for dashboard listening on any IP #5941

Merged
merged 6 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 39 additions & 17 deletions src/Aspire.Dashboard/Configuration/DashboardOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ public sealed class AllowedCertificateRule
// Don't set values after validating/parsing options.
public sealed class OtlpOptions
{
private Uri? _parsedGrpcEndpointUrl;
private Uri? _parsedHttpEndpointUrl;
private BindingAddress? _parsedGrpcEndpointAddress;
private BindingAddress? _parsedHttpEndpointAddress;
private byte[]? _primaryApiKeyBytes;
private byte[]? _secondaryApiKeyBytes;

Expand All @@ -83,14 +83,14 @@ public sealed class OtlpOptions

public List<AllowedCertificateRule> AllowedCertificates { get; set; } = new();

public Uri? GetGrpcEndpointUri()
public BindingAddress? GetGrpcEndpointAddress()
{
return _parsedGrpcEndpointUrl;
return _parsedGrpcEndpointAddress;
}

public Uri? GetHttpEndpointUri()
public BindingAddress? GetHttpEndpointAddress()
{
return _parsedHttpEndpointUrl;
return _parsedHttpEndpointAddress;
}

public byte[] GetPrimaryApiKeyBytes()
Expand All @@ -111,13 +111,13 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
return false;
}

if (!string.IsNullOrEmpty(GrpcEndpointUrl) && !Uri.TryCreate(GrpcEndpointUrl, UriKind.Absolute, out _parsedGrpcEndpointUrl))
if (!string.IsNullOrEmpty(GrpcEndpointUrl) && !OptionsHelpers.TryParseBindingAddress(GrpcEndpointUrl, out _parsedGrpcEndpointAddress))
{
errorMessage = $"Failed to parse OTLP gRPC endpoint URL '{GrpcEndpointUrl}'.";
return false;
}

if (!string.IsNullOrEmpty(HttpEndpointUrl) && !Uri.TryCreate(HttpEndpointUrl, UriKind.Absolute, out _parsedHttpEndpointUrl))
if (!string.IsNullOrEmpty(HttpEndpointUrl) && !OptionsHelpers.TryParseBindingAddress(HttpEndpointUrl, out _parsedHttpEndpointAddress))
{
errorMessage = $"Failed to parse OTLP HTTP endpoint URL '{HttpEndpointUrl}'.";
return false;
Expand All @@ -141,12 +141,15 @@ public sealed class OtlpCors
{
public string? AllowedOrigins { get; set; }
public string? AllowedHeaders { get; set; }

[MemberNotNullWhen(true, nameof(AllowedOrigins))]
public bool IsCorsEnabled => !string.IsNullOrEmpty(AllowedOrigins);
}

// Don't set values after validating/parsing options.
public sealed class FrontendOptions
{
private List<Uri>? _parsedEndpointUrls;
private List<BindingAddress>? _parsedEndpointAddresses;
private byte[]? _browserTokenBytes;

public string? EndpointUrls { get; set; }
Expand All @@ -166,10 +169,10 @@ public sealed class FrontendOptions

public byte[]? GetBrowserTokenBytes() => _browserTokenBytes;

public IReadOnlyList<Uri> GetEndpointUris()
public IReadOnlyList<BindingAddress> GetEndpointAddresses()
{
Debug.Assert(_parsedEndpointUrls is not null, "Should have been parsed during validation.");
return _parsedEndpointUrls;
Debug.Assert(_parsedEndpointAddresses is not null, "Should have been parsed during validation.");
return _parsedEndpointAddresses;
}

internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
Expand All @@ -182,18 +185,20 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
else
{
var parts = EndpointUrls.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var uris = new List<Uri>(parts.Length);
var addresses = new List<BindingAddress>(parts.Length);
foreach (var part in parts)
{
if (!Uri.TryCreate(part, UriKind.Absolute, out var uri))
if (OptionsHelpers.TryParseBindingAddress(part, out var bindingAddress))
{
addresses.Add(bindingAddress);
}
else
{
errorMessage = $"Failed to parse frontend endpoint URLs '{EndpointUrls}'.";
return false;
}

uris.Add(uri);
}
_parsedEndpointUrls = uris;
_parsedEndpointAddresses = addresses;
}

_browserTokenBytes = BrowserToken != null ? Encoding.UTF8.GetBytes(BrowserToken) : null;
Expand All @@ -203,6 +208,23 @@ internal bool TryParseOptions([NotNullWhen(false)] out string? errorMessage)
}
}

public static class OptionsHelpers
{
public static bool TryParseBindingAddress(string address, [NotNullWhen(true)] out BindingAddress? bindingAddress)
{
try
{
bindingAddress = BindingAddress.Parse(address);
return true;
}
catch
{
bindingAddress = null;
return false;
}
}
}

public sealed class TelemetryLimitOptions
{
public int MaxLogCount { get; set; } = 10_000;
Expand Down
117 changes: 77 additions & 40 deletions src/Aspire.Dashboard/DashboardWebApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.HttpsPolicy;
using Microsoft.AspNetCore.Server.Kestrel;
using Microsoft.AspNetCore.Server.Kestrel.Core;
Expand All @@ -46,13 +47,38 @@ public sealed class DashboardWebApplication : IAsyncDisposable
private readonly ILogger<DashboardWebApplication> _logger;
private readonly IOptionsMonitor<DashboardOptions> _dashboardOptionsMonitor;
private readonly IReadOnlyList<string> _validationFailures;
private Func<EndpointInfo>? _frontendEndPointAccessor;
private readonly List<Func<EndpointInfo>> _frontendEndPointAccessor = new();
private Func<EndpointInfo>? _otlpServiceGrpcEndPointAccessor;
private Func<EndpointInfo>? _otlpServiceHttpEndPointAccessor;

public Func<EndpointInfo> FrontendEndPointAccessor
public List<Func<EndpointInfo>> FrontendEndPointsAccessor
{
get => _frontendEndPointAccessor ?? throw new InvalidOperationException("WebApplication not started yet.");
get
{
if (_frontendEndPointAccessor.Count == 0)
{
throw new InvalidOperationException("WebApplication not started yet.");
}

return _frontendEndPointAccessor;
}
}

public Func<EndpointInfo> FrontendSingleEndPointAccessor
{
get
{
if (_frontendEndPointAccessor.Count == 0)
{
throw new InvalidOperationException("WebApplication not started yet.");
}
else if (_frontendEndPointAccessor.Count > 1)
{
throw new InvalidOperationException("Multiple frontend endpoints.");
}

return _frontendEndPointAccessor[0];
}
}

public Func<EndpointInfo> OtlpServiceGrpcEndPointAccessor
Expand Down Expand Up @@ -141,8 +167,8 @@ public DashboardWebApplication(

ConfigureKestrelEndpoints(builder, dashboardOptions);

var browserHttpsPort = dashboardOptions.Frontend.GetEndpointUris().FirstOrDefault(IsHttpsOrNull)?.Port;
var isAllHttps = browserHttpsPort is not null && IsHttpsOrNull(dashboardOptions.Otlp.GetGrpcEndpointUri()) && IsHttpsOrNull(dashboardOptions.Otlp.GetHttpEndpointUri());
var browserHttpsPort = dashboardOptions.Frontend.GetEndpointAddresses().FirstOrDefault(IsHttpsOrNull)?.Port;
var isAllHttps = browserHttpsPort is not null && IsHttpsOrNull(dashboardOptions.Otlp.GetGrpcEndpointAddress()) && IsHttpsOrNull(dashboardOptions.Otlp.GetHttpEndpointAddress());
if (isAllHttps)
{
// Explicitly configure the HTTPS redirect port as we're possibly listening on multiple HTTPS addresses
Expand All @@ -164,19 +190,10 @@ public DashboardWebApplication(
// See https://learn.microsoft.com/aspnet/core/performance/response-compression#compression-with-https for more information
options.MimeTypes = ["text/javascript", "application/javascript", "text/css", "image/svg+xml"];
});
if (!string.IsNullOrEmpty(dashboardOptions.Otlp.Cors.AllowedOrigins))
if (dashboardOptions.Otlp.Cors.IsCorsEnabled)
{
builder.Services.AddCors(options =>
{
// Default policy allows the dashboard's origins.
// This is added so CORS middleware doesn't report failure for dashboard browser requests that include an origin header.
options.AddDefaultPolicy(builder =>
{
builder.WithOrigins(dashboardOptions.Frontend.GetEndpointUris().Select(uri => uri.OriginalString).ToArray());
builder.AllowAnyHeader();
builder.AllowAnyMethod();
});

options.AddPolicy(OtlpHttpEndpointsBuilder.CorsPolicyName, builder =>
{
var corsOptions = dashboardOptions.Otlp.Cors;
Expand Down Expand Up @@ -256,9 +273,24 @@ public DashboardWebApplication(

_app.Lifetime.ApplicationStarted.Register(() =>
{
if (_frontendEndPointAccessor != null)
if (_frontendEndPointAccessor.Count > 0)
{
var url = _frontendEndPointAccessor().Address;
if (dashboardOptions.Otlp.Cors.IsCorsEnabled)
{
var corsOptions = _app.Services.GetRequiredService<IOptions<CorsOptions>>().Value;

// Default policy allows the dashboard's origins.
// This is added so CORS middleware doesn't report failure for dashboard browser requests that include an origin header.
// Needs to be added once app is started so the resolved frontend endpoint can be used.
corsOptions.AddDefaultPolicy(builder =>
{
builder.WithOrigins(_frontendEndPointAccessor.Select(accessor => accessor().Address).ToArray());
builder.AllowAnyHeader();
builder.AllowAnyMethod();
});
}

var url = _frontendEndPointAccessor[0]().Address;
_logger.LogInformation("Now listening on: {DashboardUri}", url);

var options = _app.Services.GetRequiredService<IOptionsMonitor<DashboardOptions>>().CurrentValue;
Expand Down Expand Up @@ -447,46 +479,46 @@ private static bool TryGetDashboardOptions(WebApplicationBuilder builder, IConfi
private void ConfigureKestrelEndpoints(WebApplicationBuilder builder, DashboardOptions dashboardOptions)
{
// A single endpoint is configured if URLs are the same and the port isn't dynamic.
var frontendUris = dashboardOptions.Frontend.GetEndpointUris();
var otlpGrpcUri = dashboardOptions.Otlp.GetGrpcEndpointUri();
var otlpHttpUri = dashboardOptions.Otlp.GetHttpEndpointUri();
var hasSingleEndpoint = frontendUris.Count == 1 && IsSameOrNull(frontendUris[0], otlpGrpcUri) && IsSameOrNull(frontendUris[0], otlpHttpUri);
var frontendAddresses = dashboardOptions.Frontend.GetEndpointAddresses();
var otlpGrpcAddress = dashboardOptions.Otlp.GetGrpcEndpointAddress();
var otlpHttpAddress = dashboardOptions.Otlp.GetHttpEndpointAddress();
var hasSingleEndpoint = frontendAddresses.Count == 1 && IsSameOrNull(frontendAddresses[0], otlpGrpcAddress) && IsSameOrNull(frontendAddresses[0], otlpHttpAddress);

var initialValues = new Dictionary<string, string?>();
var browserEndpointNames = new List<string>(capacity: frontendUris.Count);
var browserEndpointNames = new List<string>(capacity: frontendAddresses.Count);

if (!hasSingleEndpoint)
{
// Translate high-level config settings such as DOTNET_DASHBOARD_OTLP_ENDPOINT_URL and ASPNETCORE_URLS
// to Kestrel's schema for loading endpoints from configuration.
if (otlpGrpcUri != null)
if (otlpGrpcAddress != null)
{
AddEndpointConfiguration(initialValues, "OtlpGrpc", otlpGrpcUri.OriginalString, HttpProtocols.Http2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate);
AddEndpointConfiguration(initialValues, "OtlpGrpc", otlpGrpcAddress.ToString(), HttpProtocols.Http2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate);
}
if (otlpHttpUri != null)
if (otlpHttpAddress != null)
{
AddEndpointConfiguration(initialValues, "OtlpHttp", otlpHttpUri.OriginalString, HttpProtocols.Http1AndHttp2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate);
AddEndpointConfiguration(initialValues, "OtlpHttp", otlpHttpAddress.ToString(), HttpProtocols.Http1AndHttp2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate);
}

if (frontendUris.Count == 1)
if (frontendAddresses.Count == 1)
{
browserEndpointNames.Add("Browser");
AddEndpointConfiguration(initialValues, "Browser", frontendUris[0].OriginalString);
AddEndpointConfiguration(initialValues, "Browser", frontendAddresses[0].ToString());
}
else
{
for (var i = 0; i < frontendUris.Count; i++)
for (var i = 0; i < frontendAddresses.Count; i++)
{
var name = $"Browser{i}";
browserEndpointNames.Add(name);
AddEndpointConfiguration(initialValues, name, frontendUris[i].OriginalString);
AddEndpointConfiguration(initialValues, name, frontendAddresses[i].ToString());
}
}
}
else
{
// At least one gRPC endpoint must be present.
var url = otlpGrpcUri?.OriginalString ?? otlpHttpUri?.OriginalString;
var url = otlpGrpcAddress?.ToString() ?? otlpHttpAddress?.ToString();
AddEndpointConfiguration(initialValues, "OtlpGrpc", url!, HttpProtocols.Http1AndHttp2, requiredClientCertificate: dashboardOptions.Otlp.AuthMode == OtlpAuthMode.ClientCertificate);
}

Expand All @@ -499,7 +531,7 @@ static void AddEndpointConfiguration(Dictionary<string, string?> values, string
values[$"Kestrel:Endpoints:{endpointName}:Protocols"] = protocols.ToString();
}

if (requiredClientCertificate && IsHttpsOrNull(new Uri(url)))
if (requiredClientCertificate && IsHttpsOrNull(BindingAddress.Parse(url)))
{
values[$"Kestrel:Endpoints:{endpointName}:ClientCertificateMode"] = ClientCertificateMode.RequireCertificate.ToString();
}
Expand All @@ -524,7 +556,7 @@ static void AddEndpointConfiguration(Dictionary<string, string?> values, string

// Only the last endpoint is accessible. Tests should only need one but
// this will need to be improved if that changes.
_frontendEndPointAccessor ??= CreateEndPointAccessor(endpointConfiguration);
_frontendEndPointAccessor.Add(CreateEndPointAccessor(endpointConfiguration));
});
}

Expand All @@ -545,7 +577,7 @@ static void AddEndpointConfiguration(Dictionary<string, string?> values, string
}

connectionTypes.Add(ConnectionType.Frontend);
_frontendEndPointAccessor = _otlpServiceGrpcEndPointAccessor;
_frontendEndPointAccessor.Add(_otlpServiceGrpcEndPointAccessor);
}

endpointConfiguration.ListenOptions.UseConnectionTypes(connectionTypes.ToArray());
Expand Down Expand Up @@ -577,7 +609,7 @@ static void AddEndpointConfiguration(Dictionary<string, string?> values, string
}

connectionTypes.Add(ConnectionType.Frontend);
_frontendEndPointAccessor = _otlpServiceGrpcEndPointAccessor;
_frontendEndPointAccessor.Add(_otlpServiceHttpEndPointAccessor);
}

endpointConfiguration.ListenOptions.UseConnectionTypes(connectionTypes.ToArray());
Expand All @@ -601,15 +633,20 @@ static Func<EndpointInfo> CreateEndPointAccessor(EndpointConfiguration endpointC
return () =>
{
var endpoint = endpointConfiguration.ListenOptions.IPEndPoint!;
var resolvedAddress = address.Scheme.ToLowerInvariant() + Uri.SchemeDelimiter + address.Host.ToLowerInvariant() + ":" + endpoint.Port.ToString(CultureInfo.InvariantCulture);
var resolvedAddress = (address.Host is "+" or "*")
// Use IP address.
? address.Scheme.ToLowerInvariant() + Uri.SchemeDelimiter + endpoint.ToString()
// Use host name.
: address.Scheme.ToLowerInvariant() + Uri.SchemeDelimiter + address.Host.ToLowerInvariant() + ":" + endpoint.Port.ToString(CultureInfo.InvariantCulture);

return new EndpointInfo(resolvedAddress, endpoint, endpointConfiguration.IsHttps);
};
}
}

private static bool IsSameOrNull(Uri frontendUri, Uri? otlpUrl)
private static bool IsSameOrNull(BindingAddress frontendAddress, BindingAddress? otlpAddress)
{
return otlpUrl == null || (frontendUri == otlpUrl && otlpUrl.Port != 0);
return otlpAddress == null || (frontendAddress.Equals(otlpAddress) && otlpAddress.Port != 0);
}

private static void ConfigureAuthentication(WebApplicationBuilder builder, DashboardOptions dashboardOptions)
Expand Down Expand Up @@ -817,10 +854,10 @@ public ValueTask DisposeAsync()
return _app.DisposeAsync();
}

private static bool IsHttpsOrNull(Uri? uri) => uri == null || string.Equals(uri.Scheme, "https", StringComparison.Ordinal);
private static bool IsHttpsOrNull(BindingAddress? address) => address == null || string.Equals(address.Scheme, "https", StringComparison.Ordinal);
}

public record EndpointInfo(string Address, IPEndPoint EndPoint, bool isHttps);
public record EndpointInfo(string Address, IPEndPoint EndPoint, bool IsHttps);

public static class FrontendAuthorizationDefaults
{
Expand Down
2 changes: 1 addition & 1 deletion src/Aspire.Dashboard/Otlp/Http/OtlpHttpEndpointsBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public static class OtlpHttpEndpointsBuilder

public static void MapHttpOtlpApi(this IEndpointRouteBuilder endpoints, OtlpOptions options)
{
var httpEndpoint = options.GetHttpEndpointUri();
var httpEndpoint = options.GetHttpEndpointAddress();
if (httpEndpoint == null)
{
// Don't map OTLP HTTP route endpoints if there isn't a Kestrel endpoint to access them with.
Expand Down
2 changes: 1 addition & 1 deletion tests/Aspire.Dashboard.Tests/CustomAssert.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ public static class CustomAssert
public static void AssertExceedsMinInterval(TimeSpan duration, TimeSpan minInterval)
{
// Timers are not precise, so we allow for a small margin of error.
Assert.True(duration >= minInterval.Subtract(TimeSpan.FromMilliseconds(30)), $"Elapsed time {duration} should be greater than min interval {minInterval}.");
Assert.True(duration >= minInterval.Subtract(TimeSpan.FromMilliseconds(50)), $"Elapsed time {duration} should be greater than min interval {minInterval}.");
}
}
Loading
Loading