Compare commits

..

2 Commits

Author SHA1 Message Date
hyung-hwan 4cee650a59 refactored the ssh-broker with prepare stage word constants
added more logs for host key pinnning
2026-06-11 20:02:43 +09:00
hyung-hwan 38bbd6354d removed the auto-reconnect in the ssh session page 2026-06-11 19:28:38 +09:00
3 changed files with 73 additions and 29 deletions
@@ -786,7 +786,7 @@ func (api *API) openSSHSessionFileTransferClient(store *db.Store, user models.Us
prepared, prepareStage, err = api.prepareSSHSession(store, &user, &sessionItem, promptedPassword, otpCode)
if err != nil {
if prepareStage == "load_profile" {
if prepareStage == sshPrepareStageLoadProfile {
return nil, errors.New("ssh access profile not found")
}
return nil, err
+67 -11
View File
@@ -24,6 +24,11 @@ import "codit/internal/models"
const logIDSSHBroker string = "ssh-broker"
const sshWebsocketWriteTimeout time.Duration = 5 * time.Second
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 {
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)
if err != nil {
if prepareStage == "host_key_missing" || prepareStage == "build_auth" {
if prepareStage == sshPrepareStageHostKeyMissing || prepareStage == sshPrepareStageBuildAuth {
WriteJSONWithErrorReason(w, r, http.StatusBadRequest, err.Error())
return
}
@@ -2509,12 +2514,12 @@ func (api *API) prepareSSHSession(store *db.Store, user *models.User, sessionIte
prepared.Session = *sessionItem
prepared.Profile, err = store.GetSSHAccessProfileForUser(user.ID, sessionItem.ProfileID)
if err != nil {
return prepared, "load_profile", err
return prepared, sshPrepareStageLoadProfile, err
}
if strings.TrimSpace(sessionItem.ServerID) != "" && sessionItem.ServerID != prepared.Profile.ServerID {
prepared.Profile.Server, err = store.GetSSHServer(sessionItem.ServerID)
if err != nil {
return prepared, "load_server", err
return prepared, sshPrepareStageLoadServer, err
}
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)
prepared.HostKeys, err = store.ListSSHServerHostKeys(sessionItem.ServerID)
if err != nil {
return prepared, "load_host_keys", err
return prepared, sshPrepareStageLoadHostKeys, err
}
if len(prepared.HostKeys) == 0 {
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.AuthMethods, err = api.buildSSHSessionAuth(store, prepared.Profile, promptedPassword, otpCode)
if err != nil {
return prepared, "build_auth", err
return prepared, sshPrepareStageBuildAuth, err
}
return prepared, "", nil
}
@@ -2888,6 +2893,7 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
var promptedAuthInput SSHPromptedAuthInput
var runtimeStartedAt time.Time
var pinHostKeyErr error
var pinHostKeyStartedAt time.Time
runtimeStartedAt = time.Now()
defer func() {
@@ -2943,7 +2949,7 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
prepared = &sshSessionPrepared{}
*prepared, prepareStage, err = api.prepareSSHSession(store, user, sessionItem, promptedPassword, promptedOTPCode)
if err != nil {
if prepareStage == "load_profile" {
if prepareStage == sshPrepareStageLoadProfile {
api.logSSHSessionPrepareFailure(sessionItem, user, prepareStage, err)
} else if prepared.Profile.ID != "" {
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)
}
if sendStatus != nil {
if prepareStage == "load_profile" {
if prepareStage == sshPrepareStageLoadProfile {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: "ssh access profile not found"})
} else {
sendStatus(sshSessionStreamMessage{Type: "status", Status: "error", Message: err.Error()})
}
}
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")
} else {
_ = 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))),
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)
if err != nil {
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{}
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)
if err != nil {
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 {
for i = 0; i < len(reloadedHostKeys); i++ {
@@ -3035,6 +3071,25 @@ func (api *API) runSSHSessionStream(store *db.Store, user *models.User, sessionI
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 == "" {
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,
"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,
profile.Server.ID,
profile.Server.Name,
pinnedHostKey.Fingerprint,
pinnedHostKey.Algorithm)
pinnedHostKey.Algorithm,
time.Since(pinHostKeyStartedAt).Milliseconds())
}
runtimeSession.SetResources(transportWS, sshClient, nil, nil)
closeReason = runtimeSession.CloseReason()
+5 -17
View File
@@ -15,7 +15,7 @@ import Typography from '@mui/material/Typography'
import useMediaQuery from '@mui/material/useMediaQuery'
import { useTheme } from '@mui/material/styles'
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 { MutableRefObject, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useBlocker, useLocation, useNavigate, useParams } from 'react-router-dom'
@@ -659,8 +659,8 @@ function SessionTerminalPanel(props: SessionPanelProps) {
gap: 0.75,
overflow: 'hidden',
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',
outlineOffset: '-3px',
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: '-2px',
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'}>
<span>
<IconButton size="small" onClick={() => onToggleSync(sessionID)} color={isSynced ? 'warning' : 'default'} disabled={!isSynced && status !== 'connected'}>
<SyncAltIcon fontSize="small" />
<KeyboardIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
@@ -862,8 +862,7 @@ export default function SSHSessionPage() {
const [streamWSCheck, setStreamWSCheck] = useState<number>(0)
const streamHandlersRef = useRef<Record<string, SessionStreamHandlers>>({})
const attachedSessionIDsRef = useRef<Record<string, boolean>>({})
const wsReconnectAttemptRef = useRef<number>(0)
const wsReconnectTimerRef = useRef<number>(0)
const stackedLayout = useMediaQuery(theme.breakpoints.down('lg'))
const historyDialogMode: boolean = useMediaQuery(theme.breakpoints.down('md'))
const rowCount = useMemo(() => {
@@ -1345,7 +1344,6 @@ export default function SSHSessionPage() {
streamWSRef.current = ws
ws.onopen = () => {
let i: number
wsReconnectAttemptRef.current = 0
attachedSessionIDsRef.current = {}
for (i = 0; i < openSessionIDs.length; i += 1) {
attachStreamSession(openSessionIDs[i])
@@ -1386,12 +1384,6 @@ export default function SSHSessionPage() {
handlers = streamHandlersRef.current[ids[i]]
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 = () => {
setPageError((prev) => prev || 'SSH workspace stream error')
@@ -1401,10 +1393,6 @@ export default function SSHSessionPage() {
useEffect(() => {
// close the workspace websocket upon unmount
return () => {
if (wsReconnectTimerRef.current) {
window.clearTimeout(wsReconnectTimerRef.current)
wsReconnectTimerRef.current = 0
}
if (streamWSRef.current) {
streamWSRef.current.close()
streamWSRef.current = null