File: DevTunnelCli.cs
Web Access
Project: src\src\Aspire.Hosting.DevTunnels\Aspire.Hosting.DevTunnels.csproj (Aspire.Hosting.DevTunnels)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
 
namespace Aspire.Hosting.DevTunnels;
 
internal sealed class DevTunnelCli
{
    public const int ResourceConflictsWithExistingExitCode = 1;
    public const int ResourceNotFoundExitCode = 2;
 
    public static readonly Version MinimumSupportedVersion = new(1, 0, 1435);
 
    private readonly string _cliPath;
 
    public static string GetCliPath(IConfiguration configuration) => configuration["ASPIRE_DEVTUNNEL_CLI_PATH"] ?? "devtunnel";
 
    public DevTunnelCli(string filePath)
    {
        if (string.IsNullOrWhiteSpace(filePath))
        {
            throw new ArgumentException("CLI path must be provided", nameof(filePath));
        }
 
        _cliPath = filePath;
    }
 
    public Task<int> GetVersionAsync(TextWriter? outputWriter = null, TextWriter? errorWriter = null, ILogger? logger = default, CancellationToken cancellationToken = default)
        => RunAsync(["--version", "--nologo"], outputWriter, errorWriter, logger, cancellationToken);
 
    public Task<int> UserLoginMicrosoftAsync(ILogger? logger = default, CancellationToken cancellationToken = default)
        => RunAsync(["user", "login", "--entra", "--json", "--nologo"], null, null, useShellExecute: true, logger, cancellationToken);
 
    public Task<int> UserLoginGitHubAsync(ILogger? logger = default, CancellationToken cancellationToken = default)
        => RunAsync(["user", "login", "--github", "--json", "--nologo"], null, null, useShellExecute: true, logger, cancellationToken);
 
    public Task<int> UserLogoutAsync(TextWriter? outputWriter = null, TextWriter? errorWriter = null, ILogger? logger = default, CancellationToken cancellationToken = default)
        => RunAsync(new ArgsBuilder(["user", "logout", "--json", "--nologo"])
        , outputWriter, errorWriter, logger, cancellationToken);
 
    public Task<int> UserStatusAsync(TextWriter? outputWriter = null, TextWriter? errorWriter = null, ILogger? logger = default, CancellationToken cancellationToken = default)
        => RunAsync(["user", "show", "--json", "--nologo"], outputWriter, errorWriter, logger, cancellationToken);
 
    public Task<int> CreateTunnelAsync(
        string? tunnelId = null,
        DevTunnelOptions? options = null,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        ILogger? logger = default,
        CancellationToken cancellationToken = default)
    {
        options ??= new DevTunnelOptions();
        return RunAsync(new ArgsBuilder(["create"])
            .AddIfNotNull(tunnelId)
            .AddIfNotNull("--description", options.Description)
            .AddIfTrue("--allow-anonymous", options.AllowAnonymous)
            .AddValues("--labels", options.Labels)
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, logger, cancellationToken);
    }
 
    public Task<int> UpdateTunnelAsync(
        string tunnelId,
        DevTunnelOptions? options = null,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        ILogger? logger = default,
        CancellationToken cancellationToken = default)
    {
        options ??= new DevTunnelOptions();
        return RunAsync(new ArgsBuilder(["update", tunnelId])
            .AddIfNotNull("--description", options.Description)
            .AddValues("--add-labels", options.Labels)
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, logger, cancellationToken);
    }
 
    public Task<int> ListPortsAsync(
        string tunnelId,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        ILogger? logger = default,
        CancellationToken cancellationToken = default)
    {
        return RunAsync(new ArgsBuilder(["port", "list", tunnelId])
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, logger, cancellationToken);
    }
 
    public Task<int> ListAccessAsync(
        string tunnelId,
        int? portNumber = null,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        ILogger? logger = default,
        CancellationToken cancellationToken = default)
    {
        return RunAsync(new ArgsBuilder(["access", "list", tunnelId])
            .AddIfNotNull("--port-number", portNumber?.ToString(CultureInfo.InvariantCulture))
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, logger, cancellationToken);
    }
 
    public Task<int> ResetAccessAsync(
        string tunnelId,
        int? portNumber = null,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        ILogger? logger = default,
        CancellationToken cancellationToken = default)
    {
        return RunAsync(new ArgsBuilder(["access", "reset", tunnelId])
            .AddIfNotNull("--port-number", portNumber?.ToString(CultureInfo.InvariantCulture))
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, logger, cancellationToken);
    }
 
    public Task<int> CreateAccessAsync(
        string tunnelId,
        int? portNumber = null,
        bool anonymous = false,
        bool deny = false,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        ILogger? logger = default,
        CancellationToken cancellationToken = default)
    {
        if (!anonymous && !deny)
        {
            throw new ArgumentException($"Must specify either {nameof(anonymous)} or {nameof(deny)} as true, or both, but not neither.");
        }
 
        return RunAsync(new ArgsBuilder(["access", "create", tunnelId])
            .AddIfNotNull("--port-number", portNumber?.ToString(CultureInfo.InvariantCulture))
            .AddIfTrue("--deny", deny)
            .AddIfTrue("--anonymous", anonymous)
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, logger, cancellationToken);
    }
 
    public Task<int> DeleteTunnelAsync(
        string tunnelId,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        ILogger? logger = default,
        CancellationToken cancellationToken = default)
        => RunAsync(["delete", tunnelId, "--force", "--json", "--nologo"], outputWriter, errorWriter, logger, cancellationToken);
 
    public Task<int> ShowTunnelAsync(
        string tunnelId,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        ILogger? logger = default,
        CancellationToken cancellationToken = default)
        => RunAsync(["show", tunnelId, "--json", "--nologo"], outputWriter, errorWriter, logger, cancellationToken);
 
    public Task<int> CreatePortAsync(
        string tunnelId,
        int portNumber,
        DevTunnelPortOptions? options = null,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        ILogger? logger = default,
        CancellationToken cancellationToken = default)
    {
        options ??= new DevTunnelPortOptions();
        return RunAsync(new ArgsBuilder(["port", "create", tunnelId])
            .Add("--port-number", portNumber.ToString(CultureInfo.InvariantCulture))
            .AddIfNotNull("--protocol", options.Protocol)
            .AddValues("--labels", options.Labels)
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, logger, cancellationToken);
    }
 
    public Task<int> UpdatePortAsync(
        string tunnelId,
        int portNumber,
        DevTunnelPortOptions? options = null,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        ILogger? logger = default,
        CancellationToken cancellationToken = default)
    {
        options ??= new DevTunnelPortOptions();
        return RunAsync(new ArgsBuilder(["port", "update", tunnelId])
            .Add("--port-number", portNumber.ToString(CultureInfo.InvariantCulture))
            .AddIfNotNull("--description", options.Description)
            .AddValues("--add-labels", options.Labels)
            .Add("--json")
            .Add("--nologo")
        , outputWriter, errorWriter, logger, cancellationToken);
    }
 
    public Task<int> DeletePortAsync(
        string tunnelId,
        int portNumber,
        TextWriter? outputWriter = null,
        TextWriter? errorWriter = null,
        ILogger? logger = default,
        CancellationToken cancellationToken = default)
        => RunAsync(["port", "delete", tunnelId, "--port-number", portNumber.ToString(CultureInfo.InvariantCulture), "--json", "--nologo"], outputWriter, errorWriter, logger, cancellationToken);
 
    private Task<int> RunAsync(string[] args, TextWriter? outputWriter = null, TextWriter? errorWriter = null, ILogger? logger = default, CancellationToken cancellationToken = default)
        => RunAsync(args, outputWriter, errorWriter, useShellExecute: false, logger, cancellationToken);
 
    private Task<int> RunAsync(string[] args, TextWriter? outputWriter = null, TextWriter? errorWriter = null, bool useShellExecute = false, ILogger? logger = default, CancellationToken cancellationToken = default)
    {
        return RunAsync((isError, line) =>
        {
            if (isError)
            {
                errorWriter?.WriteLine(line);
            }
            else
            {
                outputWriter?.WriteLine(line);
            }
        }, args, useShellExecute, logger, cancellationToken);
    }
 
    private async Task<int> RunAsync(Action<bool, string> onOutput, string[] args, bool useShellExecute = false, ILogger? logger = default, CancellationToken cancellationToken = default)
    {
        using var process = new Process
        {
            StartInfo = BuildStartInfo(args, useShellExecute),
            EnableRaisingEvents = true
        };
 
        var stdoutTask = Task.CompletedTask;
        var stderrTask = Task.CompletedTask;
 
        logger?.LogTrace("Invoking devtunnel CLI{ShellExecuteInfo}: {FileName} {Arguments}", useShellExecute ? " (UseShellExecute=true)" : "", process.StartInfo.FileName, EscapeArgList(process.StartInfo.ArgumentList));
 
        try
        {
            if (!process.Start())
            {
                throw new InvalidOperationException("Failed to start devtunnel process.");
            }
 
            if (!useShellExecute)
            {
                stdoutTask = PumpAsync(process.StandardOutput, line => onOutput(false, line), cancellationToken);
                stderrTask = PumpAsync(process.StandardError, line => onOutput(true, line), cancellationToken);
            }
 
            using var ctr = cancellationToken.Register(() =>
            {
                try
                {
                    if (!process.HasExited)
                    {
                        logger?.LogTrace("Cancellation requested, killing devtunnel process tree.");
                        process.Kill(entireProcessTree: true);
                    }
                }
                catch
                {
                    // ignored
                }
            });
 
            await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
 
            logger?.LogTrace("devtunnel CLI exited with exit code '{ExitCode}'.", process.ExitCode);
            return process.ExitCode;
        }
        finally
        {
            if (!useShellExecute)
            {
                try
                {
                    logger?.LogTrace("devtunnel CLI exited, draining stdout/stderr.");
                    await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
                }
                catch
                {
                    // ignored
                }
            }
        }
    }
 
    private static string? EscapeArgList(Collection<string> args)
    {
        if (args.Count == 0)
        {
            return null;
        }
 
        StringBuilder? sb = null;
 
        foreach (var a in args)
        {
            if (string.IsNullOrWhiteSpace(a))
            {
                continue;
            }
 
            sb ??= new StringBuilder(args.Count * 2);
            if (sb.Length > 0)
            {
                sb.Append(' ');
            }
 
            var needsQuotes = a.Any(char.IsWhiteSpace) || a.Contains('"');
            if (needsQuotes)
            {
                sb.Append('"');
            }
 
            foreach (var c in a)
            {
                if (c == '"')
                {
                    sb.Append('\\'); // Escape quote
                }
                sb.Append(c);
            }
 
            if (needsQuotes)
            {
                sb.Append('"');
            }
        }
 
        return sb?.ToString();
    }
 
    private ProcessStartInfo BuildStartInfo(IEnumerable<string> args, bool useShellExecute = false)
    {
        var psi = new ProcessStartInfo
        {
            FileName = _cliPath,
            UseShellExecute = useShellExecute,
            RedirectStandardOutput = !useShellExecute,
            RedirectStandardError = !useShellExecute,
            RedirectStandardInput = false,
            CreateNoWindow = true,
            WindowStyle = ProcessWindowStyle.Hidden
        };
 
        // Prefer ArgumentList to avoid quoting issues.
        foreach (var a in args)
        {
            if (string.IsNullOrWhiteSpace(a))
            {
                continue;
            }
 
            psi.ArgumentList.Add(a);
        }
 
        // Ensure consistent encoding on Windows terminals
        if (!useShellExecute && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
        {
            psi.StandardOutputEncoding = Encoding.UTF8;
            psi.StandardErrorEncoding = Encoding.UTF8;
        }
 
        return psi;
    }
 
    private static async Task PumpAsync(StreamReader reader, Action<string> onLine, CancellationToken cancellationToken = default)
    {
        while (!reader.EndOfStream && !cancellationToken.IsCancellationRequested)
        {
            var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
            if (line is null)
            {
                break;
            }
            if (line.Length > 0)
            {
                onLine(line);
            }
        }
    }
}
 
internal sealed class ArgsBuilder(IEnumerable<string> initialArgs)
{
    private readonly List<string> _args = [.. initialArgs];
 
    public ArgsBuilder Add(string value)
    {
        _args.Add(value);
        return this;
    }
 
    public ArgsBuilder Add(string name, string value)
    {
        _args.Add(name);
        _args.Add(value);
        return this;
    }
 
    public ArgsBuilder AddIfNotNull(string? value)
    {
        if (!string.IsNullOrEmpty(value))
        {
            _args.Add(value);
        }
        return this;
    }
 
    public ArgsBuilder AddIfNotNull(string name, string? value)
    {
        if (!string.IsNullOrEmpty(value))
        {
            _args.Add(name);
            _args.Add(value);
        }
        return this;
    }
 
    public ArgsBuilder AddIfTrue(string name, bool? condition)
    {
        if (condition == true)
        {
            _args.Add(name);
        }
        return this;
    }
 
    public ArgsBuilder AddIfFalse(string name, bool? condition)
    {
        if (condition == false)
        {
            _args.Add(name);
        }
        return this;
    }
 
    internal ArgsBuilder AddValues(string name, List<string>? values, char? separator = null)
    {
        if (values is null || values.Count == 0)
        {
            return this;
        }
 
        if (separator is null)
        {
            // Add each value separately, e.g. --label a --label b
            foreach (var v in values)
            {
                if (!string.IsNullOrWhiteSpace(v))
                {
                    _args.Add(name);
                    _args.Add(v);
                }
            }
            return this;
        }
        else
        {
            // Build a single separated string of values, e.g. --labels a,b
            // Assumes values themselves do not contain the separator character.
            var tokens = values.Where(l => !string.IsNullOrWhiteSpace(l));
            var joined = string.Join(separator.Value, tokens);
            if (!string.IsNullOrWhiteSpace(joined))
            {
                _args.Add(name);
                _args.Add(joined);
            }
        }
 
        return this;
    }
 
    public static implicit operator string[](ArgsBuilder builder) => [.. builder._args];
}