Private/New-GceSshTunnel.ps1

function New-GceSshTunnel {

    #Requires -Version 3.0

    <#
     
    .SYNOPSIS
     
        Creates an IAP tunnel to a GCE VM instance for SSH access.
     
    .DESCRIPTION
     
        Creates an Identity-Aware Proxy (IAP) tunnel to a Google Cloud Engine VM instance.
        This is a private helper function used by New-GcePSSession.
     
    .PARAMETER Project
     
        The GCP project ID that contains the VM instance.
     
    .PARAMETER Zone
     
        The GCE zone where the VM instance is located.
     
    .PARAMETER InstanceName
     
        The name of the GCE VM instance.
     
    .PARAMETER LocalPort
     
        Local port to use for the IAP tunnel. Defaults to 0 (auto-select).
     
    .PARAMETER RemotePort
     
        Remote port on the VM (default: 22 for SSH).
     
    .PARAMETER GcloudPath
     
        Path to gcloud CLI executable. Defaults to 'gcloud'.
     
    .PARAMETER TunnelReadyTimeout
     
        Maximum time in seconds to wait for the tunnel to become ready. Defaults to 30 seconds.
     
    .PARAMETER ShowTunnelWindow
     
        When specified, the IAP tunnel process will run in a visible window.
     
    .OUTPUTS
     
        PSCustomObject with the following properties:
        - TunnelProcess: The Process object for the tunnel
        - LocalPort: The local port used for the tunnel
        - RemotePort: The remote port
        - Project: The GCP project
        - Zone: The GCE zone
        - InstanceName: The VM instance name
     
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$Project,
        
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$Zone,
        
        [Parameter(Mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$InstanceName,
        
        [Parameter(Mandatory=$false)]
        [ValidateRange(0, 65535)]
        [int]$LocalPort = 0,
        
        [Parameter(Mandatory=$false)]
        [ValidateRange(1, 65535)]
        [int]$RemotePort = 22,
        
        [Parameter(Mandatory=$false)]
        [string]$GcloudPath = 'gcloud',
        
        [Parameter(Mandatory=$false)]
        [int]$TunnelReadyTimeout = 30,
        
        [Parameter(Mandatory=$false)]
        [switch]$ShowTunnelWindow
    )

    $ErrorActionPreference = 'Stop'
    $TunnelProcess = $null
    $ErrorOutputEvent = $null
    $ErrorOutputBuilder = $null

    try {
        Write-Verbose "$(Get-Date): [GceSshTunnel]: Starting IAP tunnel setup"

        # Verify gcloud is available and get the full path
        $gcloudCheck = Get-Command $GcloudPath -ErrorAction SilentlyContinue
        if (-not $gcloudCheck) {
            throw "gcloud CLI not found. Please install Google Cloud SDK and ensure 'gcloud' is in your PATH."
        }

        # Get the actual executable path (handles aliases, functions, etc.)
        $GcloudExecutable = $gcloudCheck.Source
        if (-not $GcloudExecutable) {
            $GcloudExecutable = (Get-Command $GcloudPath -ErrorAction Stop).Source
        }

        # Handle PowerShell script files (.ps1) - need to execute via PowerShell
        $UsePowerShell = $false
        if ($GcloudExecutable -like '*.ps1') {
            $UsePowerShell = $true
            Write-Verbose "$(Get-Date): [GceSshTunnel]: gcloud is a PowerShell script, will execute via PowerShell"
        }

        Write-Verbose "$(Get-Date): [GceSshTunnel]: Using gcloud at: $GcloudExecutable"

        # Find an available local port if not specified
        if ($LocalPort -eq 0) {
            $TcpListener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Any, 0)
            $TcpListener.Start()
            $LocalPort = ($TcpListener.LocalEndpoint).Port
            $TcpListener.Stop()
            Write-Verbose "$(Get-Date): [GceSshTunnel]: Selected local port: $LocalPort"
        }

        # Start IAP tunnel in background
        Write-Verbose "$(Get-Date): [GceSshTunnel]: Starting IAP tunnel to $InstanceName"
        $TunnelArgs = @(
            'compute', 'start-iap-tunnel',
            $InstanceName,
            $RemotePort,
            '--zone', $Zone,
            '--project', $Project,
            '--local-host-port', "localhost:$LocalPort"
        )

        $TunnelProcessInfo = New-Object System.Diagnostics.ProcessStartInfo

        if ($UsePowerShell) {
            # Execute PowerShell script via PowerShell
            if ($ShowTunnelWindow) {
                # For visible window, use pwsh.exe with -NoExit to keep window open for debugging
                $TunnelProcessInfo.FileName = 'pwsh.exe'
                if (-not (Get-Command pwsh.exe -ErrorAction SilentlyContinue)) {
                    $TunnelProcessInfo.FileName = 'powershell.exe'
                }
                # Wrap in try-catch to keep window open on error
                # Properly escape and quote the path for PowerShell command string
                # Use single quotes for the path in PowerShell command string to avoid escaping issues
                $escapedGcloudPath = $GcloudExecutable -replace "'", "''"  # Escape single quotes by doubling them
                $escapedGcloudPath = "'$escapedGcloudPath'"  # Wrap in single quotes
                $escapedArgs = ($TunnelArgs | ForEach-Object { 
                    $arg = $_ -replace "'", "''"  # Escape single quotes
                    "'$arg'"  # Wrap each arg in single quotes
                }) -join ' '
                $commandScript = "try { & $escapedGcloudPath $escapedArgs } catch { Write-Host 'Error: ' + `$_.Exception.Message -ForegroundColor Red; Read-Host 'Press Enter to close this window' }"
                # Escape the command script for the command line argument
                $commandScriptEscaped = $commandScript -replace '"', '`"'
                $TunnelProcessInfo.Arguments = "-NoExit -ExecutionPolicy Bypass -Command `"$commandScriptEscaped`""
            } else {
                # Hidden window - properly quote the file path in arguments
                $TunnelProcessInfo.FileName = 'powershell.exe'
                # For -File parameter, we need to quote the path if it contains spaces
                # When building command line arguments, paths with spaces need to be quoted
                # and any quotes inside need to be escaped by doubling them
                $quotedGcloudPath = $GcloudExecutable
                if ($GcloudExecutable -match '\s' -or $GcloudExecutable -match '"') {
                    $quotedGcloudPath = $GcloudExecutable -replace '"', '""'  # Escape quotes by doubling
                    $quotedGcloudPath = "`"$quotedGcloudPath`""  # Wrap in quotes
                }
                $TunnelArgsArray = @(
                    '-NoProfile',
                    '-NonInteractive',
                    '-ExecutionPolicy', 'Bypass',
                    '-File', $quotedGcloudPath
                ) + $TunnelArgs
                # Build arguments string, ensuring each argument with spaces is properly quoted
                $argStrings = $TunnelArgsArray | ForEach-Object {
                    if ($_ -match '\s' -or $_ -match '"') {
                        $escaped = $_ -replace '"', '""'
                        "`"$escaped`""
                    } else {
                        $_
                    }
                }
                $TunnelProcessInfo.Arguments = $argStrings -join ' '
            }
        } else {
            # Execute as regular executable
            # ProcessStartInfo.FileName should be the path without quotes
            # The system will handle paths with spaces automatically
            $TunnelProcessInfo.FileName = $GcloudExecutable
            $TunnelProcessInfo.Arguments = $TunnelArgs -join ' '
        }

        # Configure process window visibility
        if ($ShowTunnelWindow) {
            $TunnelProcessInfo.UseShellExecute = $true
            $TunnelProcessInfo.CreateNoWindow = $false
            # Cannot redirect streams when UseShellExecute is true
        } else {
            $TunnelProcessInfo.UseShellExecute = $false
            # Only redirect StandardError - we don't need StandardInput/StandardOutput and they can cause blocking
            $TunnelProcessInfo.RedirectStandardError = $true
            $TunnelProcessInfo.CreateNoWindow = $true
        }

        $TunnelProcess = [System.Diagnostics.Process]::Start($TunnelProcessInfo)
        
        # Start reading error stream asynchronously to prevent blocking
        if (-not $ShowTunnelWindow) {
            $ErrorOutputBuilder = New-Object System.Text.StringBuilder
            $ErrorOutputEvent = Register-ObjectEvent -InputObject $TunnelProcess -EventName ErrorDataReceived -Action {
                if ($EventArgs.Data) {
                    [void]$Event.MessageData.AppendLine($EventArgs.Data)
                }
            } -MessageData $ErrorOutputBuilder
            $TunnelProcess.BeginErrorReadLine()
        }

        # Wait for tunnel to establish by testing port connectivity
        Write-Verbose "$(Get-Date): [GceSshTunnel]: Waiting for tunnel to be ready on port $LocalPort..."
        $TunnelReady = $false
        $MaxWaitTime = $TunnelReadyTimeout
        $Attempts = 0
        $MaxAttempts = ($MaxWaitTime * 2)  # Check every 500ms

        while (-not $TunnelReady -and $Attempts -lt $MaxAttempts) {
            $Attempts++
            Start-Sleep -Milliseconds 500

            # Check if process has exited (error)
            if ($TunnelProcess.HasExited) {
                $ErrorOutput = ""
                if (-not $ShowTunnelWindow -and $ErrorOutputEvent) {
                    # Stop reading and get accumulated error output
                    try {
                        Stop-Job -Job $ErrorOutputEvent -ErrorAction SilentlyContinue
                        Unregister-Event -SourceIdentifier $ErrorOutputEvent.Name -ErrorAction SilentlyContinue
                        $ErrorOutput = $ErrorOutputBuilder.ToString()
                    } catch { }
                }
                if ($ErrorOutput) {
                    throw "Failed to start IAP tunnel: $ErrorOutput"
                } else {
                    throw "IAP tunnel process exited unexpectedly. Exit code: $($TunnelProcess.ExitCode)"
                }
            }

            # Try to connect to the port to verify tunnel is ready
            try {
                $TcpClient = New-Object System.Net.Sockets.TcpClient
                $ConnectResult = $TcpClient.BeginConnect("localhost", $LocalPort, $null, $null)
                $WaitResult = $ConnectResult.AsyncWaitHandle.WaitOne(1000, $false)

                if ($WaitResult -and $TcpClient.Connected) {
                    $TunnelReady = $true
                    $TcpClient.Close()
                    Write-Verbose "$(Get-Date): [GceSshTunnel]: Tunnel is ready and accepting connections!"
                } else {
                    $TcpClient.Close()
                }
            } catch {
                # Port not ready yet, continue waiting
                if ($TcpClient) { $TcpClient.Close() }
            }
        }

        if (-not $TunnelReady) {
            $ErrorOutput = ""
            if (-not $TunnelProcess.HasExited -and -not $ShowTunnelWindow -and $ErrorOutputEvent) {
                # Stop reading and get accumulated error output
                try {
                    Stop-Job -Job $ErrorOutputEvent -ErrorAction SilentlyContinue
                    Unregister-Event -SourceIdentifier $ErrorOutputEvent.Name -ErrorAction SilentlyContinue
                    $ErrorOutput = $ErrorOutputBuilder.ToString()
                } catch { }
            }
            $ErrorMsg = "IAP tunnel did not become ready within $MaxWaitTime seconds. "
            if ($ErrorOutput) {
                $ErrorMsg += "Error: $ErrorOutput"
            } elseif ($ShowTunnelWindow) {
                $ErrorMsg += "Check the tunnel window for error details. Verify gcloud authentication and IAP permissions."
            } else {
                $ErrorMsg += "Check gcloud authentication and IAP permissions."
            }
            throw $ErrorMsg
        }
        
        # Clean up error stream reader if tunnel is ready
        if (-not $ShowTunnelWindow -and $ErrorOutputEvent) {
            try {
                Stop-Job -Job $ErrorOutputEvent -ErrorAction SilentlyContinue
                Unregister-Event -SourceIdentifier $ErrorOutputEvent.Name -ErrorAction SilentlyContinue
            } catch { }
        }

        Write-Verbose "$(Get-Date): [GceSshTunnel]: Tunnel established successfully"

        # Return tunnel information object
        return [PSCustomObject]@{
            TunnelProcess = $TunnelProcess
            LocalPort = $LocalPort
            RemotePort = $RemotePort
            Project = $Project
            Zone = $Zone
            InstanceName = $InstanceName
        }

    } catch {
        # Cleanup on error
        if ($TunnelProcess -and -not $TunnelProcess.HasExited) {
            Write-Verbose "$(Get-Date): [GceSshTunnel]: Cleaning up tunnel process due to error"
            $TunnelProcess.Kill()
            $TunnelProcess.WaitForExit(5000)
        }
        if ($ErrorOutputEvent) {
            try {
                Stop-Job -Job $ErrorOutputEvent -ErrorAction SilentlyContinue
                Unregister-Event -SourceIdentifier $ErrorOutputEvent.Name -ErrorAction SilentlyContinue
            } catch { }
        }
        throw
    }
}