PrivateHelpers.ps1
|
function Invoke-DevTunnelProcess { [CmdletBinding()] param( [Parameter(Mandatory)][string]$FilePath, [Parameter(Mandatory)][string[]]$Arguments, [switch]$Stream, [switch]$Sensitive, [ValidateRange(1, 3600)][int]$TimeoutSeconds = 60 ) end { $process = [System.Diagnostics.Process]::new() $process.StartInfo = [System.Diagnostics.ProcessStartInfo]::new() $process.StartInfo.FileName = $FilePath $process.StartInfo.UseShellExecute = $false $process.StartInfo.RedirectStandardOutput = $true $process.StartInfo.RedirectStandardError = $true $process.StartInfo.RedirectStandardInput = $true foreach ($argument in $Arguments) { if ($null -eq $argument -or $argument.Contains([char]0)) { throw 'devtunnel arguments cannot contain null characters.' } $process.StartInfo.ArgumentList.Add($argument) } $stdout = [System.Text.StringBuilder]::new() $stderr = [System.Text.StringBuilder]::new() $timer = [System.Diagnostics.Stopwatch]::StartNew() $started = $process.Start() if (-not $started) { throw 'Could not start devtunnel.' } $process.StandardInput.Close() $outRead = $process.StandardOutput.ReadLineAsync() $errRead = $process.StandardError.ReadLineAsync() while ($null -ne $outRead -or $null -ne $errRead -or -not $process.HasExited) { $readCompleted = $false if (-not $Stream -and $timer.Elapsed.TotalSeconds -ge $TimeoutSeconds) { throw "devtunnel timed out after $TimeoutSeconds seconds." } if ($null -ne $outRead -and $outRead.IsCompleted) { $readCompleted = $true $line = $outRead.GetAwaiter().GetResult() if ($null -eq $line) { $outRead = $null } else { if ($Stream) { if ($Sensitive) { Protect-DevTunnelAuthenticationOutput -Text $line } else { $line } } else { $null = $stdout.AppendLine($line) } $outRead = $process.StandardOutput.ReadLineAsync() } } if ($null -ne $errRead -and $errRead.IsCompleted) { $readCompleted = $true $line = $errRead.GetAwaiter().GetResult() if ($null -eq $line) { $errRead = $null } else { if ($Stream) { if ($Sensitive) { $line = Protect-DevTunnelAuthenticationOutput -Text $line } Write-Information $line -InformationAction Continue } else { $null = $stderr.AppendLine($line) } $errRead = $process.StandardError.ReadLineAsync() } } if (-not $readCompleted) { Start-Sleep -Milliseconds 10 } } $process.WaitForExit() if ($process.ExitCode -ne 0) { $message = "devtunnel failed (exit code $($process.ExitCode))." # Authentication and token failures must not echo credential-bearing output. if (-not $Sensitive -and $stderr.Length) { $message += [Environment]::NewLine + $stderr.ToString().TrimEnd() } $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new($message), 'DevTunnelCommandFailed', [System.Management.Automation.ErrorCategory]::InvalidOperation, $FilePath)) } if (-not $Stream) { if (-not $Sensitive -and $stderr.Length) { Write-Warning $stderr.ToString().TrimEnd() } $stdout.ToString() } } clean { if ($process) { try { if ($started -and -not $process.HasExited) { $process.Kill($true) $process.WaitForExit() } } finally { $process.Dispose() } } } } function Protect-DevTunnelAuthenticationOutput { param([AllowEmptyString()][string]$Text) # Device-code instructions must arrive before login finishes; do not buffer them. $text = [regex]::Replace($Text, '(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|authorization|client[_-]?secret)\b["'']?\s*[:=]\s*["'']?(?:bearer\s+)?)[^\s"'',;]+', '${1}[REDACTED]') $text = [regex]::Replace($text, '(?i)\bBearer\s+[A-Za-z0-9._~+/-]+=*', 'Bearer [REDACTED]') $text = [regex]::Replace($text, '\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b', '[REDACTED]') [regex]::Replace($text, '\b(?:gh[pousr]_[A-Za-z0-9]+|github_pat_[A-Za-z0-9_]+)\b', '[REDACTED]') } function Resolve-DevTunnelCli { [CmdletBinding()] param() $command = Get-Command devtunnel -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $command) { throw 'devtunnel was not found on PATH. Install the Microsoft devtunnel CLI: https://learn.microsoft.com/azure/developer/dev-tunnels/get-started' } if ($IsWindows -and [System.IO.Path]::GetExtension($command.Source) -ne '.exe') { throw 'devtunnel must resolve to a native .exe, not a Windows command shim. Put the devtunnel executable on PATH.' } $command.Source } function Get-DevTunnelCliVersion { [CmdletBinding()] param([Parameter(Mandatory)][string]$FilePath) $versionText = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($FilePath).ProductVersion if (-not $versionText) { $versionText = Invoke-DevTunnelProcess -FilePath $FilePath -Arguments '--version' } if ($versionText -notmatch '(?m)^(?:Tunnel CLI version:\s*)?(?<version>\d+\.\d+\.\d+)(?:[+.\s-]|$)') { throw 'Cannot determine the devtunnel CLI version. Install devtunnel 1.0.2115 or a compatible 1.x version.' } $version = [version]$Matches.version if ($version -lt [version]'1.0.2115' -or $version.Major -ne 1) { throw "Unsupported devtunnel CLI version $version. Install devtunnel 1.0.2115 or a compatible 1.x version." } $version } function New-DevTunnelArguments { [CmdletBinding()] param( [Parameter(Mandatory)][string[]]$Command, [Parameter(Mandatory)][System.Collections.IDictionary]$Parameters, [switch]$Json ) $arguments = [System.Collections.Generic.List[string]]::new() $arguments.AddRange($Command) if ($Parameters.Keys -contains 'TunnelId') { $arguments.Add([string]$Parameters['TunnelId']) } $options = [ordered]@{ PortNumber = 'port-number'; Protocol = 'protocol'; Description = 'description' Label = 'labels'; AllLabel = 'all-labels'; AddLabel = 'add-labels'; RemoveLabel = 'remove-labels' Expiration = 'expiration'; Limit = 'limit'; Scope = 'scopes'; Index = 'index' Organization = 'organization'; Repository = 'repo'; Interface = 'interface' Size = 'size'; Interval = 'interval' } foreach ($name in $options.Keys) { if ($Parameters.Keys -contains $name) { foreach ($value in @($Parameters[$name])) { $arguments.Add("--$($options[$name])=$value") } } } $switches = [ordered]@{ AllowAnonymous = 'allow-anonymous'; Anonymous = 'anonymous'; Tenant = 'tenant' Deny = 'deny'; GitHub = 'github'; DeviceCode = 'use-device-code-auth'; Ping = 'ping' } foreach ($entry in $switches.GetEnumerator()) { if ($Parameters.Keys -contains $entry.Key -and $Parameters[$entry.Key]) { $arguments.Add("--$($entry.Value)") } } if ($Json) { $arguments.Add('--json') } $arguments.ToArray() } function Invoke-DevTunnelCommand { [CmdletBinding()] param( [Parameter(Mandatory)][string[]]$Arguments, [switch]$Stream, [switch]$Sensitive ) $filePath = Resolve-DevTunnelCli $null = Get-DevTunnelCliVersion -FilePath $filePath if ($Stream) { Invoke-DevTunnelProcess -FilePath $filePath -Arguments $Arguments -Stream -Sensitive:$Sensitive return } $output = Invoke-DevTunnelProcess -FilePath $filePath -Arguments $Arguments -Sensitive:$Sensitive if ([string]::IsNullOrWhiteSpace($output)) { return } try { $output | ConvertFrom-Json -Depth 100 -ErrorAction Stop } catch { # Do not include JSON content: token output can contain secrets. throw 'devtunnel returned invalid JSON. Verify that the installed CLI version supports this command and --json.' } } function Write-DevTunnelAnonymousWarning { Write-Warning 'Anonymous access allows anyone on the internet who knows the tunnel address to connect to the exposed service.' } function Test-DevTunnelExpiration { param([string]$Value) if ($Value -notmatch '\A(?<value>[1-9][0-9]{0,2})(?<unit>[hd])\z') { throw 'Expiration must be 1h through 720h or 1d through 30d.' } $maximum = if ($Matches.unit -eq 'h') { 720 } else { 30 } if ([int]$Matches.value -gt $maximum) { throw 'Expiration cannot exceed 30 days.' } $true } |