Public/Enter-OnePAMSSH.ps1

function Enter-OnePAMSSH {
    <#
    .SYNOPSIS
        Starts an interactive SSH session to a OnePAM resource.
    .DESCRIPTION
        Resolves the resource, creates a gateway session, and opens a WebSocket-based
        terminal relay. The session is recorded and audited by OnePAM.
    .PARAMETER Resource
        Resource name or UUID. Accepts user@resource syntax.
    .PARAMETER User
        SSH login user (overrides user@ prefix).
    .PARAMETER Command
        Remote command to execute instead of opening a shell.
    .EXAMPLE
        Enter-OnePAMSSH -Resource "my-server"
    .EXAMPLE
        Enter-OnePAMSSH -Resource "root@my-server"
    .EXAMPLE
        Enter-OnePAMSSH -Resource "my-server" -Command "uname -a"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Resource,

        [Alias('l')]
        [string]$User,

        [string]$Command
    )

    $login = $User
    $resourceName = $Resource
    if ($Resource -match '^(.+)@(.+)$' -and -not $User) {
        $login = $Matches[1]
        $resourceName = $Matches[2]
    }

    $res = Get-OnePAMResource -Name $resourceName
    if (-not $res) { throw "Resource '$resourceName' not found." }

    $resId = if ($res.id) { $res.id } elseif ($res.ID) { $res.ID } else { throw "Cannot determine resource ID." }
    Assert-OpSafePathSegment -Value $resId -Label 'resource ID'

    if ($res.type -and $res.type -ne 'ssh') {
        throw "Resource '$resourceName' is type '$($res.type)', not SSH."
    }

    $connectBody = @{}
    if ($login) { $connectBody['ssh_user'] = $login }
    $session = Invoke-OpApi -Method POST -Path "/api/v1/resources/$resId/connect" -Body $connectBody

    if (-not $session.session_id) {
        throw 'Failed to create session: no session_id returned.'
    }
    Assert-OpSafePathSegment -Value $session.session_id -Label 'session ID'

    $cfg = Get-OpConfig
    if ($session.direct) {
        $baseUri = [Uri]$cfg.api_base
        $wsScheme = if ($baseUri.Scheme -eq 'https') { 'wss' } else { 'ws' }
        $hostPort = $baseUri.Authority
    }
    else {
        if (-not $session.proxy_host) { throw 'Session response missing proxy_host.' }
        $wsScheme = 'wss'
        $hostPort = $session.proxy_host
        if ($session.proxy_port -and $session.proxy_port -ne 443) {
            $hostPort = "$($session.proxy_host):$($session.proxy_port)"
        }
    }

    $connectPath = if ($session.connect_url) { $session.connect_url } else { "/gateway/ssh/$($session.session_id)" }

    $queryParts = [System.Collections.Generic.List[string]]::new()
    if (-not $session.direct -and $session.token) {
        $queryParts.Add("token=$([System.Uri]::EscapeDataString($session.token))")
    }
    if ($Command) {
        $queryParts.Add("mode=exec")
        $queryParts.Add("command=$([System.Uri]::EscapeDataString($Command))")
    }

    $wsUrl = "${wsScheme}://${hostPort}${connectPath}"
    if ($queryParts.Count -gt 0) {
        $wsUrl += "?$($queryParts -join '&')"
    }

    $ws = [System.Net.WebSockets.ClientWebSocket]::new()
    $ws.Options.SetRequestHeader('User-Agent', "onepam-powershell/$script:ModuleVersion")

    if ($session.direct) {
        $token = Get-OpToken
        if ($token) {
            $ws.Options.SetRequestHeader('Authorization', "Bearer $($token.access_token)")
        }
    }

    $connectCts = [System.Threading.CancellationTokenSource]::new([TimeSpan]::FromSeconds(15))
    try {
        $ws.ConnectAsync([Uri]$wsUrl, $connectCts.Token).GetAwaiter().GetResult()
    }
    catch {
        $ws.Dispose()
        throw "WebSocket connection failed: $_"
    }
    finally {
        $connectCts.Dispose()
    }

    Write-Host "Connected to $resourceName (session: $($session.session_id))" -ForegroundColor Green
    if ($Command) {
        Write-Host "Executing: $Command" -ForegroundColor Gray
    }
    Write-Host ''

    try {
        $cols = [Console]::WindowWidth
        $rows = [Console]::WindowHeight
        $resizeMsg = @{ type = 'resize'; cols = $cols; rows = $rows } | ConvertTo-Json -Compress
        Send-OpWsMessage -WebSocket $ws -Message $resizeMsg
    }
    catch { }

    $stdinBuffer = [byte[]]::new(4096)
    $stdin  = [Console]::OpenStandardInput()
    $stdout = [Console]::OpenStandardOutput()

    $cts = [System.Threading.CancellationTokenSource]::new()

    $receiveTask = [System.Threading.Tasks.Task]::Run({
        $buf = [byte[]]::new(65536)
        $seg = [ArraySegment[byte]]::new($buf)
        try {
            while ($ws.State -eq [System.Net.WebSockets.WebSocketState]::Open -and -not $cts.Token.IsCancellationRequested) {
                $ms = [System.IO.MemoryStream]::new()
                try {
                    do {
                        $result = $ws.ReceiveAsync($seg, $cts.Token).GetAwaiter().GetResult()
                        if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) {
                            $cts.Cancel()
                            return
                        }
                        $ms.Write($buf, 0, $result.Count)
                    } while (-not $result.EndOfMessage)

                    if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Binary) {
                        $data = $ms.ToArray()
                        $stdout.Write($data, 0, $data.Length)
                        $stdout.Flush()
                    }
                }
                finally {
                    $ms.Dispose()
                }
            }
        }
        catch [System.OperationCanceledException] { }
        catch { }
    }.GetNewClosure())

    try {
        if ([Environment]::OSVersion.Platform -ne 'Win32NT') {
            $sttyState = & stty -g 2>$null
            & stty raw -echo 2>$null
        }

        while ($ws.State -eq [System.Net.WebSockets.WebSocketState]::Open -and -not $cts.Token.IsCancellationRequested) {
            try {
                $bytesRead = $stdin.Read($stdinBuffer, 0, $stdinBuffer.Length)
                if ($bytesRead -le 0) {
                    $cts.Cancel()
                    break
                }
                $segment = [ArraySegment[byte]]::new($stdinBuffer, 0, $bytesRead)
                $ws.SendAsync($segment, [System.Net.WebSockets.WebSocketMessageType]::Binary, $true, $cts.Token).GetAwaiter().GetResult()
            }
            catch [System.OperationCanceledException] { break }
            catch { break }
        }
    }
    finally {
        if ([Environment]::OSVersion.Platform -ne 'Win32NT' -and $sttyState) {
            & stty $sttyState 2>$null
        }

        $cts.Cancel()

        try { $receiveTask.Wait(2000) } catch { }

        Close-OpWebSocket -WebSocket $ws
        $cts.Dispose()
        $stdin.Dispose()
        $stdout.Dispose()

        Write-Host ''
        Write-Host "Session ended." -ForegroundColor Gray
    }
}