Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cee650a59 | |||
| 38bbd6354d |
@@ -786,7 +786,7 @@ func (api *API) openSSHSessionFileTransferClient(store *db.Store, user models.Us
|
|||||||
|
|
||||||
prepared, prepareStage, err = api.prepareSSHSession(store, &user, &sessionItem, promptedPassword, otpCode)
|
prepared, prepareStage, err = api.prepareSSHSession(store, &user, &sessionItem, promptedPassword, otpCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if prepareStage == "load_profile" {
|
if prepareStage == sshPrepareStageLoadProfile {
|
||||||
return nil, errors.New("ssh access profile not found")
|
return nil, errors.New("ssh access profile not found")
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import "codit/internal/models"
|
|||||||
const logIDSSHBroker string = "ssh-broker"
|
const logIDSSHBroker string = "ssh-broker"
|
||||||
const sshWebsocketWriteTimeout time.Duration = 5 * time.Second
|
const sshWebsocketWriteTimeout time.Duration = 5 * time.Second
|
||||||
const sshSessionInputBufferSize int = 64
|
const sshSessionInputBufferSize int = 64
|
||||||
|
const sshPrepareStageLoadProfile string = "load_profile"
|
||||||
|
const sshPrepareStageLoadServer string = "load_server"
|
||||||
|
const sshPrepareStageLoadHostKeys string = "load_host_keys"
|
||||||
|
const sshPrepareStageHostKeyMissing string = "host_key_missing"
|
||||||
|
const sshPrepareStageBuildAuth string = "build_auth"
|
||||||
|
|
||||||
type sshServerRequest struct {
|
type sshServerRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -1769,7 +1774,7 @@ func (api *API) CreateSSHSessionForSelf(w http.ResponseWriter, r *http.Request,
|
|||||||
|
|
||||||
prepared, prepareStage, err = api.prepareSSHSession(api.store(r), &user, &item, req.Password, req.OTPCode)
|
prepared, prepareStage, err = api.prepareSSHSession(api.store(r), &user, &item, req.Password, req.OTPCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if prepareStage == "host_key_missing" || prepareStage == "build_auth" {
|
if prepareStage == sshPrepareStageHostKeyMissing || prepareStage == sshPrepareStageBuildAuth {
|
||||||
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
|
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -2509,12 +2514,12 @@ func (api *API) prepareSSHSession(store *db.Store, user *models.User, sessionIte
|
|||||||
prepared.Session = *sessionItem
|
prepared.Session = *sessionItem
|
||||||
prepared.Profile, err = store.GetSSHAccessProfileForUser(user.ID, sessionItem.ProfileID)
|
prepared.Profile, err = store.GetSSHAccessProfileForUser(user.ID, sessionItem.ProfileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return prepared, "load_profile", err
|
return prepared, sshPrepareStageLoadProfile, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(sessionItem.ServerID) != "" && sessionItem.ServerID != prepared.Profile.ServerID {
|
if strings.TrimSpace(sessionItem.ServerID) != "" && sessionItem.ServerID != prepared.Profile.ServerID {
|
||||||
prepared.Profile.Server, err = store.GetSSHServer(sessionItem.ServerID)
|
prepared.Profile.Server, err = store.GetSSHServer(sessionItem.ServerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return prepared, "load_server", err
|
return prepared, sshPrepareStageLoadServer, err
|
||||||
}
|
}
|
||||||
prepared.Profile.ServerID = prepared.Profile.Server.ID
|
prepared.Profile.ServerID = prepared.Profile.Server.ID
|
||||||
}
|
}
|
||||||
@@ -2522,18 +2527,18 @@ func (api *API) prepareSSHSession(store *db.Store, user *models.User, sessionIte
|
|||||||
api.logSSHSessionPrepare(sessionItem, user, &prepared.Profile)
|
api.logSSHSessionPrepare(sessionItem, user, &prepared.Profile)
|
||||||
prepared.HostKeys, err = store.ListSSHServerHostKeys(sessionItem.ServerID)
|
prepared.HostKeys, err = store.ListSSHServerHostKeys(sessionItem.ServerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return prepared, "load_host_keys", err
|
return prepared, sshPrepareStageLoadHostKeys, err
|
||||||
}
|
}
|
||||||
if len(prepared.HostKeys) == 0 {
|
if len(prepared.HostKeys) == 0 {
|
||||||
if normalizeSSHServerHostKeyPolicy(prepared.Profile.Server.HostKeyPolicy) != "trust_on_first_use" {
|
if normalizeSSHServerHostKeyPolicy(prepared.Profile.Server.HostKeyPolicy) != "trust_on_first_use" {
|
||||||
return prepared, "host_key_missing", errors.New("no pinned host key for " + prepared.Profile.Server.Name)
|
return prepared, sshPrepareStageHostKeyMissing, errors.New("no pinned host key for " + prepared.Profile.Server.Name)
|
||||||
}
|
}
|
||||||
prepared.PinHostKeyOnFirstUse = true
|
prepared.PinHostKeyOnFirstUse = true
|
||||||
}
|
}
|
||||||
|
|
||||||
prepared.AuthMethods, err = api.buildSSHSessionAuth(store, prepared.Profile, promptedPassword, otpCode)
|
prepared.AuthMethods, err = api.buildSSHSessionAuth(store, prepared.Profile, promptedPassword, otpCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return prepared, "build_auth", err
|
return prepared, sshPrepareStageBuildAuth, err
|
||||||
}
|
}
|
||||||
return prepared, "", nil
|
return prepared, "", nil
|
||||||
}
|
}
|
||||||
@@ -2888,6 +2893,7 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
|
|||||||
var promptedAuthInput SSHPromptedAuthInput
|
var promptedAuthInput SSHPromptedAuthInput
|
||||||
var runtimeStartedAt time.Time
|
var runtimeStartedAt time.Time
|
||||||
var pinHostKeyErr error
|
var pinHostKeyErr error
|
||||||
|
var pinHostKeyStartedAt time.Time
|
||||||
|
|
||||||
runtimeStartedAt = time.Now()
|
runtimeStartedAt = time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -2943,7 +2949,7 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
|
|||||||
prepared = &sshSessionPrepared{}
|
prepared = &sshSessionPrepared{}
|
||||||
*prepared, prepareStage, err = api.prepareSSHSession(store, user, sessionItem, promptedPassword, promptedOTPCode)
|
*prepared, prepareStage, err = api.prepareSSHSession(store, user, sessionItem, promptedPassword, promptedOTPCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if prepareStage == "load_profile" {
|
if prepareStage == sshPrepareStageLoadProfile {
|
||||||
api.logSSHSessionPrepareFailure(sessionItem, user, prepareStage, err)
|
api.logSSHSessionPrepareFailure(sessionItem, user, prepareStage, err)
|
||||||
} else if prepared.Profile.ID != "" {
|
} else if prepared.Profile.ID != "" {
|
||||||
api.logSSHSessionConnectFailure(sessionItem, &prepared.Profile, user, prepareStage, err)
|
api.logSSHSessionConnectFailure(sessionItem, &prepared.Profile, user, prepareStage, err)
|
||||||
@@ -2951,14 +2957,14 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
|
|||||||
api.logSSHSessionPrepareFailure(sessionItem, user, prepareStage, err)
|
api.logSSHSessionPrepareFailure(sessionItem, user, prepareStage, err)
|
||||||
}
|
}
|
||||||
if sendStatus != nil {
|
if sendStatus != nil {
|
||||||
if prepareStage == "load_profile" {
|
if prepareStage == sshPrepareStageLoadProfile {
|
||||||
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: "ssh access profile not found"})
|
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: "ssh access profile not found"})
|
||||||
} else {
|
} else {
|
||||||
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
|
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
now = time.Now().UTC().Unix()
|
now = time.Now().UTC().Unix()
|
||||||
if prepareStage == "load_profile" {
|
if prepareStage == sshPrepareStageLoadProfile {
|
||||||
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "error", sessionItem.HostKeyFingerprint, sessionItem.ConnectedAt, now, "ssh access profile not found")
|
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "error", sessionItem.HostKeyFingerprint, sessionItem.ConnectedAt, now, "ssh access profile not found")
|
||||||
} else {
|
} else {
|
||||||
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "error", sessionItem.HostKeyFingerprint, sessionItem.ConnectedAt, now, err.Error())
|
_ = store.UpdateSSHSessionStatus(sessionItem.ID, "error", sessionItem.HostKeyFingerprint, sessionItem.ConnectedAt, now, err.Error())
|
||||||
@@ -3020,13 +3026,43 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
|
|||||||
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(connectedHostKey))),
|
PublicKey: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(connectedHostKey))),
|
||||||
Fingerprint: connectedFingerprint,
|
Fingerprint: connectedFingerprint,
|
||||||
}
|
}
|
||||||
|
pinHostKeyStartedAt = time.Now()
|
||||||
|
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
|
||||||
|
"host key pin start session=%s server=%s server_name=%q fingerprint=%s algorithm=%s",
|
||||||
|
sessionItem.ID,
|
||||||
|
profile.Server.ID,
|
||||||
|
profile.Server.Name,
|
||||||
|
pinnedHostKey.Fingerprint,
|
||||||
|
pinnedHostKey.Algorithm)
|
||||||
pinnedHostKey, err = store.CreateSSHServerHostKey(pinnedHostKey)
|
pinnedHostKey, err = store.CreateSSHServerHostKey(pinnedHostKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pinHostKeyErr = err
|
pinHostKeyErr = err
|
||||||
|
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN,
|
||||||
|
"host key pin insert failed session=%s server=%s server_name=%q fingerprint=%s dur_ms=%d err=%s",
|
||||||
|
sessionItem.ID,
|
||||||
|
profile.Server.ID,
|
||||||
|
profile.Server.Name,
|
||||||
|
connectedFingerprint,
|
||||||
|
time.Since(pinHostKeyStartedAt).Milliseconds(),
|
||||||
|
err.Error())
|
||||||
pinnedHostKey = models.SSHServerHostKey{}
|
pinnedHostKey = models.SSHServerHostKey{}
|
||||||
|
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
|
||||||
|
"host key pin reload start session=%s server=%s server_name=%q fingerprint=%s",
|
||||||
|
sessionItem.ID,
|
||||||
|
profile.Server.ID,
|
||||||
|
profile.Server.Name,
|
||||||
|
connectedFingerprint)
|
||||||
reloadedHostKeys, err = store.ListSSHServerHostKeys(sessionItem.ServerID)
|
reloadedHostKeys, err = store.ListSSHServerHostKeys(sessionItem.ServerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pinHostKeyErr = fmt.Errorf("%v; reload host keys: %w", pinHostKeyErr, err)
|
pinHostKeyErr = fmt.Errorf("%v; reload host keys: %w", pinHostKeyErr, err)
|
||||||
|
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN,
|
||||||
|
"host key pin reload failed session=%s server=%s server_name=%q fingerprint=%s dur_ms=%d err=%s",
|
||||||
|
sessionItem.ID,
|
||||||
|
profile.Server.ID,
|
||||||
|
profile.Server.Name,
|
||||||
|
connectedFingerprint,
|
||||||
|
time.Since(pinHostKeyStartedAt).Milliseconds(),
|
||||||
|
err.Error())
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for i = 0; i < len(reloadedHostKeys); i++ {
|
for i = 0; i < len(reloadedHostKeys); i++ {
|
||||||
@@ -3035,6 +3071,25 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if pinnedHostKey.ID != "" {
|
||||||
|
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
|
||||||
|
"host key pin reload found existing session=%s server=%s server_name=%q host_key=%s fingerprint=%s dur_ms=%d",
|
||||||
|
sessionItem.ID,
|
||||||
|
profile.Server.ID,
|
||||||
|
profile.Server.Name,
|
||||||
|
pinnedHostKey.ID,
|
||||||
|
pinnedHostKey.Fingerprint,
|
||||||
|
time.Since(pinHostKeyStartedAt).Milliseconds())
|
||||||
|
} else {
|
||||||
|
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_WARN,
|
||||||
|
"host key pin reload found no match session=%s server=%s server_name=%q fingerprint=%s loaded=%d dur_ms=%d",
|
||||||
|
sessionItem.ID,
|
||||||
|
profile.Server.ID,
|
||||||
|
profile.Server.Name,
|
||||||
|
connectedFingerprint,
|
||||||
|
len(reloadedHostKeys),
|
||||||
|
time.Since(pinHostKeyStartedAt).Milliseconds())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if pinnedHostKey.ID == "" {
|
if pinnedHostKey.ID == "" {
|
||||||
api.logSSHSessionConnectFailure(sessionItem, &profile, user, "pin_host_key", pinHostKeyErr)
|
api.logSSHSessionConnectFailure(sessionItem, &profile, user, "pin_host_key", pinHostKeyErr)
|
||||||
@@ -3047,12 +3102,13 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
|
api.Logger.Write(logIDSSHBroker, codit_logger.LOG_INFO,
|
||||||
"host key pinned session=%s server=%s server_name=%q fingerprint=%s algorithm=%s",
|
"host key pinned session=%s server=%s server_name=%q fingerprint=%s algorithm=%s dur_ms=%d",
|
||||||
sessionItem.ID,
|
sessionItem.ID,
|
||||||
profile.Server.ID,
|
profile.Server.ID,
|
||||||
profile.Server.Name,
|
profile.Server.Name,
|
||||||
pinnedHostKey.Fingerprint,
|
pinnedHostKey.Fingerprint,
|
||||||
pinnedHostKey.Algorithm)
|
pinnedHostKey.Algorithm,
|
||||||
|
time.Since(pinHostKeyStartedAt).Milliseconds())
|
||||||
}
|
}
|
||||||
runtimeSession.SetResources(transportWS, sshClient, nil, nil)
|
runtimeSession.SetResources(transportWS, sshClient, nil, nil)
|
||||||
closeReason = runtimeSession.CloseReason()
|
closeReason = runtimeSession.CloseReason()
|
||||||
|
|||||||
@@ -15,7 +15,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 SyncAltIcon from '@mui/icons-material/SyncAlt'
|
import KeyboardIcon from '@mui/icons-material/Keyboard'
|
||||||
import Tooltip from '@mui/material/Tooltip'
|
import Tooltip from '@mui/material/Tooltip'
|
||||||
import { MutableRefObject, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { MutableRefObject, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useBlocker, useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { useBlocker, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
@@ -659,8 +659,8 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
|||||||
gap: 0.75,
|
gap: 0.75,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
outline: (openSessionIDs.length > 1 && isSynced) ? (theme) => `3px solid ${theme.palette.warning.main}` : (openSessionIDs.length > 1 && terminalFocused) ? (theme) => `3px solid ${theme.palette.primary.main}` : '3px solid transparent',
|
outline: (openSessionIDs.length > 1 && isSynced) ? (theme) => `2px solid ${theme.palette.warning.main}` : (openSessionIDs.length > 1 && terminalFocused) ? (theme) => `2px solid ${theme.palette.primary.main}` : '2px solid transparent',
|
||||||
outlineOffset: '-3px',
|
outlineOffset: '-2px',
|
||||||
transition: 'outline-color 0.15s ease'
|
transition: 'outline-color 0.15s ease'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -697,7 +697,7 @@ function SessionTerminalPanel(props: SessionPanelProps) {
|
|||||||
<Tooltip title={isSynced ? 'Leave sync group' : 'Join sync group — share keystrokes with other synced terminals'}>
|
<Tooltip title={isSynced ? 'Leave sync group' : 'Join sync group — share keystrokes with other synced terminals'}>
|
||||||
<span>
|
<span>
|
||||||
<IconButton size="small" onClick={() => onToggleSync(sessionID)} color={isSynced ? 'warning' : 'default'} disabled={!isSynced && status !== 'connected'}>
|
<IconButton size="small" onClick={() => onToggleSync(sessionID)} color={isSynced ? 'warning' : 'default'} disabled={!isSynced && status !== 'connected'}>
|
||||||
<SyncAltIcon fontSize="small" />
|
<KeyboardIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -862,8 +862,7 @@ export default function SSHSessionPage() {
|
|||||||
const [streamWSCheck, setStreamWSCheck] = useState<number>(0)
|
const [streamWSCheck, setStreamWSCheck] = useState<number>(0)
|
||||||
const streamHandlersRef = useRef<Record<string, SessionStreamHandlers>>({})
|
const streamHandlersRef = useRef<Record<string, SessionStreamHandlers>>({})
|
||||||
const attachedSessionIDsRef = useRef<Record<string, boolean>>({})
|
const attachedSessionIDsRef = useRef<Record<string, boolean>>({})
|
||||||
const wsReconnectAttemptRef = useRef<number>(0)
|
|
||||||
const wsReconnectTimerRef = useRef<number>(0)
|
|
||||||
const stackedLayout = useMediaQuery(theme.breakpoints.down('lg'))
|
const stackedLayout = useMediaQuery(theme.breakpoints.down('lg'))
|
||||||
const historyDialogMode: boolean = useMediaQuery(theme.breakpoints.down('md'))
|
const historyDialogMode: boolean = useMediaQuery(theme.breakpoints.down('md'))
|
||||||
const rowCount = useMemo(() => {
|
const rowCount = useMemo(() => {
|
||||||
@@ -1345,7 +1344,6 @@ export default function SSHSessionPage() {
|
|||||||
streamWSRef.current = ws
|
streamWSRef.current = ws
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
let i: number
|
let i: number
|
||||||
wsReconnectAttemptRef.current = 0
|
|
||||||
attachedSessionIDsRef.current = {}
|
attachedSessionIDsRef.current = {}
|
||||||
for (i = 0; i < openSessionIDs.length; i += 1) {
|
for (i = 0; i < openSessionIDs.length; i += 1) {
|
||||||
attachStreamSession(openSessionIDs[i])
|
attachStreamSession(openSessionIDs[i])
|
||||||
@@ -1386,12 +1384,6 @@ export default function SSHSessionPage() {
|
|||||||
handlers = streamHandlersRef.current[ids[i]]
|
handlers = streamHandlersRef.current[ids[i]]
|
||||||
if (handlers) handlers.onTransportClosed('SSH workspace stream closed')
|
if (handlers) handlers.onTransportClosed('SSH workspace stream closed')
|
||||||
}
|
}
|
||||||
// schedule reconnect with exponential backoff; immediate on first attempt
|
|
||||||
const delay: number = Math.min(Math.pow(2, wsReconnectAttemptRef.current + 1) * 1000, 30000)
|
|
||||||
wsReconnectAttemptRef.current += 1
|
|
||||||
wsReconnectTimerRef.current = window.setTimeout(() => {
|
|
||||||
setStreamWSCheck((prev) => prev + 1)
|
|
||||||
}, delay)
|
|
||||||
}
|
}
|
||||||
ws.onerror = () => {
|
ws.onerror = () => {
|
||||||
setPageError((prev) => prev || 'SSH workspace stream error')
|
setPageError((prev) => prev || 'SSH workspace stream error')
|
||||||
@@ -1401,10 +1393,6 @@ export default function SSHSessionPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// close the workspace websocket upon unmount
|
// close the workspace websocket upon unmount
|
||||||
return () => {
|
return () => {
|
||||||
if (wsReconnectTimerRef.current) {
|
|
||||||
window.clearTimeout(wsReconnectTimerRef.current)
|
|
||||||
wsReconnectTimerRef.current = 0
|
|
||||||
}
|
|
||||||
if (streamWSRef.current) {
|
if (streamWSRef.current) {
|
||||||
streamWSRef.current.close()
|
streamWSRef.current.close()
|
||||||
streamWSRef.current = null
|
streamWSRef.current = null
|
||||||
|
|||||||
Reference in New Issue
Block a user