|
// 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.CodeAnalysis;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text.RegularExpressions;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.DevTunnels;
using Aspire.Hosting.Eventing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding dev tunnels resources to an <see cref="IDistributedApplicationBuilder"/>.
/// </summary>
public static partial class DevTunnelsResourceBuilderExtensions
{
private static readonly string s_aspireUserAgent = GetUserAgent();
/// <summary>
/// Adds a dev tunnel resource to the application model.
/// </summary>
/// <remarks>
/// Dev tunnels can be used to expose local endpoints to the public internet via a secure tunnel. By default,
/// the tunnel requires authentication, but anonymous access can be enabled via <see cref="WithAnonymousAccess(IResourceBuilder{DevTunnelResource})"/>.
/// </remarks>
/// <example>
/// The following example shows how to create a dev tunnel resource that exposes all endpoints on a web application project and enable anonymous access:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
/// var web = builder.AddProject<Projects.WebApp>("web");
/// var tunnel = builder.AddDevTunnel("mytunnel")
/// .WithReference(web)
/// .WithAnonymousAccess();
/// builder.Build().Run();
/// </code>
/// </example>
public static IResourceBuilder<DevTunnelResource> AddDevTunnel(
this IDistributedApplicationBuilder builder,
[ResourceName] string name,
string? tunnelId = null,
DevTunnelOptions? options = null)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);
var appHostId = builder.Configuration["AppHost:Sha256"]?[..8];
tunnelId ??= $"{name}-{appHostId}".ToLowerInvariant();
// Validate the TunnelId format [a-z0-9][a-z0-9-]{1,58}[a-z0-9]
if (!TunnelIdRegex().IsMatch(tunnelId))
{
throw new ArgumentException($"""
The tunnel ID '{tunnelId}' is invalid. A valid tunnel ID must:
- start and end with a letter or number
- consist of lowercase letters, numbers, and hyphens
- be 1-58 characters long
""", nameof(tunnelId));
}
options ??= new DevTunnelOptions();
options.Labels ??= [];
options.Labels.Add($"aspire_{name}-{appHostId}");
options.Description ??= $"Dev tunnel for '{name}' in Aspire AppHost '{builder.Environment.ApplicationName}'";
if (!TryValidateLabels(options.Labels, out var errorMessage))
{
throw new ArgumentException(errorMessage, nameof(options));
}
// Add services
builder.Services.TryAddSingleton<DevTunnelCliInstallationManager>();
builder.Services.TryAddSingleton<DevTunnelLoginManager>();
builder.Services.TryAddSingleton<IDevTunnelClient, DevTunnelCliClient>();
var workingDirectory = builder.AppHostDirectory;
var tunnelResource = new DevTunnelResource(name, tunnelId, DevTunnelCli.GetCliPath(builder.Configuration), workingDirectory, options);
// Health check
var healtCheckKey = $"{name}-check";
builder.Services.AddHealthChecks().Add(new HealthCheckRegistration(
healtCheckKey,
services => new DevTunnelHealthCheck(services.GetRequiredService<IDevTunnelClient>(), tunnelResource),
failureStatus: default,
tags: default,
timeout: default));
var rb = builder.AddResource(tunnelResource)
.WithArgs("host", tunnelId, "--nologo")
.WithIconName("CloudBidirectional")
.WithEnvironment("TUNNEL_SERVICE_USER_AGENT", s_aspireUserAgent)
.WithInitialState(new()
{
ResourceType = "DevTunnel",
CreationTimeStamp = DateTime.UtcNow,
State = KnownResourceStates.NotStarted,
Properties = [
new("TunnelId", tunnelId)
]
})
.ExcludeFromManifest() // Dev tunnels do not get deployed
.WithHealthCheck(healtCheckKey)
// Lifecycle
.OnBeforeResourceStarted(static async (tunnelResource, e, ct) =>
{
var logger = e.Services.GetRequiredService<ResourceLoggerService>().GetLogger(tunnelResource);
var eventing = e.Services.GetRequiredService<IDistributedApplicationEventing>();
var devTunnelCliInstallationManager = e.Services.GetRequiredService<DevTunnelCliInstallationManager>();
var devTunnelEnvironmentManager = e.Services.GetRequiredService<DevTunnelLoginManager>();
var devTunnelClient = e.Services.GetRequiredService<IDevTunnelClient>();
// Ensure CLI is available
await devTunnelCliInstallationManager.EnsureInstalledAsync(ct).ConfigureAwait(false);
// Login to the dev tunnels service if needed
logger.LogInformation("Ensuring user is logged in to dev tunnel service");
await devTunnelEnvironmentManager.EnsureUserLoggedInAsync(ct).ConfigureAwait(false);
// Create the dev tunnel
try
{
logger.LogInformation("Creating or updating dev tunnel '{TunnelId}'", tunnelResource.TunnelId);
var tunnelStatus = await devTunnelClient.CreateOrUpdateTunnelAsync(tunnelResource.TunnelId, tunnelResource.Options, ct).ConfigureAwait(false);
logger.LogDebug("Dev tunnel '{TunnelId}' created/updated", tunnelResource.TunnelId);
}
catch (Exception ex)
{
var exception = new DistributedApplicationException($"Error trying to create/update the dev tunnel resource '{tunnelResource.TunnelId}' that this resource has a reference to: {ex.Message}", ex);
foreach (var portResource in tunnelResource.Ports)
{
portResource.TunnelEndpointAllocatedTcs.SetException(exception);
}
throw;
}
// Wait for target resource endpoints to be allocated
await Task.WhenAll(tunnelResource.Ports.Select(p => p.TargetEndpointAllocatedTask)).ConfigureAwait(false);
// Start the tunnel ports
await Task.WhenAll(tunnelResource.Ports.Select(StartPortAsync)).ConfigureAwait(false);
async Task StartPortAsync(DevTunnelPortResource portResource)
{
var portLogger = e.Services.GetRequiredService<ResourceLoggerService>().GetLogger(portResource);
var notifications = e.Services.GetRequiredService<ResourceNotificationService>();
var eventing = e.Services.GetRequiredService<IDistributedApplicationEventing>();
// Clear any prior port status
portLogger.LogInformation("Tunnel starting");
await notifications.PublishUpdateAsync(portResource, snapshot => snapshot with
{
State = KnownResourceStates.Starting
}).ConfigureAwait(false);
// Create/update the tunnel port
try
{
_ = await devTunnelClient.CreateOrUpdatePortAsync(
portResource.DevTunnel.TunnelId,
portResource.TargetEndpoint.Port,
portResource.Options,
ct)
.ConfigureAwait(false);
portLogger.LogInformation("Created/updated dev tunnel port '{Port}' on tunnel '{Tunnel}' targeting endpoint '{Endpoint}' on resource '{TargetResource}'", portResource.TargetEndpoint.Port, portResource.DevTunnel.TunnelId, portResource.TargetEndpoint.EndpointName, portResource.TargetEndpoint.Resource.Name);
}
catch (Exception ex)
{
portLogger.LogError(ex, "Error trying to create/update dev tunnel port '{Port}' on tunnel '{Tunnel}': {Error}", portResource.TargetEndpoint.Port, portResource.DevTunnel.TunnelId, ex.Message);
portResource.TunnelEndpointAllocatedTcs.SetException(ex);
throw;
}
await eventing.PublishAsync<BeforeResourceStartedEvent>(new(portResource, e.Services), EventDispatchBehavior.NonBlockingConcurrent, ct).ConfigureAwait(false);
}
})
.OnResourceStopped(static (tunnelResource, e, ct) =>
{
// Tunnel stopped, mark status as null
tunnelResource.LastKnownStatus = null;
return Task.CompletedTask;
});
// Tunnels will expire after not being hosted for 30 days by default so we won't forcibly delete them when the resource or AppHost is stopped
return rb;
}
/// <summary>
/// Adds ports on the dev tunnel for all endpoints found on the referenced resource and sets whether anonymous access is allowed.
/// </summary>
/// <param name="tunnelBuilder">The resource builder.</param>
/// <param name="resourceBuilder">The resource builder for the referenced resource.</param>
/// <param name="allowAnonymous">Whether anonymous access is allowed.</param>
/// <returns>The resource builder.</returns>
public static IResourceBuilder<DevTunnelResource> WithReference<TResource>(
this IResourceBuilder<DevTunnelResource> tunnelBuilder,
IResourceBuilder<TResource> resourceBuilder,
bool allowAnonymous)
where TResource : IResourceWithEndpoints
{
ArgumentNullException.ThrowIfNull(tunnelBuilder);
ArgumentNullException.ThrowIfNull(resourceBuilder);
return tunnelBuilder.WithReference(resourceBuilder, new DevTunnelPortOptions { AllowAnonymous = allowAnonymous });
}
/// <summary>
/// Adds ports on the dev tunnel for all endpoints found on the referenced resource.
/// </summary>
/// <remarks>
/// To expose only specific endpoints on the referenced resource, use <see cref="WithReference(IResourceBuilder{DevTunnelResource}, EndpointReference, DevTunnelPortOptions?)"/>.
/// </remarks>
/// <param name="tunnelBuilder">The resource builder.</param>
/// <param name="resourceBuilder">The resource builder for the referenced resource.</param>
/// <param name="portOptions">Options for the dev tunnel ports.</param>
/// <returns>The resource builder.</returns>
public static IResourceBuilder<DevTunnelResource> WithReference<TResource>(
this IResourceBuilder<DevTunnelResource> tunnelBuilder,
IResourceBuilder<TResource> resourceBuilder,
DevTunnelPortOptions? portOptions = null)
where TResource : IResourceWithEndpoints
{
ArgumentNullException.ThrowIfNull(tunnelBuilder);
ArgumentNullException.ThrowIfNull(resourceBuilder);
foreach (var endpoint in resourceBuilder.Resource.GetEndpoints())
{
AddDevTunnelPort(tunnelBuilder, endpoint, portOptions);
}
return tunnelBuilder;
}
// NOTE: This is a separate overload to ensure it's bound over the generic service discovery extension method
/// <summary>
/// Exposes the specified endpoint via the dev tunnel.
/// </summary>
/// <param name="tunnelBuilder">The resource builder.</param>
/// <param name="targetEndpoint">The endpoint to expose via the dev tunnel.</param>
/// <returns>The resource builder.</returns>
public static IResourceBuilder<DevTunnelResource> WithReference(
this IResourceBuilder<DevTunnelResource> tunnelBuilder,
EndpointReference targetEndpoint)
=> tunnelBuilder.WithReference(targetEndpoint, portOptions: null);
/// <summary>
/// Exposes the specified endpoint via the dev tunnel and sets whether anonymous access is allowed.
/// </summary>
/// <param name="tunnelBuilder">The resource builder.</param>
/// <param name="targetEndpoint">The endpoint to expose via the dev tunnel.</param>
/// <param name="allowAnonymous">Whether anonymous access is allowed.</param>
/// <returns>The resource builder.</returns>
public static IResourceBuilder<DevTunnelResource> WithReference(
this IResourceBuilder<DevTunnelResource> tunnelBuilder,
EndpointReference targetEndpoint,
bool allowAnonymous)
=> tunnelBuilder.WithReference(targetEndpoint, new DevTunnelPortOptions { AllowAnonymous = allowAnonymous });
/// <summary>
/// Exposes the specified endpoint via the dev tunnel.
/// </summary>
/// <param name="tunnelBuilder">The resource builder.</param>
/// <param name="targetEndpoint">The endpoint to expose via the dev tunnel.</param>
/// <param name="portOptions">Options for the dev tunnel port.</param>
/// <returns>The resource builder.</returns>
public static IResourceBuilder<DevTunnelResource> WithReference(
this IResourceBuilder<DevTunnelResource> tunnelBuilder,
EndpointReference targetEndpoint,
DevTunnelPortOptions? portOptions)
{
ArgumentNullException.ThrowIfNull(tunnelBuilder);
ArgumentNullException.ThrowIfNull(targetEndpoint);
AddDevTunnelPort(tunnelBuilder, targetEndpoint, portOptions);
return tunnelBuilder;
}
/// <summary>
/// Allows the tunnel to be publicly accessed without authentication.
/// </summary>
/// <remarks>
/// Sets <see cref="DevTunnelOptions.AllowAnonymous"/> to <c>true</c> on <see cref="DevTunnelResource.Options"/> .
/// </remarks>
/// <param name="tunnelBuilder">The resource builder.</param>
/// <returns>The resource builder.</returns>
public static IResourceBuilder<DevTunnelResource> WithAnonymousAccess(this IResourceBuilder<DevTunnelResource> tunnelBuilder)
{
tunnelBuilder.Resource.Options.AllowAnonymous = true;
return tunnelBuilder;
}
/// <summary>
/// Injects service discovery information as environment variables from the dev tunnel resource into the destination resource, using the tunneled resource's name as the service name.
/// Each endpoint defined on the target resource will be injected using the format "services__{sourceResourceName}__{endpointName}__{endpointIndex}={uriString}".
/// </summary>
/// <remarks>
/// Referencing a dev tunnel will delay the start of the resource until the referenced dev tunnel's endpoint is allocated.
/// </remarks>
/// <param name="builder">The builder.</param>
/// <param name="targetResource">The resource to inject service discovery information for.</param>
/// <param name="tunnelResource">The dev tunnel resource to resolve the tunnel address from.</param>
/// <returns>The builder.</returns>
public static IResourceBuilder<TResource> WithReference<TResource>(this IResourceBuilder<TResource> builder,
IResourceBuilder<IResourceWithEndpoints> targetResource, IResourceBuilder<DevTunnelResource> tunnelResource)
where TResource : IResourceWithEnvironment
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(targetResource);
ArgumentNullException.ThrowIfNull(tunnelResource);
builder
.WithReferenceRelationship(tunnelResource)
.WithEnvironment(async context =>
{
// Add environment variables for each tunnel port that references an endpoint on the target resource
foreach (var port in tunnelResource.Resource.Ports.Where(p => p.TargetEndpoint.Resource == targetResource.Resource))
{
await port.TunnelEndpointAllocatedTask.ConfigureAwait(false);
var serviceName = targetResource.Resource.Name;
var endpointName = port.TargetEndpoint.EndpointName;
context.EnvironmentVariables[$"services__{serviceName}__{endpointName}__0"] = port.TunnelEndpoint;
}
});
return builder;
}
private static void AddDevTunnelPort(
IResourceBuilder<DevTunnelResource> tunnelBuilder,
EndpointReference targetEndpoint,
DevTunnelPortOptions? portOptions)
{
var tunnel = tunnelBuilder.Resource;
var targetResource = targetEndpoint.Resource;
if (tunnel.Ports.FirstOrDefault(p => p.TargetEndpoint == targetEndpoint) is { } existingPort)
{
// Port already added to the tunnel for this endpoint
throw new ArgumentException($"Target endpoint '{targetEndpoint.EndpointName}' on resource '{targetEndpoint.Resource.Name}' has already been added to dev tunnel '{tunnel.Name}'.", nameof(targetEndpoint));
}
if (targetEndpoint.Resource.Annotations.OfType<EndpointAnnotation>()
.SingleOrDefault(a => StringComparers.EndpointAnnotationName.Equals(a.Name, targetEndpoint.EndpointName)) is { } targetEndpointAnnotation)
{
// The target endpoint already exists so let's ensure it's target is localhost
if (!string.Equals(targetEndpointAnnotation.TargetHost, "localhost", StringComparison.OrdinalIgnoreCase)
&& !targetEndpointAnnotation.TargetHost.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase))
{
// Target endpoint is not localhost so can't be tunneled
throw new ArgumentException($"Cannot tunnel endpoint '{targetEndpointAnnotation.Name}' with host '{targetEndpointAnnotation.TargetHost}' on resource '{targetResource.Name}' because it is not a localhost endpoint.", nameof(targetEndpoint));
}
}
portOptions ??= new();
if (portOptions.Protocol is { } proto && proto is not "http" and not "https" and not "auto")
{
throw new ArgumentException($"Invalid protocol '{proto}' specified in port options. Supported protocols are 'http', 'https', or 'auto'. Set protocol to null to use the endpoint's scheme.", nameof(portOptions));
}
portOptions.Protocol ??= targetEndpoint.Scheme switch
{
"https" or "http" => targetEndpoint.Scheme,
_ => throw new ArgumentException($"Cannot tunnel endpoint '{targetEndpoint.EndpointName}' on resource '{targetResource.Name}' because it uses the unsupported scheme '{targetEndpoint.Scheme}'. Only 'http' and 'https' endpoints can be tunneled."),
};
portOptions.Description ??= $"{targetResource.Name}/{targetEndpoint.EndpointName}";
var portName = $"{tunnel.Name}-{targetResource.Name}-{targetEndpoint.EndpointName}";
portOptions.Labels ??= [];
portOptions.Labels.Add(targetResource.Name);
portOptions.Labels.Add(targetEndpoint.EndpointName);
if (!TryValidateLabels(portOptions.Labels, out var errorMessage))
{
throw new ArgumentException(errorMessage, nameof(portOptions));
}
var portResource = new DevTunnelPortResource(
portName,
tunnel,
targetEndpoint,
portOptions);
tunnel.Ports.Add(portResource);
// Add the tunnel endpoint annotation
portResource.Annotations.Add(portResource.TunnelEndpointAnnotation);
var portBuilder = tunnelBuilder.ApplicationBuilder.AddResource(portResource)
// visual grouping beneath the tunnel
.WithParentRelationship(tunnelBuilder)
// indicate the target resource relationship
.WithReferenceRelationship(targetResource)
// NOTE:
// The endpoint target full host is set by the dev tunnels service and is not known in advance, but the suffix is always devtunnels.ms
// We might consider updating the central logic that creates endpoint URLs to allow setting a target host like *.devtunnels.ms & if the
// host of the allocated endpoint matches that pattern, *don't* try to add a localhost version of the URL too (because it won't work), e.g.:
// .WithEndpoint(DevTunnelPortResource.TunnelEndpointName, e => { e.TargetHost = "*.devtunnels.ms"; }, createIfNotExists: false)
.WithUrls(static context =>
{
var urls = context.Urls;
// Remove the port and trailing slash from the tunnel URL since the dev tunnels service always uses 443 for HTTPS
if (urls.FirstOrDefault(u => string.Equals(u.Endpoint?.EndpointName, DevTunnelPortResource.TunnelEndpointName, StringComparisons.EndpointAnnotationName)
&& !string.Equals(new UriBuilder(u.Url).Host, "localhost")) is { } tunnelUrl)
{
tunnelUrl.Url = new UriBuilder(tunnelUrl.Url).Uri.ToString().TrimEnd('/');
}
// Remove the localhost version of the tunnel URL that's added by the central endpoint URL logic
// HACK: See the NOTE above about potentially handling this more generically in the central endpoint URL logic
if (urls.FirstOrDefault(u => string.Equals(u.Endpoint?.EndpointName, DevTunnelPortResource.TunnelEndpointName, StringComparisons.EndpointAnnotationName)
&& string.Equals(new UriBuilder(u.Url).Host, "localhost", StringComparison.OrdinalIgnoreCase)) is { } localhostTunnelUrl)
{
urls.Remove(localhostTunnelUrl);
}
// Remove any existing inspect URL
if (urls.FirstOrDefault(u => string.Equals(u.DisplayText, "Inspect", StringComparison.OrdinalIgnoreCase)) is { } inspectUrl)
{
urls.Remove(inspectUrl);
}
// Add the inspect URL if available
var portResource = (DevTunnelPortResource)context.Resource;
if (portResource.LastKnownStatus?.PortUri is { } portUri)
{
// If tunnel host is sdfdff-3456.usw.devtunnels.ms, the inspect host is sdfdff-3456-inspect.usw.devtunnels.ms
var hostPrefixLength = portUri.Host.IndexOf('.');
var hostPrefix = portUri.Host[..hostPrefixLength];
var hostSuffix = portUri.Host[hostPrefixLength..];
urls.Add(new()
{
Url = new UriBuilder(portUri) { Host = $"{hostPrefix}-inspect{hostSuffix}" }.Uri.ToString(),
DisplayText = "Inspect",
DisplayLocation = UrlDisplayLocation.DetailsOnly
});
}
})
.WithIconName("VirtualNetwork")
.WithInitialState(new()
{
ResourceType = "DevTunnelPort",
CreationTimeStamp = DateTime.UtcNow,
State = KnownResourceStates.NotStarted,
Properties =
[
new(CustomResourceKnownProperties.Source, $"{targetResource.Name}/{targetEndpoint.EndpointName}"),
new("TargetResource", targetResource.Name),
new("TargetEndpoint", targetEndpoint.EndpointName),
new("Protocol", portOptions.Protocol),
new("Description", portOptions.Description),
new("Labels", portOptions.Labels is null ? "" : $"{string.Join(", ", portOptions.Labels)}"),
]
});
// When the target endpoint is allocated, validate it and mark the TCS accordingly
var targetResourceBuilder = tunnelBuilder.ApplicationBuilder.CreateResourceBuilder(targetResource);
targetResourceBuilder.OnResourceEndpointsAllocated((resource, e, ct) =>
{
var portLogger = e.Services.GetRequiredService<ResourceLoggerService>().GetLogger(portResource);
if (!portResource.TargetEndpoint.IsAllocated)
{
// Target endpoint is not allocated, ignore
portLogger.LogWarning("Target resource endpoints allocated event was fired but target endpoint was not allocated.");
return Task.CompletedTask;
}
portLogger.LogDebug("Target resource endpoints allocated");
// We do this check now so that we're verifying the allocated endpoint's address
if (!string.Equals(portResource.TargetEndpoint.Host, "localhost", StringComparison.OrdinalIgnoreCase) &&
!portResource.TargetEndpoint.Host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase))
{
// Target endpoint is not localhost so can't be tunneled
portLogger.LogError("Cannot tunnel endpoint '{Endpoint}' with host '{Host}' on resource '{Resource}' because it is not a localhost endpoint.", portResource.TargetEndpoint.EndpointName, portResource.TargetEndpoint.Host, portResource.TargetEndpoint.Resource.Name);
portResource.TargetEndpointAllocatedTcs.SetException(new DistributedApplicationException($"Cannot tunnel endpoint '{portResource.TargetEndpoint.EndpointName}' with host '{portResource.TargetEndpoint.Host}' on resource '{portResource.TargetEndpoint.Resource.Name}' because it is not a localhost endpoint."));
return Task.CompletedTask;
}
// Signal the target endpoint created
portResource.TargetEndpointAllocatedTcs.SetResult();
return Task.CompletedTask;
});
// Lifecycle from the tunnel
tunnelBuilder
.OnResourceReady(async (tunnelResource, e, ct) =>
{
// Update the port now that the tunnel is ready (healthy)
// We need to do this in this handler so that it runs every time the tunnel is started
var tunnelStatus = portResource.DevTunnel.LastKnownStatus;
var tunnelPortStatus = portResource.LastKnownStatus;
// Ensure the expected state for the port still exists after the ready event was raised
if (tunnelStatus?.HostConnections is 0 or null || tunnelPortStatus?.PortUri is null)
{
// Tunnel is not ready
return;
}
var services = e.Services;
var eventing = services.GetRequiredService<IDistributedApplicationEventing>();
var notifications = services.GetRequiredService<ResourceNotificationService>();
// Mark the port as starting
await eventing.PublishAsync<BeforeResourceStartedEvent>(new(portResource, services), EventDispatchBehavior.NonBlockingSequential, ct).ConfigureAwait(false);
await notifications.PublishUpdateAsync(portResource, snapshot => snapshot with
{
State = KnownResourceStates.Starting,
StartTimeStamp = DateTime.UtcNow
}).ConfigureAwait(false);
// Allocate endpoint to the tunnel port
var raiseEndpointsAllocatedEvent = portResource.TunnelEndpointAnnotation.AllocatedEndpoint is null;
portResource.TunnelEndpointAnnotation.AllocatedEndpoint = new(portResource.TunnelEndpointAnnotation, tunnelPortStatus.PortUri.Host, 443 /* Always 443 for public tunnel endpoint */);
// We can only raise the endpoints allocated event once as the central URL logic assumes it's a one-time event per resource.
// AFAIK the PortUri should not change between restarts of the same tunnel (with same tunnel ID) so we don't need to update the URLs for
// the resource every time the tunnel starts, just the first time.
if (raiseEndpointsAllocatedEvent)
{
await eventing.PublishAsync<ResourceEndpointsAllocatedEvent>(new(portResource, services), ct).ConfigureAwait(false);
portResource.TunnelEndpointAllocatedTcs.SetResult();
}
// Mark the port as running
await notifications.PublishUpdateAsync(portResource, snapshot => snapshot with
{
State = KnownResourceStates.Running,
Urls = [.. snapshot.Urls.Select(u => u with { IsInactive = false /* All URLs active */ })]
}).ConfigureAwait(false);
var portLogger = services.GetRequiredService<ResourceLoggerService>().GetLogger(portResource);
portLogger.LogInformation("Forwarding from {PortUrl} to {TargetUrl} ({TargetResourceName}/{TargetEndpointName})", tunnelPortStatus.PortUri.ToString().TrimEnd('/'), portResource.TargetEndpoint.Url, portResource.TargetEndpoint.Resource.Name, portResource.TargetEndpoint.EndpointName);
// Log anonymous access status
try
{
var effectivePolicy = portResource.LastKnownAccessStatus?.LogAnonymousAccessPolicy(portLogger);
if (effectivePolicy is not null)
{
// Set property detailing the anonymous access status
await notifications.PublishUpdateAsync(portResource, snapshot => snapshot with
{
Properties = [
.. snapshot.Properties.Where(p => !string.Equals(p.Name, "Anonymous access", StringComparison.OrdinalIgnoreCase)),
new("Anonymous access", effectivePolicy)
]
}).ConfigureAwait(false);
}
else
{
portLogger.LogDebug("Anonymous access status unavailable for port at this time (tunnel or port access status null)");
}
}
catch (Exception ex)
{
portLogger.LogDebug(ex, "Failed to log anonymous access status for port");
}
})
.OnResourceStopped(async (tunnelResource, e, ct) =>
{
// Tunnel stopped, mark port as stopped too
portResource.LastKnownStatus = null;
var portLogger = e.Services.GetRequiredService<ResourceLoggerService>().GetLogger(portResource);
var notifications = e.Services.GetRequiredService<ResourceNotificationService>();
var eventing = e.Services.GetRequiredService<IDistributedApplicationEventing>();
portLogger.LogInformation("Port forwarding stopped");
CustomResourceSnapshot? stoppedSnapshot = default;
await notifications.PublishUpdateAsync(portResource, snapshot => stoppedSnapshot = snapshot with
{
State = KnownResourceStates.Finished,
StopTimeStamp = DateTime.UtcNow,
Urls = [.. snapshot.Urls.Select(u => u with { IsInactive = true /* All URLs inactive */ })]
}).ConfigureAwait(false);
await eventing.PublishAsync<ResourceStoppedEvent>(new(portResource, e.Services, new(portResource, portResource.Name, stoppedSnapshot!)), ct).ConfigureAwait(false);
});
}
private static string GetUserAgent()
{
var assembly = typeof(DevTunnelResource).Assembly;
var version = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? assembly.GetName().Version?.ToString()
?? "unknown";
return new ProductInfoHeaderValue("Aspire.DevTunnels", version).ToString();
}
private static bool TryValidateLabels(IList<string>? labels, [NotNullWhen(false)] out string? errorMessage)
{
if (labels is null || labels.Count == 0)
{
errorMessage = null;
return true;
}
foreach (var label in labels)
{
// Validate the label format '[\w-=]{1,50}'
if (!LabelRegex().IsMatch(label))
{
errorMessage = $"""
The label '{label}' is invalid. A valid label must:
- consist of letters, numbers, underscores, hyphens, or equals signs
- be 1-50 characters long
""";
return false;
}
}
errorMessage = null;
return true;
}
[GeneratedRegex(@"^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$")]
private static partial Regex TunnelIdRegex();
[GeneratedRegex(@"^[\w\-=_]{1,50}$")]
private static partial Regex LabelRegex();
private sealed class DevTunnelResourceStartedEvent(DevTunnelResource tunnel) : IDistributedApplicationResourceEvent
{
public IResource Resource { get; } = tunnel;
}
}
|