Compare commits

...

3 Commits

3 changed files with 302 additions and 36 deletions
@@ -211,10 +211,35 @@ func (r *SSHSessionRegistry) RequestClose(id string, reason string) bool {
} }
func (s *sshActiveSession) SetResources(ws *websocket.Conn, client *ssh.Client, session *ssh.Session, stdin io.WriteCloser) { func (s *sshActiveSession) SetResources(ws *websocket.Conn, client *ssh.Client, session *ssh.Session, stdin io.WriteCloser) {
var closeWS *websocket.Conn
var closeClient *ssh.Client
var closeSession *ssh.Session
var closeStdin io.WriteCloser
if s == nil { if s == nil {
return return
} }
s.mu.Lock() s.mu.Lock()
if s.closed {
closeWS = ws
closeClient = client
closeSession = session
closeStdin = stdin
s.mu.Unlock()
if closeStdin != nil {
_ = closeStdin.Close()
}
if closeSession != nil {
_ = closeSession.Close()
}
if closeClient != nil {
_ = closeClient.Close()
}
if closeWS != nil {
_ = closeWS.Close()
}
return
}
if ws != nil { if ws != nil {
s.ws = ws s.ws = ws
} }
+248 -23
View File
@@ -22,6 +22,9 @@ import "codit/internal/middleware"
import "codit/internal/models" import "codit/internal/models"
const logIDSSHBroker string = "ssh-broker" const logIDSSHBroker string = "ssh-broker"
const sshWebsocketWriteTimeout time.Duration = 5 * time.Second
const sshSessionInputBufferSize int = 64
type sshServerRequest struct { type sshServerRequest struct {
Name string `json:"name"` Name string `json:"name"`
Host string `json:"host"` Host string `json:"host"`
@@ -137,6 +140,11 @@ type sshWorkspaceAttachment struct {
InputErrCh chan error InputErrCh chan error
} }
type sshWorkspaceAttachmentDone struct {
SessionID string
Attachment *sshWorkspaceAttachment
}
const sshAuthMethodManagedSSHCert string = "managed_ssh_cert" const sshAuthMethodManagedSSHCert string = "managed_ssh_cert"
const sshAuthMethodPromptedPassword string = "prompted_password" const sshAuthMethodPromptedPassword string = "prompted_password"
const sshAuthMethodStoredPassword string = "stored_password" const sshAuthMethodStoredPassword string = "stored_password"
@@ -2392,14 +2400,27 @@ func (api *API) serveSSHSessionStream(ws *websocket.Conn, store *db.Store, user
var sendMu sync.Mutex var sendMu sync.Mutex
var inputCh chan sshSessionStreamMessage var inputCh chan sshSessionStreamMessage
var inputErrCh chan error var inputErrCh chan error
var readerErrCh chan error
var prepared sshSessionPrepared var prepared sshSessionPrepared
var preparedOK bool var preparedOK bool
var preparedPtr *sshSessionPrepared var preparedPtr *sshSessionPrepared
defer ws.Close() defer ws.Close()
inputCh = make(chan sshSessionStreamMessage) inputCh = make(chan sshSessionStreamMessage, sshSessionInputBufferSize)
inputErrCh = make(chan error, 1) inputErrCh = make(chan error, 1)
go api.readSSHSessionWebsocket(ws, inputCh, inputErrCh) // read websocket and write to inputCh readerErrCh = make(chan error, 1)
go api.readSSHSessionWebsocket(ws, inputCh, readerErrCh) // read websocket and write to inputCh
go func() {
var err error
var reason string
err = <-readerErrCh
reason = sshSessionWebsocketCloseReason(err)
if api.SSHSessionRegistry != nil {
api.SSHSessionRegistry.RequestClose(sessionItem.ID, reason)
}
inputErrCh <- err
}()
prepared, preparedOK = api.SSHPreparedSessionStore.Take(sessionItem.ID) prepared, preparedOK = api.SSHPreparedSessionStore.Take(sessionItem.ID)
if !preparedOK { if !preparedOK {
prepared = sshSessionPrepared{} prepared = sshSessionPrepared{}
@@ -2410,8 +2431,22 @@ func (api *API) serveSSHSessionStream(ws *websocket.Conn, store *db.Store, user
// this reads the inputCh // this reads the inputCh
api.runSSHSessionStream(store, user, sessionItem, ws, inputCh, inputErrCh, api.runSSHSessionStream(store, user, sessionItem, ws, inputCh, inputErrCh,
preparedPtr, preparedPtr,
func(msg sshSessionStreamMessage) { api.sendSSHSessionWS(&sendMu, ws, msg) }, func(msg sshSessionStreamMessage) {
func(data []byte) { api.sendSSHSessionBinary(&sendMu, ws, data) }) var err error
err = api.sendSSHSessionWS(&sendMu, ws, msg)
if err != nil && api.SSHSessionRegistry != nil {
api.SSHSessionRegistry.RequestClose(sessionItem.ID, "websocket send failed")
}
},
func(data []byte) {
var err error
err = api.sendSSHSessionBinary(&sendMu, ws, data)
if err != nil && api.SSHSessionRegistry != nil {
api.SSHSessionRegistry.RequestClose(sessionItem.ID, "websocket send failed")
}
})
} }
func (api *API) serveSSHWorkspaceStream(ws *websocket.Conn, store *db.Store, user models.User, remoteAddr string) { func (api *API) serveSSHWorkspaceStream(ws *websocket.Conn, store *db.Store, user models.User, remoteAddr string) {
@@ -2426,10 +2461,15 @@ func (api *API) serveSSHWorkspaceStream(ws *websocket.Conn, store *db.Store, use
var preparedOK bool var preparedOK bool
var err error var err error
var ok bool var ok bool
var attachedID string
var attachmentDoneCh chan sshWorkspaceAttachmentDone
var attachmentDone sshWorkspaceAttachmentDone
var websocketCloseReason string
defer ws.Close() defer ws.Close()
controlCh = make(chan sshSessionStreamMessage) controlCh = make(chan sshSessionStreamMessage, sshSessionInputBufferSize)
controlErrCh = make(chan error, 1) controlErrCh = make(chan error, 1)
attachmentDoneCh = make(chan sshWorkspaceAttachmentDone, sshSessionInputBufferSize)
attachments = map[string]*sshWorkspaceAttachment{} attachments = map[string]*sshWorkspaceAttachment{}
go api.readSSHSessionWebsocket(ws, controlCh, controlErrCh) go api.readSSHSessionWebsocket(ws, controlCh, controlErrCh)
@@ -2437,20 +2477,27 @@ event_loop:
for { for {
select { select {
case err = <-controlErrCh: case err = <-controlErrCh:
websocketCloseReason = sshSessionWebsocketCloseReason(err)
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN, api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN,
"workspace stream closed requester=%s user=%s err=%v", "workspace stream closed requester=%s user=%s err=%v",
remoteAddr, remoteAddr,
user.Username, user.Username,
err) err)
// write the error message to the error notification channel of all existing attachments for attachedID, attachment = range attachments {
for _, attachment = range attachments { if api.SSHSessionRegistry != nil {
api.SSHSessionRegistry.RequestClose(attachedID, websocketCloseReason)
}
select { select {
case attachment.InputErrCh <- err: case attachment.InputErrCh <- err:
default: // make it non-blocking default: // make it non-blocking
} }
} }
break event_loop break event_loop
case attachmentDone = <-attachmentDoneCh:
if attachments[attachmentDone.SessionID] == attachmentDone.Attachment {
delete(attachments, attachmentDone.SessionID)
}
case msg = <-controlCh: case msg = <-controlCh:
if msg.Type == "attach" { if msg.Type == "attach" {
var attachedItem models.SSHSession var attachedItem models.SSHSession
@@ -2493,7 +2540,7 @@ event_loop:
continue continue
} }
attachment = &sshWorkspaceAttachment{ attachment = &sshWorkspaceAttachment{
InputCh: make(chan sshSessionStreamMessage, 16), InputCh: make(chan sshSessionStreamMessage, sshSessionInputBufferSize),
InputErrCh: make(chan error, 1), InputErrCh: make(chan error, 1),
} }
attachments[msg.SessionID] = attachment attachments[msg.SessionID] = attachment
@@ -2510,19 +2557,40 @@ event_loop:
attachedPrepared = &prepared attachedPrepared = &prepared
} }
} }
go api.runSSHSessionStream(store, user, attachedItem, nil, attachment.InputCh, attachment.InputErrCh, go func(runItem models.SSHSession, runPrepared *sshSessionPrepared, runSessionID string, runAttachment *sshWorkspaceAttachment) {
attachedPrepared, defer func() {
select {
case attachmentDoneCh <- sshWorkspaceAttachmentDone{SessionID: runSessionID, Attachment: runAttachment}:
default:
}
}()
api.runSSHSessionStream(store, user, runItem, nil, runAttachment.InputCh, runAttachment.InputErrCh,
runPrepared,
func(status sshSessionStreamMessage) { func(status sshSessionStreamMessage) {
status.SessionID = attachedSessionID var sendErr error
api.sendSSHSessionWS(&sendMu, ws, status)
status.SessionID = runSessionID
sendErr = api.sendSSHSessionWS(&sendMu, ws, status)
if sendErr != nil && api.SSHSessionRegistry != nil {
api.SSHSessionRegistry.RequestClose(runSessionID, "websocket send failed")
}
}, },
func(data []byte) { func(data []byte) {
api.sendSSHWorkspaceOutput(&sendMu, ws, attachedSessionID, data) var sendErr error
sendErr = api.sendSSHWorkspaceOutput(&sendMu, ws, runSessionID, data)
if sendErr != nil && api.SSHSessionRegistry != nil {
api.SSHSessionRegistry.RequestClose(runSessionID, "websocket send failed")
}
}) })
}(attachedItem, attachedPrepared, attachedSessionID, attachment)
continue continue
} }
attachment, ok = attachments[msg.SessionID] attachment, ok = attachments[msg.SessionID]
if !ok { if !ok {
if msg.Type == "input" || msg.Type == "resize" || msg.Type == "detach" {
continue
}
api.sendSSHSessionWS(&sendMu, ws, sshSessionStreamMessage{ api.sendSSHSessionWS(&sendMu, ws, sshSessionStreamMessage{
SessionID: msg.SessionID, SessionID: msg.SessionID,
Type: "status", Type: "status",
@@ -2532,6 +2600,9 @@ event_loop:
continue continue
} }
if msg.Type == "detach" { if msg.Type == "detach" {
if api.SSHSessionRegistry != nil {
api.SSHSessionRegistry.RequestClose(msg.SessionID, "detached")
}
select { select {
case attachment.InputErrCh <- io.EOF: case attachment.InputErrCh <- io.EOF:
default: default:
@@ -2541,7 +2612,23 @@ event_loop:
} }
/* other message types. write to the channel */ /* other message types. write to the channel */
attachment.InputCh <- msg select {
case attachment.InputCh <- msg:
default:
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN,
"workspace input queue full session=%s requester=%s user=%s type=%s",
msg.SessionID,
remoteAddr,
user.Username,
msg.Type)
if api.SSHSessionRegistry != nil {
api.SSHSessionRegistry.RequestClose(msg.SessionID, "websocket input queue full")
}
select {
case attachment.InputErrCh <- errors.New("websocket input queue full"):
default:
}
}
} }
} }
} }
@@ -2716,8 +2803,28 @@ func (api *API) runSSHSessionStream(store *db.Store, user models.User, sessionIt
pinnedHostKey.Algorithm) pinnedHostKey.Algorithm)
} }
runtimeSession.SetResources(transportWS, sshClient, nil, nil) runtimeSession.SetResources(transportWS, sshClient, nil, nil)
closeReason = runtimeSession.CloseReason()
if closeReason != "" {
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "closed", connectedFingerprint, 0, now, closeReason)
api.logSSHSessionClosed(sessionItem, profile, user, "closed", closeReason, 0, now)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "closed", Message: closeReason})
}
return
}
sshSession, err = sshClient.NewSession() sshSession, err = sshClient.NewSession()
if err != nil { if err != nil {
closeReason = runtimeSession.CloseReason()
if closeReason != "" {
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "closed", connectedFingerprint, 0, now, closeReason)
api.logSSHSessionClosed(sessionItem, profile, user, "closed", closeReason, 0, now)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "closed", Message: closeReason})
}
return
}
api.logSSHSessionConnectFailure(sessionItem, profile, user, "new_session", err) api.logSSHSessionConnectFailure(sessionItem, profile, user, "new_session", err)
if sendStatus != nil { if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()}) sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
@@ -2728,8 +2835,28 @@ func (api *API) runSSHSessionStream(store *db.Store, user models.User, sessionIt
} }
defer sshSession.Close() defer sshSession.Close()
runtimeSession.SetResources(transportWS, sshClient, sshSession, nil) runtimeSession.SetResources(transportWS, sshClient, sshSession, nil)
closeReason = runtimeSession.CloseReason()
if closeReason != "" {
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "closed", connectedFingerprint, 0, now, closeReason)
api.logSSHSessionClosed(sessionItem, profile, user, "closed", closeReason, 0, now)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "closed", Message: closeReason})
}
return
}
stdin, err = sshSession.StdinPipe() stdin, err = sshSession.StdinPipe()
if err != nil { if err != nil {
closeReason = runtimeSession.CloseReason()
if closeReason != "" {
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "closed", connectedFingerprint, 0, now, closeReason)
api.logSSHSessionClosed(sessionItem, profile, user, "closed", closeReason, 0, now)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "closed", Message: closeReason})
}
return
}
api.logSSHSessionConnectFailure(sessionItem, profile, user, "stdin_pipe", err) api.logSSHSessionConnectFailure(sessionItem, profile, user, "stdin_pipe", err)
if sendStatus != nil { if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()}) sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
@@ -2740,6 +2867,16 @@ func (api *API) runSSHSessionStream(store *db.Store, user models.User, sessionIt
} }
stdout, err = sshSession.StdoutPipe() stdout, err = sshSession.StdoutPipe()
if err != nil { if err != nil {
closeReason = runtimeSession.CloseReason()
if closeReason != "" {
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "closed", connectedFingerprint, 0, now, closeReason)
api.logSSHSessionClosed(sessionItem, profile, user, "closed", closeReason, 0, now)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "closed", Message: closeReason})
}
return
}
api.logSSHSessionConnectFailure(sessionItem, profile, user, "stdout_pipe", err) api.logSSHSessionConnectFailure(sessionItem, profile, user, "stdout_pipe", err)
if sendStatus != nil { if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()}) sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
@@ -2750,6 +2887,16 @@ func (api *API) runSSHSessionStream(store *db.Store, user models.User, sessionIt
} }
stderr, err = sshSession.StderrPipe() stderr, err = sshSession.StderrPipe()
if err != nil { if err != nil {
closeReason = runtimeSession.CloseReason()
if closeReason != "" {
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "closed", connectedFingerprint, 0, now, closeReason)
api.logSSHSessionClosed(sessionItem, profile, user, "closed", closeReason, 0, now)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "closed", Message: closeReason})
}
return
}
api.logSSHSessionConnectFailure(sessionItem, profile, user, "stderr_pipe", err) api.logSSHSessionConnectFailure(sessionItem, profile, user, "stderr_pipe", err)
if sendStatus != nil { if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()}) sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
@@ -2774,6 +2921,16 @@ func (api *API) runSSHSessionStream(store *db.Store, user models.User, sessionIt
ssh.TTY_OP_OSPEED: 14400, ssh.TTY_OP_OSPEED: 14400,
}) })
if err != nil { if err != nil {
closeReason = runtimeSession.CloseReason()
if closeReason != "" {
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "closed", connectedFingerprint, 0, now, closeReason)
api.logSSHSessionClosed(sessionItem, profile, user, "closed", closeReason, 0, now)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "closed", Message: closeReason})
}
return
}
api.logSSHSessionConnectFailure(sessionItem, profile, user, "request_pty", err) api.logSSHSessionConnectFailure(sessionItem, profile, user, "request_pty", err)
if sendStatus != nil { if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()}) sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
@@ -2782,8 +2939,28 @@ func (api *API) runSSHSessionStream(store *db.Store, user models.User, sessionIt
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "error", connectedFingerprint, 0, now, err.Error()) _ = store.UpdateSSHSessionStatus(sessionItem.ID, "error", connectedFingerprint, 0, now, err.Error())
return return
} }
closeReason = runtimeSession.CloseReason()
if closeReason != "" {
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "closed", connectedFingerprint, 0, now, closeReason)
api.logSSHSessionClosed(sessionItem, profile, user, "closed", closeReason, 0, now)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "closed", Message: closeReason})
}
return
}
err = sshSession.Shell() err = sshSession.Shell()
if err != nil { if err != nil {
closeReason = runtimeSession.CloseReason()
if closeReason != "" {
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "closed", connectedFingerprint, 0, now, closeReason)
api.logSSHSessionClosed(sessionItem, profile, user, "closed", closeReason, 0, now)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "closed", Message: closeReason})
}
return
}
api.logSSHSessionConnectFailure(sessionItem, profile, user, "shell", err) api.logSSHSessionConnectFailure(sessionItem, profile, user, "shell", err)
if sendStatus != nil { if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()}) sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
@@ -2792,6 +2969,16 @@ func (api *API) runSSHSessionStream(store *db.Store, user models.User, sessionIt
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "error", connectedFingerprint, 0, now, err.Error()) _ = store.UpdateSSHSessionStatus(sessionItem.ID, "error", connectedFingerprint, 0, now, err.Error())
return return
} }
closeReason = runtimeSession.CloseReason()
if closeReason != "" {
now = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "closed", connectedFingerprint, 0, now, closeReason)
api.logSSHSessionClosed(sessionItem, profile, user, "closed", closeReason, 0, now)
if sendStatus != nil {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "closed", Message: closeReason})
}
return
}
connectedAt = time.Now().UTC().Unix() connectedAt = time.Now().UTC().Unix()
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "connected", connectedFingerprint, connectedAt, 0, "") _ = store.UpdateSSHSessionStatus(sessionItem.ID, "connected", connectedFingerprint, connectedAt, 0, "")
api.logSSHSessionConnectSuccess(sessionItem, profile, user, connectedFingerprint) api.logSSHSessionConnectSuccess(sessionItem, profile, user, connectedFingerprint)
@@ -3137,28 +3324,66 @@ func (api *API) readSSHSessionWebsocket(ws *websocket.Conn, inputCh chan<- sshSe
errCh <- err errCh <- err
return return
} }
inputCh <- msg select {
case inputCh <- msg:
default:
errCh <- errors.New("websocket input queue full")
return
}
} }
} }
func (api *API) sendSSHSessionWS(mu *sync.Mutex, ws *websocket.Conn, msg sshSessionStreamMessage) { func sshSessionWebsocketCloseReason(err error) string {
if mu == nil { return } if err != nil && strings.Contains(err.Error(), "input queue full") {
return err.Error()
}
return "websocket closed"
}
func (api *API) sendSSHSessionWS(mu *sync.Mutex, ws *websocket.Conn, msg sshSessionStreamMessage) error {
var err error
if mu == nil || ws == nil { return nil }
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
_ = websocket.JSON.Send(ws, msg) _ = ws.SetWriteDeadline(time.Now().Add(sshWebsocketWriteTimeout))
err = websocket.JSON.Send(ws, msg)
_ = ws.SetWriteDeadline(time.Time{})
if err != nil {
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN,
"websocket json send failed session=%s type=%s err=%v",
msg.SessionID,
msg.Type,
err)
}
return err
} }
func (api *API) sendSSHSessionBinary(mu *sync.Mutex, ws *websocket.Conn, data []byte) { func (api *API) sendSSHSessionBinary(mu *sync.Mutex, ws *websocket.Conn, data []byte) error {
if mu == nil { return } var err error
if mu == nil || ws == nil { return nil }
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
_ = websocket.Message.Send(ws, data) _ = ws.SetWriteDeadline(time.Now().Add(sshWebsocketWriteTimeout))
err = websocket.Message.Send(ws, data)
_ = ws.SetWriteDeadline(time.Time{})
if err != nil {
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN,
"websocket binary send failed bytes=%d err=%v",
len(data),
err)
}
return err
} }
func (api *API) sendSSHWorkspaceOutput(mu *sync.Mutex, ws *websocket.Conn, sessionID string, data []byte) { func (api *API) sendSSHWorkspaceOutput(mu *sync.Mutex, ws *websocket.Conn, sessionID string, data []byte) error {
api.sendSSHSessionWS(mu, ws, sshSessionStreamMessage{ var err error
err = api.sendSSHSessionWS(mu, ws, sshSessionStreamMessage{
SessionID: sessionID, SessionID: sessionID,
Type: "output", Type: "output",
Data: base64.StdEncoding.EncodeToString(data), Data: base64.StdEncoding.EncodeToString(data),
}) })
return err
} }
+19 -3
View File
@@ -13,7 +13,7 @@ import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery' import useMediaQuery from '@mui/material/useMediaQuery'
import { useTheme } from '@mui/material/styles' import { useTheme } from '@mui/material/styles'
import MoreVertIcon from '@mui/icons-material/MoreVert' import MoreVertIcon from '@mui/icons-material/MoreVert'
import { PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { MutableRefObject, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom' import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { FitAddon, ITerminalDimensions } from '@xterm/addon-fit' import { FitAddon, ITerminalDimensions } from '@xterm/addon-fit'
import { Unicode11Addon } from '@xterm/addon-unicode11' import { Unicode11Addon } from '@xterm/addon-unicode11'
@@ -144,6 +144,10 @@ function downloadTextFile(filename: string, text: string): void {
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
} }
function isSSHSessionStreamActive(status: string): boolean {
return status !== 'closed' && status !== 'error'
}
function SessionTerminalPanel(props: SessionPanelProps) { function SessionTerminalPanel(props: SessionPanelProps) {
const { sessionID, layoutToken, registerStream, unregisterStream, sendStreamInput, sendStreamResize, onDuplicate, onClose, onReplace, onSummaryChange } = props const { sessionID, layoutToken, registerStream, unregisterStream, sendStreamInput, sendStreamResize, onDuplicate, onClose, onReplace, onSummaryChange } = props
const [session, setSession] = useState<SSHSession | null>(null) const [session, setSession] = useState<SSHSession | null>(null)
@@ -163,6 +167,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
const terminalRef = useRef<Terminal | null>(null) const terminalRef = useRef<Terminal | null>(null)
const fitAddonRef = useRef<FitAddon | null>(null) const fitAddonRef = useRef<FitAddon | null>(null)
const unicodeAddonRef = useRef<Unicode11Addon | null>(null) const unicodeAddonRef = useRef<Unicode11Addon | null>(null)
const streamActiveRef: MutableRefObject<boolean> = useRef<boolean>(true)
const sessionReady = Boolean(session) const sessionReady = Boolean(session)
const sessionTitle = useMemo(() => buildSessionTitle(session), [session]) const sessionTitle = useMemo(() => buildSessionTitle(session), [session])
const canReconnect = status === 'closed' || status === 'error' const canReconnect = status === 'closed' || status === 'error'
@@ -179,6 +184,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
const fitAddon = fitAddonRef.current const fitAddon = fitAddonRef.current
let dimensions: ITerminalDimensions | undefined let dimensions: ITerminalDimensions | undefined
if (!streamActiveRef.current) { return }
if (!fitAddon) { return } if (!fitAddon) { return }
fitAddon.fit() fitAddon.fit()
dimensions = fitAddon.proposeDimensions() dimensions = fitAddon.proposeDimensions()
@@ -195,6 +201,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
setSession(null) setSession(null)
setStatus('pending') setStatus('pending')
setError(null) setError(null)
streamActiveRef.current = true
const load = async () => { const load = async () => {
let item: SSHSession let item: SSHSession
@@ -209,6 +216,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
if (cancelled) { return } if (cancelled) { return }
setSession(item) setSession(item)
setStatus(item.status || 'pending') setStatus(item.status || 'pending')
streamActiveRef.current = isSSHSessionStreamActive(item.status || 'pending')
} catch (err) { } catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load SSH session') if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load SSH session')
} finally { } finally {
@@ -309,15 +317,21 @@ function SessionTerminalPanel(props: SessionPanelProps) {
fitAddonRef.current = fitAddon fitAddonRef.current = fitAddon
unicodeAddonRef.current = unicodeAddon unicodeAddonRef.current = unicodeAddon
term.onData((data: string) => { sendStreamInput(sessionID, data) }) term.onData((data: string) => {
if (!streamActiveRef.current) { return }
sendStreamInput(sessionID, data)
})
registerStream(sessionID, { registerStream(sessionID, {
onOutput: (data: Uint8Array<ArrayBufferLike>) => { onOutput: (data: Uint8Array<ArrayBufferLike>) => {
if (!disposed && term) term.write(data) if (!disposed && term) term.write(data)
}, },
onStatus: (message: SSHSessionStreamMessage) => { onStatus: (message: SSHSessionStreamMessage) => {
const nextStatus: string = message.status || 'unknown'
if (disposed || !term) return if (disposed || !term) return
setStatus(message.status || 'unknown') streamActiveRef.current = isSSHSessionStreamActive(nextStatus)
setStatus(nextStatus)
if (message.status === 'connected') { if (message.status === 'connected') {
setSession((prev) => (prev ? { ...prev, host_key_fingerprint: message.message || prev.host_key_fingerprint, status: 'connected' } : prev)) setSession((prev) => (prev ? { ...prev, host_key_fingerprint: message.message || prev.host_key_fingerprint, status: 'connected' } : prev))
scheduleResize() scheduleResize()
@@ -335,6 +349,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
}, },
onTransportClosed: (message: string) => { onTransportClosed: (message: string) => {
if (disposed) return if (disposed) return
streamActiveRef.current = false
setStatus((prev) => (prev === 'closed' || prev === 'error' ? prev : 'closed')) setStatus((prev) => (prev === 'closed' || prev === 'error' ? prev : 'closed'))
if (message) setError((prev) => prev || message) if (message) setError((prev) => prev || message)
} }
@@ -383,6 +398,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
setActionPending(true) setActionPending(true)
try { try {
await api.disconnectSSHSessionForSelf(sessionID) await api.disconnectSSHSessionForSelf(sessionID)
streamActiveRef.current = false
setStatus('closed') setStatus('closed')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to disconnect SSH session') setError(err instanceof Error ? err.message : 'Failed to disconnect SSH session')