// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace Aspire.Hosting.DevTunnels;
internal sealed class DevTunnelCliClient : IDevTunnelClient
{
private readonly int _maxCliAttempts;
private readonly TimeSpan _cliRetryOnErrorDelay = TimeSpan.FromSeconds(2);
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } };
private readonly DevTunnelCli _cli;
public DevTunnelCliClient(IConfiguration configuration)
: this(configuration, new DevTunnelCli(DevTunnelCli.GetCliPath(configuration)))
{
}
internal DevTunnelCliClient(IConfiguration configuration, DevTunnelCli cli)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(cli);
_maxCliAttempts = configuration.GetValue<int?>("ASPIRE_DEVTUNNEL_CLI_MAX_ATTEMPTS") ?? 3;
_cli = cli;
}
public async Task<Version> GetVersionAsync(ILogger? logger = default, CancellationToken cancellationToken = default)
{
using var outputWriter = new StringWriter();
using var errorWriter = new StringWriter();
var exitCode = await _cli.GetVersionAsync(outputWriter, errorWriter, logger, cancellationToken).ConfigureAwait(false);
var output = outputWriter.ToString().Trim();
if (exitCode == 0)
{
// Find the line with the version number. It will look like "Tunnel CLI version: 1.0.1435+d49a94cc24"
var prefix = "Tunnel CLI version:";
var versionLine = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault(l => l.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
var versionString = versionLine?.Length > prefix.Length
? versionLine[prefix.Length..].Trim()
: output;
// Trim the commit SHA suffix if present
if (versionString.IndexOf('+') is >= 0 and var plusIndex)
{
versionString = versionString[..plusIndex];
}
if (Version.TryParse(versionString, out var version))
{
return version;
}
}
var error = errorWriter.ToString().Trim();
throw new DistributedApplicationException($"Failed to get devtunnel CLI version. Output: '{output}'. Error: '{error}'");
}
public Task<DevTunnelStatus> CreateTunnelAsync(string tunnelId, DevTunnelOptions options, ILogger? logger = default, CancellationToken cancellationToken = default)
=> RetryProvisioningAsync(() => CreateTunnelCoreAsync(tunnelId, options, logger, cancellationToken), logger, cancellationToken);
private async Task<DevTunnelStatus> CreateTunnelCoreAsync(string tunnelId, DevTunnelOptions options, ILogger? logger, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tunnelId);
var resolvedId = options.Region is not null ? $"{tunnelId}.{options.RegionCode}" : tunnelId;
var existing = await FindExistingAsync<DevTunnelStatus>(
(stdout, stderr, log, ct) => _cli.ShowTunnelAsync(resolvedId, stdout, stderr, log, ct),
"tunnel", $"dev tunnel '{resolvedId}'", t => ValidateTunnelIdentity(resolvedId, t.TunnelId), logger, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
var access = existing.AccessControl;
if (access is null)
{
access = (await GetAccessAsync(existing.TunnelId, portNumber: null, logger, cancellationToken).ConfigureAwait(false)).AccessControlEntries;
}
var descriptionMatches = string.IsNullOrEmpty(options.Description) || string.Equals(existing.Description, options.Description, StringComparison.Ordinal);
var labelsMatch = options.Labels is null || options.Labels.All(l => (existing.Labels ?? []).Contains(l, StringComparer.Ordinal));
var expirationMatches = options.ExpirationHours is null || GetExpirationHours(existing.TunnelExpiration) == options.ExpirationHours;
if (!descriptionMatches || !labelsMatch || !expirationMatches)
{
var (updated, updateExitCode, updateError) = await CallCliAsJsonAsync<DevTunnelStatus>(
(stdout, stderr, log, ct) => _cli.UpdateTunnelAsync(existing.TunnelId, options, stdout, stderr, log, ct),
"tunnel", logger, cancellationToken).ConfigureAwait(false);
if (updated is not null)
{
ValidateTunnelIdentity(existing.TunnelId, updated.TunnelId);
}
existing = updated ?? throw new RetryableProvisioningException($"Failed to update dev tunnel '{existing.TunnelId}'. Exit code {updateExitCode}: {updateError}");
}
await EnsureAccessAsync(existing.TunnelId, portNumber: null, options.AllowAnonymous ? true : null, access, logger, cancellationToken).ConfigureAwait(false);
logger?.LogDebug("Reusing dev tunnel '{TunnelId}'.", existing.TunnelId);
return existing;
}
var attempts = 0;
var exitCode = 0;
string? error = null;
string resolvedTunnelId = options.Region is not null ? $"{tunnelId}.{options.RegionCode}" : tunnelId;
while (attempts < _maxCliAttempts)
{
logger?.LogTrace("Creating dev tunnel '{TunnelId}' with options: {Options}", tunnelId, options.ToLoggerString());
if (attempts++ > 1)
{
logger?.LogTrace("Attempt {Attempt} of {MaxAttempts} to create dev tunnel '{TunnelId}'", attempts, _maxCliAttempts, tunnelId);
}
(var tunnel, exitCode, error) = await CallCliAsJsonAsync<DevTunnelStatus>((stdout, stderr, log, ct) => _cli.CreateTunnelAsync(tunnelId, options, stdout, stderr, log, ct),
"tunnel",
logger, cancellationToken).ConfigureAwait(false);
if (exitCode == 0 && tunnel is not null)
{
ValidateTunnelIdentity(resolvedId, tunnel.TunnelId);
logger?.LogTrace("Dev tunnel '{TunnelId}' created successfully.", tunnelId);
return tunnel;
}
if (exitCode == DevTunnelCli.ResourceConflictsWithExistingExitCode)
{
// Update the tunnel as it already exists
logger?.LogTrace("Dev tunnel '{TunnelId}' already exists, will update it instead.", tunnelId);
var createError = error;
(tunnel, exitCode, error) = await CallCliAsJsonAsync<DevTunnelStatus>(
(stdout, stderr, log, ct) => _cli.UpdateTunnelAsync(resolvedTunnelId, options, stdout, stderr, log, ct),
"tunnel", logger, cancellationToken).ConfigureAwait(false);
if (exitCode == DevTunnelCli.ResourceNotFoundExitCode)
{
// A service ghost can produce:
// create <id>: exit 1, "Conflict with existing entity"
// update <id>: exit 2, "Tunnel not found"
// Retrying the same candidate cannot resolve that contradictory service state.
throw new DistributedApplicationException(
$"Dev tunnel '{resolvedTunnelId}' could not be created because the dev tunnels service reported that it already exists, " +
"but then reported it was not found when Aspire tried to update it. This tunnel ID is in an inconsistent service state " +
$"and retrying it cannot recover. Specify a different tunnel ID with {nameof(DevTunnelsResourceBuilderExtensions.AddDevTunnel)}" +
"(name, tunnelId: \"new-id\") and restart " +
$"the AppHost. Create error: '{createError}'. Update error: '{error}'.");
}
if (exitCode == 0 && tunnel is not null)
{
ValidateTunnelIdentity(resolvedTunnelId, tunnel.TunnelId);
resolvedTunnelId = tunnel.TunnelId;
logger?.LogTrace("Dev tunnel '{TunnelId}' updated successfully.", resolvedTunnelId);
// Ensure tunnel access controls are set as specified in options by resetting existing policies first.
// Port-specific policies are reconciled separately.
logger?.LogTrace("Clearing access policies for dev tunnel '{TunnelId}'.", resolvedTunnelId);
(var accessStatus, exitCode, error) = await CallCliAsJsonAsync<DevTunnelAccessStatus>(
(stdout, stderr, log, ct) => _cli.ResetAccessAsync(resolvedTunnelId, portNumber: null, stdout, stderr, log, ct),
logger, cancellationToken).ConfigureAwait(false);
if (exitCode == 0 && accessStatus is { AccessControlEntries: [] })
{
logger?.LogTrace("Dev tunnel '{TunnelId}' access policies cleared successfully.", resolvedTunnelId);
if (options.AllowAnonymous)
{
// Set anonymous access as specified
logger?.LogTrace("Allowing anonymous access for dev tunnel '{TunnelId}'.", resolvedTunnelId);
(accessStatus, exitCode, error) = await CallCliAsJsonAsync<DevTunnelAccessStatus>(
(stdout, stderr, log, ct) => _cli.CreateAccessAsync(resolvedTunnelId, portNumber: null, anonymous: true, deny: false, stdout, stderr, log, ct),
logger, cancellationToken).ConfigureAwait(false);
if (exitCode == 0 && accessStatus is not null)
{
logger?.LogTrace("Dev tunnel '{TunnelId}' anonymous access set successfully.", resolvedTunnelId);
}
}
if (exitCode == 0 && accessStatus is not null)
{
return tunnel;
}
}
}
}
logger?.LogError("Failed to create dev tunnel '{TunnelId}' (attempt {Attempt} of {MaxAttempts}). Exit code {ExitCode}: {Error}", tunnelId, attempts, _maxCliAttempts, exitCode, error);
if (attempts < _maxCliAttempts)
{
logger?.LogTrace("Waiting {WaitSeconds} seconds before retrying to create dev tunnel '{TunnelId}'", _cliRetryOnErrorDelay.TotalSeconds, tunnelId);
await Task.Delay(_cliRetryOnErrorDelay, cancellationToken).ConfigureAwait(false);
}
}
throw new DistributedApplicationException($"Failed to create dev tunnel '{tunnelId}' after {attempts} attempts. Exit code {exitCode}: {error}");
}
public async Task<DevTunnelStatus> GetTunnelAsync(string tunnelId, ILogger? logger = default, CancellationToken cancellationToken = default)
{
logger?.LogTrace("Getting details for dev tunnel '{TunnelId}'.", tunnelId);
var (tunnel, exitCode, error) = await CallCliAsJsonAsync<DevTunnelStatus>(
(stdout, stderr, log, ct) => _cli.ShowTunnelAsync(tunnelId, stdout, stderr, log, ct),
"tunnel",
logger, cancellationToken).ConfigureAwait(false);
if (exitCode == DevTunnelCli.ResourceNotFoundExitCode)
{
throw new DevTunnelNotFoundException(tunnelId, error);
}
if (tunnel is not null)
{
ValidateTunnelIdentity(tunnelId, tunnel.TunnelId);
}
return tunnel ?? throw new DistributedApplicationException($"Failed to get dev tunnel '{tunnelId}'. Exit code {exitCode}: {error}");
}
public async Task<DevTunnelPortList> GetPortListAsync(string tunnelId, ILogger? logger = default, CancellationToken cancellationToken = default)
{
logger?.LogTrace("Getting port list for dev tunnel '{TunnelId}'.", tunnelId);
var (ports, exitCode, error) = await CallCliAsJsonAsync<DevTunnelPortList>(
(stdout, stderr, log, ct) => _cli.ListPortsAsync(tunnelId, stdout, stderr, log, ct),
logger, cancellationToken).ConfigureAwait(false);
return ports ?? throw new DistributedApplicationException($"Failed to get port list for dev tunnel '{tunnelId}'. Exit code {exitCode}: {error}");
}
public Task<DevTunnelPortStatus> CreatePortAsync(string tunnelId, int portNumber, DevTunnelPortOptions portOptions, ILogger? logger = default, CancellationToken cancellationToken = default)
=> RetryProvisioningAsync(() => CreatePortCoreAsync(tunnelId, portNumber, portOptions, logger, cancellationToken), logger, cancellationToken);
private async Task<DevTunnelPortStatus> CreatePortCoreAsync(string tunnelId, int portNumber, DevTunnelPortOptions portOptions, ILogger? logger, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tunnelId);
var existing = await FindExistingAsync<DevTunnelPortStatus>(
(stdout, stderr, log, ct) => _cli.ShowPortAsync(tunnelId, portNumber, stdout, stderr, log, ct),
"port", $"port '{portNumber}' on dev tunnel '{tunnelId}'", p => ValidatePortIdentity(tunnelId, portNumber, p), logger, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
tunnelId = existing.TunnelId;
var protocolMatches = string.Equals(existing.Protocol, portOptions.Protocol ?? "auto", StringComparison.OrdinalIgnoreCase);
var descriptionMatches = string.IsNullOrEmpty(portOptions.Description) || string.Equals(existing.Description, portOptions.Description, StringComparison.Ordinal);
var labelsMatch = existing.Labels.ToHashSet(StringComparer.Ordinal).SetEquals(portOptions.Labels ?? []);
if (protocolMatches && descriptionMatches && labelsMatch)
{
var access = existing.AccessControl;
if (access is null)
{
access = (await GetAccessAsync(tunnelId, portNumber, logger, cancellationToken).ConfigureAwait(false)).AccessControlEntries;
}
await EnsureAccessAsync(tunnelId, portNumber, portOptions.AllowAnonymous, access, logger, cancellationToken).ConfigureAwait(false);
logger?.LogDebug("Reusing dev tunnel port '{PortNumber}' on '{TunnelId}'.", portNumber, tunnelId);
return existing;
}
// Protocol cannot be changed by `devtunnel port update`. Recreate only ports whose
// modeled configuration changed, preserving unchanged port URLs and access policies.
await DeletePortAsync(tunnelId, portNumber, logger, cancellationToken).ConfigureAwait(false);
}
var attempts = 0;
var exitCode = 0;
string? error = null;
DevTunnelPortStatus? port = null;
while (attempts < _maxCliAttempts)
{
logger?.LogTrace("Creating port '{PortNumber}' on dev tunnel '{TunnelId}' with options: {Options}", portNumber, tunnelId, portOptions.ToLoggerString());
if (attempts++ > 1)
{
logger?.LogTrace("Attempt {Attempt} of {MaxAttempts} to create port '{PortNumber}' on dev tunnel '{TunnelId}'", attempts, _maxCliAttempts, portNumber, tunnelId);
}
(port, exitCode, error) = await CallCliAsJsonAsync<DevTunnelPortStatus>(
(outWriter, errWriter, log, ct) => _cli.CreatePortAsync(tunnelId, portNumber, portOptions, outWriter, errWriter, log, ct),
"port", logger, cancellationToken).ConfigureAwait(false);
if (exitCode == 0 && port is not null)
{
ValidatePortIdentity(tunnelId, portNumber, port);
// Creation alone does not establish the requested policy. A missing or failed
// access result must retry reconciliation of this port, not recreate it or report success.
await EnsureAccessAsync(port.TunnelId, portNumber, portOptions.AllowAnonymous, port.AccessControl ?? [], logger, cancellationToken).ConfigureAwait(false);
logger?.LogTrace("Port '{PortNumber}' on dev tunnel '{TunnelId}' created successfully.", portNumber, port.TunnelId);
return port;
}
else if (exitCode == DevTunnelCli.ResourceConflictsWithExistingExitCode)
{
logger?.LogTrace("Port '{PortNumber}' already exists on dev tunnel '{TunnelId}', deleting and trying again.", portNumber, tunnelId);
(var deleteResult, exitCode, error) = await CallCliAsJsonAsync<DevTunnelDeleteResult>(
(stdout, stderr, log, ct) => _cli.DeletePortAsync(tunnelId, portNumber, stdout, stderr, log, ct),
logger, cancellationToken).ConfigureAwait(false);
if (exitCode == 0)
{
logger?.LogTrace("Deleted existing port '{PortNumber}' on dev tunnel '{TunnelId}'.", portNumber, tunnelId);
continue; // Retry create
}
}
logger?.LogError("Failed to create port '{PortNumber}' for dev tunnel '{TunnelId}' (attempt {Attempt} of {MaxAttempts}). Exit code {ExitCode}: {Error}", portNumber, tunnelId, attempts, _maxCliAttempts, exitCode, error);
if (attempts < _maxCliAttempts)
{
logger?.LogTrace("Waiting {WaitSeconds} seconds before retrying to create port '{PortNumber}' on dev tunnel '{TunnelId}'", _cliRetryOnErrorDelay.TotalSeconds, portNumber, tunnelId);
await Task.Delay(_cliRetryOnErrorDelay, cancellationToken).ConfigureAwait(false);
}
}
throw new DistributedApplicationException($"Failed to create port '{portNumber}' for tunnel '{tunnelId}' after {attempts} attempts. Exit code {exitCode}: {error}");
}
private async Task<T> RetryProvisioningAsync<T>(Func<Task<T>> operation, ILogger? logger, CancellationToken cancellationToken)
{
for (var attempt = 1; ; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
return await operation().ConfigureAwait(false);
}
catch (RetryableProvisioningException ex) when (attempt < _maxCliAttempts)
{
// A failed mutation may still have reached the service. Inspect again on retry
// instead of blindly repeating resets or adding duplicate access policies.
logger?.LogWarning(ex, "Dev tunnel provisioning failed (attempt {Attempt} of {MaxAttempts}); reconciling again.", attempt, _maxCliAttempts);
await Task.Delay(_cliRetryOnErrorDelay, cancellationToken).ConfigureAwait(false);
}
}
}
private async Task<T?> FindExistingAsync<T>(
Func<TextWriter, TextWriter, ILogger?, CancellationToken, Task<int>> query,
string propertyName,
string description,
Action<T> validate,
ILogger? logger,
CancellationToken cancellationToken) where T : class
{
int exitCode = 0;
string? error = null;
for (var attempt = 1; attempt <= _maxCliAttempts; attempt++)
{
var response = await CallCliAsJsonAsync<T>(query, propertyName, logger, cancellationToken).ConfigureAwait(false);
(var result, exitCode, error) = response;
if (result is not null)
{
validate(result);
return result;
}
if (exitCode == DevTunnelCli.ResourceNotFoundExitCode)
{
return result;
}
if (attempt < _maxCliAttempts)
{
logger?.LogWarning("Failed to inspect {Resource} (attempt {Attempt} of {MaxAttempts}); retrying.", description, attempt, _maxCliAttempts);
await Task.Delay(_cliRetryOnErrorDelay, cancellationToken).ConfigureAwait(false);
}
}
throw new DistributedApplicationException($"Failed to inspect {description}. Exit code {exitCode}: {error}");
}
private static void ValidateTunnelIdentity(string requestedId, string returnedId)
{
if (string.Equals(requestedId, returnedId, StringComparison.OrdinalIgnoreCase))
{
return;
}
// A bare ID such as "mytunnel" may resolve to "mytunnel.usw2". A qualified request
// must remain in that exact cluster; a shared prefix or another region is not a match.
if (!requestedId.Contains('.')
&& returnedId.StartsWith(requestedId + ".", StringComparison.OrdinalIgnoreCase)
&& returnedId.Length > requestedId.Length + 1
&& returnedId.AsSpan(requestedId.Length + 1).IndexOf('.') < 0
&& returnedId.Skip(requestedId.Length + 1).All(c => char.IsAsciiLetterOrDigit(c) || c == '-'))
{
return;
}
throw new DistributedApplicationException($"The devtunnel CLI returned tunnel '{returnedId}' when '{requestedId}' was requested.");
}
private static void ValidatePortIdentity(string tunnelId, int portNumber, DevTunnelPortStatus port)
{
ValidateTunnelIdentity(tunnelId, port.TunnelId);
if (port.PortNumber != portNumber)
{
throw new DistributedApplicationException($"The devtunnel CLI returned port '{port.PortNumber}' when port '{portNumber}' on tunnel '{tunnelId}' was requested.");
}
}
private async Task EnsureAccessAsync(
string tunnelId,
int? portNumber,
bool? allowAnonymous,
IReadOnlyList<DevTunnelAccessStatus.AccessControlEntry> access,
ILogger? logger,
CancellationToken cancellationToken)
{
// Inherited entries belong to the parent tunnel. A port with no explicit policy must
// inherit them, whereas AllowAnonymous=false requires an explicit anonymous deny.
var explicitEntries = access.Where(e => !e.IsInherited).ToArray();
if (allowAnonymous is false)
{
// Never reset a restrictive policy while reconciling other entries. Removing a
// working deny could expose a port through an anonymously accessible parent until
// replacement succeeds; cancellation cannot restore that access restriction.
// An anonymous connect deny is sufficient even with additional entries/scopes:
// deny rules take precedence over allows. Preserve those entries and add a deny
// if needed, rather than weakening the policy to obtain an exact ACL shape.
// https://github.com/microsoft/dev-tunnels/blob/main/cs/src/Contracts/TunnelAccessControl.cs
if (explicitEntries.Any(e => e.IsPermanentAnonymousConnectRule(deny: true)))
{
return;
}
await CreateAnonymousAccessAsync(tunnelId, portNumber, allow: false, logger, cancellationToken).ConfigureAwait(false);
return;
}
var matches = allowAnonymous is null
? explicitEntries.Length == 0
: explicitEntries is [var entry]
&& entry.IsPermanentAnonymousConnectRule(deny: !allowAnonymous.Value)
&& entry.Scopes.Count == 1;
if (matches)
{
return;
}
if (explicitEntries.Length > 0)
{
var (reset, exitCode, error) = await CallCliAsJsonAsync<DevTunnelAccessStatus>(
(stdout, stderr, log, ct) => _cli.ResetAccessAsync(tunnelId, portNumber, stdout, stderr, log, ct),
logger, cancellationToken).ConfigureAwait(false);
if (reset is null)
{
throw new RetryableProvisioningException($"Failed to reset access for dev tunnel '{tunnelId}', port '{portNumber}'. Exit code {exitCode}: {error}");
}
}
if (allowAnonymous is { } allow)
{
await CreateAnonymousAccessAsync(tunnelId, portNumber, allow, logger, cancellationToken).ConfigureAwait(false);
}
}
private async Task CreateAnonymousAccessAsync(string tunnelId, int? portNumber, bool allow, ILogger? logger, CancellationToken cancellationToken)
{
var (created, exitCode, error) = await CallCliAsJsonAsync<DevTunnelAccessStatus>(
(stdout, stderr, log, ct) => _cli.CreateAccessAsync(tunnelId, portNumber, anonymous: true, deny: !allow, stdout, stderr, log, ct),
logger, cancellationToken).ConfigureAwait(false);
if (created is null)
{
throw new RetryableProvisioningException($"Failed to set access for dev tunnel '{tunnelId}', port '{portNumber}'. Exit code {exitCode}: {error}");
}
}
private static decimal? GetExpirationHours(string? expiration)
{
// The CLI's JSON uses display strings, such as "1 hours", "30 days", or a combination
// of days and hours. An unfamiliar representation is not evidence of a match: reapply
// the requested expiration instead of silently keeping a potentially different value.
if (expiration is null)
{
return null;
}
var parts = expiration.Split([' ', ',', '\t'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0 || parts.Length % 2 != 0)
{
return null;
}
var hours = 0m;
for (var i = 0; i < parts.Length; i += 2)
{
if (!decimal.TryParse(parts[i], NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var value) || value is < 0 or > 720)
{
return null;
}
var multiplier = parts[i + 1].ToLowerInvariant() switch
{
"hour" or "hours" => 1,
"day" or "days" => 24,
_ => 0
};
if (multiplier == 0)
{
return null;
}
hours += value * multiplier;
}
return hours;
}
public async Task<DevTunnelPortDeleteResult> DeletePortAsync(string tunnelId, int portNumber, ILogger? logger = default, CancellationToken cancellationToken = default)
{
logger?.LogTrace("Deleting port '{PortNumber}' on dev tunnel '{TunnelId}'.", portNumber, tunnelId);
var (result, exitCode, error) = await CallCliAsJsonAsync<DevTunnelPortDeleteResult>(
(stdout, stderr, log, ct) => _cli.DeletePortAsync(tunnelId, portNumber, stdout, stderr, log, ct),
logger, cancellationToken).ConfigureAwait(false);
return result ?? throw new RetryableProvisioningException($"Failed to delete port '{portNumber}' on dev tunnel '{tunnelId}'. Exit code {exitCode}: {error}");
}
public async Task<DevTunnelAccessStatus> GetAccessAsync(string tunnelId, int? portNumber = null, ILogger? logger = default, CancellationToken cancellationToken = default)
{
logger?.LogTrace("Getting access details for {PortInfo}dev tunnel '{TunnelId}'.", portNumber.HasValue ? $"port '{portNumber}' on " : string.Empty, tunnelId);
var (access, exitCode, error) = await CallCliAsJsonAsync<DevTunnelAccessStatus>(
(stdout, stderr, log, ct) => _cli.ListAccessAsync(tunnelId, portNumber, stdout, stderr, log, ct),
logger, cancellationToken).ConfigureAwait(false);
return access ?? throw new RetryableProvisioningException($"Failed to get access details for '{tunnelId}'{(portNumber.HasValue ? $" port {portNumber}" : "")}. Exit code {exitCode}: {error}");
}
public async Task<UserLoginStatus> GetUserLoginStatusAsync(ILogger? logger = default, CancellationToken cancellationToken = default)
{
logger?.LogTrace("Getting dev tunnel user login status.");
var (login, exitCode, error) = await CallCliAsJsonAsync<UserLoginStatus>(
_cli.UserStatusAsync,
logger, cancellationToken).ConfigureAwait(false);
return login ?? throw new DistributedApplicationException($"Failed to get user login status. Exit code {exitCode}: {error}");
}
public async Task<UserLoginStatus> UserLoginAsync(LoginProvider provider, ILogger? logger = default, CancellationToken cancellationToken = default)
{
logger?.LogTrace("Logging in to dev tunnel service using {LoginProvider}.", provider);
var exitCode = provider switch
{
LoginProvider.Microsoft => await _cli.UserLoginMicrosoftAsync(logger, cancellationToken).ConfigureAwait(false),
LoginProvider.GitHub => await _cli.UserLoginGitHubAsync(logger, cancellationToken).ConfigureAwait(false),
_ => throw new ArgumentException("Unsupported provider. Supported providers are 'microsoft' and 'github'.", nameof(provider)),
};
if (exitCode == 0)
{
// Login succeeded, get the login status
return await GetUserLoginStatusAsync(logger, cancellationToken).ConfigureAwait(false);
}
throw new DistributedApplicationException($"Failed to perform user login. Process finished with exit code: {exitCode}");
}
private async Task<(T? Result, int ExitCode, string? Error)> CallCliAsJsonAsync<T>(Func<TextWriter, TextWriter, ILogger?, CancellationToken, Task<int>> cliCall, ILogger? logger = default, CancellationToken cancellationToken = default)
{
return await CallCliAsJsonAsync<T>(cliCall, propertyName: null, logger, cancellationToken).ConfigureAwait(false);
}
private async Task<(T? Result, int ExitCode, string? Error)> CallCliAsJsonAsync<T>(Func<TextWriter, TextWriter, ILogger?, CancellationToken, Task<int>> cliCall, string? propertyName, ILogger? logger = default, CancellationToken cancellationToken = default)
{
// PERF: Could pool these writers
using var stdout = new StringWriter();
using var stderr = new StringWriter();
var exitCode = await cliCall(stdout, stderr, logger, cancellationToken).ConfigureAwait(false);
if (exitCode != 0)
{
var error = stderr.ToString().Trim();
logger?.LogError("CLI call returned non-zero exit code '{ExitCode}'. stderr output:\n{Error}", exitCode, error);
return (default, exitCode, error);
}
var output = stdout.ToString().Trim();
logger?.LogTrace("CLI call output:\n{Output}", output);
if (cancellationToken.IsCancellationRequested)
{
logger?.LogDebug("Operation was cancelled.");
cancellationToken.ThrowIfCancellationRequested();
}
if (string.IsNullOrEmpty(output))
{
logger?.LogError("CLI call returned empty output with exit code '{ExitCode}'.", exitCode);
return (default, exitCode, "CLI call returned empty output.");
}
try
{
if (!string.IsNullOrEmpty(propertyName))
{
// For example, update returns {"tunnel":{"tunnelId":"name.usw2",...}} and
// port create/show return {"port":{...}}. Also accept a flat response, but
// validate its identity below so an envelope can never deserialize to a null ID.
using var document = JsonDocument.Parse(output);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
throw new JsonException("The devtunnel response must be a JSON object.");
}
if (document.RootElement.TryGetProperty(propertyName, out var value))
{
output = value.GetRawText();
logger?.LogTrace("Extracted JSON property '{PropertyName}':\n{Output}", propertyName, output);
}
}
var result = JsonSerializer.Deserialize<T>(output, _jsonOptions);
if (result is DevTunnelStatus tunnel && string.IsNullOrWhiteSpace(tunnel.TunnelId)
|| result is DevTunnelPortStatus port && (string.IsNullOrWhiteSpace(port.TunnelId) || port.PortNumber is < 1 or > 65535 || string.IsNullOrWhiteSpace(port.Protocol))
|| result is DevTunnelAccessStatus { AccessControlEntries: null })
{
throw new JsonException("The devtunnel response is missing a valid tunnel or port identity.");
}
logger?.LogTrace("JSON output successfully deserialized to '{TypeName}' instance", typeof(T).Name);
return (result, 0, default);
}
catch (JsonException ex)
{
logger?.LogError(ex, "Failed to parse JSON output into type '{TypeName}':\n{Output}", typeof(T).Name, output);
throw new DistributedApplicationException($"Failed to parse JSON output into type '{typeof(T).Name}':\n{output}", ex);
}
}
private record DevTunnelDeleteResult(string DeletedTunnel);
private sealed class RetryableProvisioningException(string message) : DistributedApplicationException(message);
}