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) 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
+67 -11
View File
@@ -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()
+5 -17
View File
@@ -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