// Package sessions orchestrates multiple simultaneous SSH, Telnet, RAW TCP, // and GitHub sessions. It coordinates between protocol clients, the storage // layer, the secret store, and the Wails runtime for event emission. package sessions import ( "context" "encoding/base64" "fmt" "io" "net" "os" "strings" "sync" "time" "github.com/google/uuid" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" "golang.org/x/crypto/ssh" "github.com/andydixon/nexus/internal/audit" "github.com/andydixon/nexus/internal/githubclient" "github.com/andydixon/nexus/internal/localshell" "github.com/andydixon/nexus/internal/models" "github.com/andydixon/nexus/internal/rawclient" "github.com/andydixon/nexus/internal/recording" "github.com/andydixon/nexus/internal/secrets" "github.com/andydixon/nexus/internal/sftpclient" "github.com/andydixon/nexus/internal/sshclient" "github.com/andydixon/nexus/internal/storage" "github.com/andydixon/nexus/internal/telnetclient" "github.com/andydixon/nexus/internal/vncclient" ) // Session wraps a protocol client with metadata and state. type Session struct { Info models.SessionInfo sshClient *sshclient.Client telnetClient *telnetclient.Client rawClient *rawclient.Client githubClient *githubclient.Client githubCancel context.CancelFunc // stops the auto-refresh goroutine vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions localClient *localshell.Client // local shell PTY, nil for non-local sessions sftpClient *sftpclient.Client // SFTP subsystem, nil until requested profile *models.Profile recorder *recording.Recorder // active recording, nil if not recording reconnectCount int // number of reconnection attempts so far cancelReconn context.CancelFunc // cancels an in-progress reconnect backoff // Login script support — temporary buffer for expect-style matching. loginBuf []byte loginBufMu sync.Mutex loginDone chan struct{} // closed when login script completes or is abandoned } // Manager handles the lifecycle of all active sessions. // It coordinates between protocol clients, the storage layer, // the secret store, and the Wails runtime for event emission. type Manager struct { mu sync.RWMutex sessions map[string]*Session store *storage.Store secretStore *secrets.SecretStore keychainStore *secrets.KeychainStore auditLog *audit.Logger recordStore *recording.Store ctx context.Context // Wails app context for event emission hostKeyMu sync.Mutex hostKeyChans map[string]chan bool hostKeyListenerRegistered bool } // NewManager creates a Manager wired to the given backing services. func NewManager(store *storage.Store, secretStore *secrets.SecretStore, keychainStore *secrets.KeychainStore, auditLog *audit.Logger, recordStore *recording.Store) *Manager { return &Manager{ sessions: make(map[string]*Session), store: store, secretStore: secretStore, keychainStore: keychainStore, auditLog: auditLog, recordStore: recordStore, } } // SetContext stores the Wails runtime context for event emission. func (m *Manager) SetContext(ctx context.Context) { m.ctx = ctx m.registerHostKeyResponseListener() } // Connect creates a new session from a profile and connects it. // The getSecret callback is used to prompt the user for credentials when // required. Returns the session ID on success. func (m *Manager) Connect(profile *models.Profile, getSecret func(prompt string) (string, error)) (string, error) { sessionID := uuid.New().String() now := time.Now() info := models.SessionInfo{ ID: sessionID, ProfileID: profile.ID, ProfileName: profile.DisplayName, Protocol: profile.Protocol, Host: profile.Host, Port: profile.Port, State: models.SessionConnecting, TabName: m.createTabName(profile), ConnectedAt: now, } sess := &Session{ Info: info, profile: profile, } m.mu.Lock() m.sessions[sessionID] = sess m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionConnecting), }) // Perform the actual protocol handshake in a goroutine so we do not // block the Wails event loop — secret prompts require the UI thread. go func() { var err error switch profile.Protocol { case models.ProtocolSSH: err = m.connectSSH(sess, getSecret) case models.ProtocolTelnet: err = m.connectTelnet(sess) case models.ProtocolRAW: err = m.connectRAW(sess) case models.ProtocolGitHub: err = m.connectGitHub(sess, getSecret) case models.ProtocolVNC: err = m.connectVNC(sess, getSecret) default: err = fmt.Errorf("unsupported protocol %q", profile.Protocol) } if err != nil { m.mu.Lock() sess.Info.State = models.SessionError sess.Info.Error = err.Error() m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionError), "error": err.Error(), }) return } m.mu.Lock() sess.Info.State = models.SessionConnected sess.Info.ConnectedAt = time.Now() m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionConnected), }) m.auditLog.LogConnect(profile.ID, sessionID, string(profile.Protocol), profile.Host) }() return sessionID, nil } // ConnectLocal creates a local shell session without a saved profile. // The shell path is resolved from the given parameter or falls back to the system default. func (m *Manager) ConnectLocal(shell string) (string, error) { sessionID := uuid.New().String() now := time.Now() info := models.SessionInfo{ ID: sessionID, ProfileID: "", ProfileName: "Local Machine", Protocol: models.ProtocolLocal, Host: "localhost", Port: 0, State: models.SessionConnecting, TabName: "Local", ConnectedAt: now, } sess := &Session{ Info: info, } m.mu.Lock() m.sessions[sessionID] = sess m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionConnecting), }) go func() { client := localshell.NewClient(localshell.Config{ Shell: shell, Cols: 80, Rows: 24, OnData: func(data []byte) { m.emitEvent("session:data", map[string]interface{}{ "sessionID": sessionID, "data": base64.StdEncoding.EncodeToString(data), }) // Write to active recording if present. m.mu.RLock() rec := sess.recorder m.mu.RUnlock() if rec != nil { rec.WriteOutput(data) } }, OnError: func(err error) { m.mu.Lock() sess.Info.State = models.SessionError sess.Info.Error = err.Error() m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionError), "error": err.Error(), }) }, OnClose: func() { m.handleSessionClosed(sessionID) }, }) if err := client.Start(); err != nil { m.mu.Lock() sess.Info.State = models.SessionError sess.Info.Error = err.Error() m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionError), "error": err.Error(), }) return } sess.localClient = client m.mu.Lock() sess.Info.State = models.SessionConnected sess.Info.ConnectedAt = time.Now() m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionConnected), }) m.auditLog.LogConnect("", sessionID, "local", "localhost") }() return sessionID, nil } // Disconnect closes a specific session. func (m *Manager) Disconnect(sessionID string) error { m.mu.Lock() sess, ok := m.sessions[sessionID] if !ok { m.mu.Unlock() return fmt.Errorf("sessions: session %q not found", sessionID) } m.mu.Unlock() m.closeSession(sess) return nil } // Reconnect closes and re-establishes a session using its original profile. func (m *Manager) Reconnect(sessionID string, getSecret func(prompt string) (string, error)) error { m.mu.RLock() sess, ok := m.sessions[sessionID] if !ok { m.mu.RUnlock() return fmt.Errorf("sessions: session %q not found", sessionID) } profile := sess.profile tabName := sess.Info.TabName pinned := sess.Info.Pinned m.mu.RUnlock() // Close the existing session. m.closeSession(sess) // Remove the old entry. m.mu.Lock() delete(m.sessions, sessionID) m.mu.Unlock() // Reconnect with the same profile. newID, err := m.Connect(profile, getSecret) if err != nil { return err } // Preserve tab name and pin state. m.mu.Lock() if newSess, ok := m.sessions[newID]; ok { newSess.Info.TabName = tabName newSess.Info.Pinned = pinned } m.mu.Unlock() return nil } // WriteToSession sends data to a session's input. func (m *Manager) WriteToSession(sessionID string, data []byte) error { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } // Record input if recording is active. m.mu.RLock() rec := sess.recorder m.mu.RUnlock() if rec != nil { rec.WriteInput(data) } switch { case sess.sshClient != nil: return sess.sshClient.Write(data) case sess.telnetClient != nil: return sess.telnetClient.Write(data) case sess.rawClient != nil: return sess.rawClient.Write(data) case sess.localClient != nil: return sess.localClient.Write(data) } return fmt.Errorf("sessions: no active client for session %q", sessionID) } // ResizeSession sends a terminal resize to a session. func (m *Manager) ResizeSession(sessionID string, cols, rows int) error { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } switch { case sess.sshClient != nil: return sess.sshClient.Resize(cols, rows) case sess.telnetClient != nil: return sess.telnetClient.Resize(cols, rows) case sess.localClient != nil: return sess.localClient.Resize(uint16(cols), uint16(rows)) } // RAW sessions have no concept of terminal size. return nil } // GetSessionCwd returns a best-effort working directory for SSH sessions. func (m *Manager) GetSessionCwd(sessionID string) (string, error) { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return "", fmt.Errorf("sessions: session %q not found", sessionID) } if sess.sshClient == nil { return "", fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) } cwd, err := sess.sshClient.RunCommand("pwd") if err != nil { return "", err } return strings.TrimSpace(cwd), nil } // GetSession returns session info by ID. func (m *Manager) GetSession(sessionID string) (*models.SessionInfo, error) { m.mu.RLock() defer m.mu.RUnlock() sess, ok := m.sessions[sessionID] if !ok { return nil, fmt.Errorf("sessions: session %q not found", sessionID) } info := sess.Info return &info, nil } // GetSessionInfo returns session metadata or nil if the session does not exist. func (m *Manager) GetSessionInfo(sessionID string) *models.SessionInfo { m.mu.RLock() defer m.mu.RUnlock() sess, ok := m.sessions[sessionID] if !ok { return nil } info := sess.Info return &info } // ListSessions returns all active session info. func (m *Manager) ListSessions() []models.SessionInfo { m.mu.RLock() defer m.mu.RUnlock() result := make([]models.SessionInfo, 0, len(m.sessions)) for _, sess := range m.sessions { result = append(result, sess.Info) } return result } // RenameSession changes a session's tab name. func (m *Manager) RenameSession(sessionID string, name string) error { m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } sess.Info.TabName = name return nil } // PinSession toggles pin status. func (m *Manager) PinSession(sessionID string, pinned bool) error { m.mu.Lock() defer m.mu.Unlock() sess, ok := m.sessions[sessionID] if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } sess.Info.Pinned = pinned return nil } // CloseAll closes every active session. func (m *Manager) CloseAll() { m.mu.RLock() sessions := make([]*Session, 0, len(m.sessions)) for _, sess := range m.sessions { sessions = append(sessions, sess) } m.mu.RUnlock() for _, sess := range sessions { m.closeSession(sess) } m.mu.Lock() m.sessions = make(map[string]*Session) m.mu.Unlock() } // GetSessionHistory returns the RAW session history (RAW protocol only). func (m *Manager) GetSessionHistory(sessionID string) ([]rawclient.HistoryEntry, error) { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return nil, fmt.Errorf("sessions: session %q not found", sessionID) } if sess.rawClient == nil { return nil, fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) } return sess.rawClient.GetHistory(), nil } // ClearSessionHistory clears RAW session history. func (m *Manager) ClearSessionHistory(sessionID string) error { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } if sess.rawClient == nil { return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) } sess.rawClient.ClearHistory() return nil } // SaveSessionCapture saves RAW session capture to file. func (m *Manager) SaveSessionCapture(sessionID string, path string) error { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } if sess.rawClient == nil { return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) } return sess.rawClient.SaveCapture(path) } // SendBreak sends a Telnet break signal (Telnet only). func (m *Manager) SendBreak(sessionID string) error { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } if sess.telnetClient == nil { return fmt.Errorf("sessions: session %q is not a Telnet connection", sessionID) } return sess.telnetClient.SendBreak() } // WriteSessionText sends text with line ending via the RAW client. func (m *Manager) WriteSessionText(sessionID string, text string) error { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } if sess.rawClient == nil { return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) } return sess.rawClient.WriteText(text) } // WriteSessionHex sends hex-encoded data via the RAW client. func (m *Manager) WriteSessionHex(sessionID string, hexStr string) error { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } if sess.rawClient == nil { return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) } return sess.rawClient.WriteHex(hexStr) } // WriteSessionEscaped sends text with C-style escapes via the RAW client. func (m *Manager) WriteSessionEscaped(sessionID string, text string) error { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } if sess.rawClient == nil { return fmt.Errorf("sessions: session %q is not a RAW connection", sessionID) } return sess.rawClient.WriteEscaped(text) } // --------------------------------------------------------------------------- // Internal: protocol-specific connection logic // --------------------------------------------------------------------------- // connectSSH creates an SSH client, sets callbacks, and connects. func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (string, error)) error { profile := sess.profile client := sshclient.NewClient(profile) client.SetCallbacks( // onData — forward terminal output to the frontend. func(data []byte) { m.emitEvent("session:data", map[string]interface{}{ "sessionID": sess.Info.ID, "data": base64.StdEncoding.EncodeToString(data), }) // Write to active recording if present. m.mu.RLock() rec := sess.recorder m.mu.RUnlock() if rec != nil { rec.WriteOutput(data) } // Feed data to the login script buffer if active. sess.loginBufMu.Lock() if sess.loginDone != nil { select { case <-sess.loginDone: // Login script finished — stop buffering. default: sess.loginBuf = append(sess.loginBuf, data...) if len(sess.loginBuf) > 16384 { sess.loginBuf = sess.loginBuf[len(sess.loginBuf)-16384:] } } } sess.loginBufMu.Unlock() }, // onError — report connection errors to the frontend. func(err error) { m.mu.Lock() sess.Info.State = models.SessionError sess.Info.Error = err.Error() m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sess.Info.ID, "state": string(models.SessionError), "error": err.Error(), }) }, // onClose — handle remote session closure. func() { m.handleSessionClosed(sess.Info.ID) }, ) // Build credential callbacks that respect the profile's secret mode. sshConf := profile.SSH getPassword := m.buildSecretCallback(profile.ID, "password", sshConf.SecretMode, getSecret) getPassphrase := m.buildSecretCallback(profile.ID, "passphrase", sshConf.KeySecretMode, getSecret) // Resolve TOTP secret from vault if not already inline. if sshConf.TOTPSecret == "" { totpKey := fmt.Sprintf("profile:%s:totp", profile.ID) if m.keychainStore != nil && m.keychainStore.Available() { if val, err := m.keychainStore.GetSecret(totpKey); err == nil && val != "" { sshConf.TOTPSecret = val } } if sshConf.TOTPSecret == "" && m.secretStore != nil && m.secretStore.IsUnlocked() { if val, err := m.secretStore.GetSecret(totpKey); err == nil && val != "" { sshConf.TOTPSecret = val } } } if err := client.ConnectWithHostKeyCallback(m.hostKeyCallback(sess.Info.ID, sshConf.HostKeyPolicy), getPassword, getPassphrase); err != nil { return err } sess.sshClient = client // Start any port-forwarding rules configured in the profile. if sshConf != nil && len(sshConf.PortForwards) > 0 { fwdMgr := client.Forwards() if fwdMgr != nil { for _, pf := range sshConf.PortForwards { if !pf.Enabled { continue } if err := fwdMgr.StartForward(pf); err != nil { // Non-fatal — log and notify but don't abort the session. m.emitEvent("session:forward:error", map[string]interface{}{ "sessionID": sess.Info.ID, "forwardID": pf.ID, "label": pf.Label, "error": err.Error(), }) } else { m.emitEvent("session:forward:started", map[string]interface{}{ "sessionID": sess.Info.ID, "forward": pf, }) } } } } // Execute login scripts if configured. if sshConf != nil && len(sshConf.LoginScripts) > 0 { go m.executeLoginScripts(sess, sshConf.LoginScripts) } return nil } // connectTelnet creates a Telnet client, sets callbacks, and connects. func (m *Manager) connectTelnet(sess *Session) error { profile := sess.profile client := telnetclient.NewClient(profile) client.SetCallbacks( // onData func(data []byte) { m.emitEvent("session:data", map[string]interface{}{ "sessionID": sess.Info.ID, "data": base64.StdEncoding.EncodeToString(data), }) // Write to active recording if present. m.mu.RLock() rec := sess.recorder m.mu.RUnlock() if rec != nil { rec.WriteOutput(data) } // Feed data to the login script buffer if active. sess.loginBufMu.Lock() if sess.loginDone != nil { select { case <-sess.loginDone: default: sess.loginBuf = append(sess.loginBuf, data...) if len(sess.loginBuf) > 16384 { sess.loginBuf = sess.loginBuf[len(sess.loginBuf)-16384:] } } } sess.loginBufMu.Unlock() }, // onError func(err error) { m.mu.Lock() sess.Info.State = models.SessionError sess.Info.Error = err.Error() m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sess.Info.ID, "state": string(models.SessionError), "error": err.Error(), }) }, // onClose func() { m.handleSessionClosed(sess.Info.ID) }, // onDebug func(msg string) { m.emitEvent("session:debug", map[string]interface{}{ "sessionID": sess.Info.ID, "message": msg, }) }, ) if err := client.Connect(); err != nil { return err } sess.telnetClient = client // Execute login scripts if configured. telConf := profile.Telnet if telConf != nil && len(telConf.LoginScripts) > 0 { go m.executeLoginScripts(sess, telConf.LoginScripts) } return nil } // connectRAW creates a RAW TCP client, sets callbacks, and connects. func (m *Manager) connectRAW(sess *Session) error { profile := sess.profile client := rawclient.NewClient(profile) client.SetCallbacks( // onData — includes direction and timestamp for RAW protocol. func(data []byte, direction string, timestamp time.Time) { m.emitEvent("session:raw:data", map[string]interface{}{ "sessionID": sess.Info.ID, "data": base64.StdEncoding.EncodeToString(data), "direction": direction, "timestamp": timestamp.UnixMilli(), }) }, // onError func(err error) { m.mu.Lock() sess.Info.State = models.SessionError sess.Info.Error = err.Error() m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sess.Info.ID, "state": string(models.SessionError), "error": err.Error(), }) }, // onClose func() { m.handleSessionClosed(sess.Info.ID) }, ) if err := client.Connect(); err != nil { return err } sess.rawClient = client return nil } // connectGitHub validates the token and performs the initial repository fetch. // It also starts a background goroutine for periodic refresh. func (m *Manager) connectGitHub(sess *Session, getSecret func(prompt string) (string, error)) error { profile := sess.profile ghConf := profile.GitHub if ghConf == nil { return fmt.Errorf("GitHub configuration missing on profile %q", profile.ID) } // Resolve the access token using the standard secret flow. getToken := m.buildSecretCallback(profile.ID, "github-token", ghConf.SecretMode, getSecret) token, err := getToken() if err != nil { return fmt.Errorf("obtaining GitHub token: %w", err) } if token == "" { return fmt.Errorf("GitHub token cannot be empty") } client := githubclient.New(ghConf.BaseURL, token) // Validate the token. login, err := client.ValidateToken(context.Background()) if err != nil { if m.isGitHubAuthError(err) && getSecret != nil { retryToken, retryErr := m.promptAndStoreSecret( profile.ID, "github-token", ghConf.SecretMode, getSecret, "GitHub token did not work. Please enter a valid token:", ) if retryErr == nil && retryToken != "" { client = githubclient.New(ghConf.BaseURL, retryToken) login, err = client.ValidateToken(context.Background()) } } if err != nil { return fmt.Errorf("GitHub authentication failed: %w", err) } } sess.githubClient = client // Perform the initial fetch. m.emitEvent("session:github:status", map[string]interface{}{ "sessionID": sess.Info.ID, "status": "Loading repositories...", }) repos, err := client.FetchReposWithProgress(context.Background(), func(done int, total int) { if total <= 0 { return } m.emitEvent("session:github:status", map[string]interface{}{ "sessionID": sess.Info.ID, "status": fmt.Sprintf("Loading commit info (%d/%d)", done, total), }) }) if err != nil { return fmt.Errorf("fetching repositories: %w", err) } // Emit the initial repo list. m.emitEvent("session:github:repos", map[string]interface{}{ "sessionID": sess.Info.ID, "login": login, "repos": repos, }) // Start the periodic refresh goroutine. interval := time.Duration(ghConf.RefreshIntervalSecs) * time.Second if interval < 60*time.Second { interval = 5 * time.Minute } ctx, cancel := context.WithCancel(context.Background()) sess.githubCancel = cancel go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: m.emitEvent("session:github:status", map[string]interface{}{ "sessionID": sess.Info.ID, "status": "Refreshing repositories...", }) updated, fetchErr := client.FetchReposWithProgress(ctx, func(done int, total int) { if total <= 0 { return } m.emitEvent("session:github:status", map[string]interface{}{ "sessionID": sess.Info.ID, "status": fmt.Sprintf("Loading commit info (%d/%d)", done, total), }) }) if fetchErr != nil { m.emitEvent("session:github:error", map[string]interface{}{ "sessionID": sess.Info.ID, "error": fetchErr.Error(), }) continue } m.emitEvent("session:github:repos", map[string]interface{}{ "sessionID": sess.Info.ID, "login": login, "repos": updated, }) } } }() return nil } // FetchGitHubRepos triggers an on-demand refresh of GitHub repos for a session. func (m *Manager) FetchGitHubRepos(sessionID string) ([]models.GitHubRepo, error) { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return nil, fmt.Errorf("sessions: session %q not found", sessionID) } if sess.githubClient == nil { return nil, fmt.Errorf("sessions: session %q is not a GitHub connection", sessionID) } repos, err := sess.githubClient.FetchRepos(context.Background()) if err != nil { return nil, fmt.Errorf("fetching repos: %w", err) } m.emitEvent("session:github:repos", map[string]interface{}{ "sessionID": sessionID, "repos": repos, }) return repos, nil } // DownloadGitHubArchive downloads a repository zip archive for a GitHub session. func (m *Manager) DownloadGitHubArchive(sessionID string, fullName string, ref string, destPath string) error { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } if sess.githubClient == nil { return fmt.Errorf("sessions: session %q is not a GitHub connection", sessionID) } if ref == "" { ref = "HEAD" } if err := sess.githubClient.DownloadRepoArchive(context.Background(), fullName, ref, destPath); err != nil { return fmt.Errorf("downloading archive: %w", err) } return nil } // connectVNC establishes a VNC connection (with optional SSH tunnel) and // emits the local WebSocket proxy URL to the frontend. func (m *Manager) connectVNC(sess *Session, getSecret func(prompt string) (string, error)) error { profile := sess.profile vncConf := profile.VNC if vncConf == nil { return fmt.Errorf("VNC configuration missing") } // Fall back to the profile-level host if VNC-specific host is empty. vncHost := vncConf.VNCHost if vncHost == "" { vncHost = profile.Host } cfg := vncclient.Config{ VNCHost: vncHost, VNCPort: vncConf.VNCPort, OnError: func(err error) { m.emitEvent("session:state", map[string]interface{}{ "sessionID": sess.Info.ID, "state": string(models.SessionError), "error": err.Error(), }) }, } // SSH tunnel configuration if vncConf.SSHTunnel { cfg.SSHTunnel = true cfg.SSHHost = vncConf.SSHHost cfg.SSHPort = vncConf.SSHPort cfg.SSHUsername = vncConf.SSHUsername cfg.SSHAuthMethod = vncConf.SSHAuthMethod cfg.SSHAgentAuth = vncConf.SSHAgentAuth cfg.SSHHostKeyCallback = m.hostKeyCallback(sess.Info.ID, models.HostKeyAsk) // Resolve SSH credentials switch vncConf.SSHAuthMethod { case "password": getPass := m.buildSecretCallback(profile.ID, "vnc-ssh-password", vncConf.SSHSecretMode, getSecret) password, err := getPass() if err != nil { return fmt.Errorf("SSH password: %w", err) } cfg.SSHPassword = password case "publickey": if vncConf.SSHPrivateKey != "" { keyData, err := os.ReadFile(vncConf.SSHPrivateKey) if err != nil { return fmt.Errorf("reading SSH key: %w", err) } cfg.SSHPrivateKey = keyData // Get passphrase if needed getPassphrase := m.buildSecretCallback(profile.ID, "vnc-ssh-passphrase", vncConf.SSHSecretMode, getSecret) passphrase, _ := getPassphrase() cfg.SSHPassphrase = passphrase } } // SSH agent support if vncConf.SSHAgentAuth { socketPath := os.Getenv("SSH_AUTH_SOCK") if socketPath != "" { agentConn, err := net.Dial("unix", socketPath) if err == nil { cfg.SSHAgentConn = agentConn } } } } else { // Direct VNC — may need a VNC password getVNCPass := m.buildSecretCallback(profile.ID, "vnc-password", vncConf.SecretMode, getSecret) password, _ := getVNCPass() // VNC auth is handled by the RFB protocol in the frontend (noVNC), // but we store it in case we need server-side auth negotiation later. _ = password } client := vncclient.NewClient(cfg) wsURL, err := client.Connect(cfg) if err != nil { return err } sess.vncClient = client // Emit the WebSocket URL so the frontend can connect noVNC. m.emitEvent("session:vnc:ready", map[string]interface{}{ "sessionID": sess.Info.ID, "wsURL": wsURL, }) return nil } // GetVNCWebSocketURL returns the local WebSocket proxy URL for a VNC session. func (m *Manager) GetVNCWebSocketURL(sessionID string) (string, error) { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return "", fmt.Errorf("sessions: session %q not found", sessionID) } if sess.vncClient == nil { return "", fmt.Errorf("sessions: session %q is not a VNC connection", sessionID) } return sess.vncClient.WSURL(), nil } // --------------------------------------------------------------------------- // Session recording // --------------------------------------------------------------------------- // StartRecording begins recording terminal I/O for a session. // Returns the recording ID (same as session ID) on success. func (m *Manager) StartRecording(sessionID string, cols, rows int) (string, error) { m.mu.Lock() sess, ok := m.sessions[sessionID] if !ok { m.mu.Unlock() return "", fmt.Errorf("sessions: session %q not found", sessionID) } if sess.recorder != nil { m.mu.Unlock() return "", fmt.Errorf("sessions: session %q is already being recorded", sessionID) } m.mu.Unlock() if m.recordStore == nil { return "", fmt.Errorf("sessions: recording store not configured") } title := fmt.Sprintf("%s — %s", sess.Info.ProfileName, sess.Info.Host) rec, err := recording.NewRecorder(m.recordStore.Dir(), sessionID, title, cols, rows) if err != nil { return "", fmt.Errorf("sessions: failed to start recording: %w", err) } m.mu.Lock() sess.recorder = rec m.mu.Unlock() m.emitEvent("session:recording:started", map[string]interface{}{ "sessionID": sessionID, }) return sessionID, nil } // StopRecording stops an active recording for a session. func (m *Manager) StopRecording(sessionID string) error { m.mu.Lock() sess, ok := m.sessions[sessionID] if !ok { m.mu.Unlock() return fmt.Errorf("sessions: session %q not found", sessionID) } rec := sess.recorder sess.recorder = nil m.mu.Unlock() if rec == nil { return fmt.Errorf("sessions: session %q is not being recorded", sessionID) } if err := rec.Close(); err != nil { return fmt.Errorf("sessions: failed to stop recording: %w", err) } m.emitEvent("session:recording:stopped", map[string]interface{}{ "sessionID": sessionID, "duration": rec.Duration(), "events": rec.EventCount(), }) return nil } // IsRecording reports whether a session is currently being recorded. func (m *Manager) IsRecording(sessionID string) bool { m.mu.RLock() defer m.mu.RUnlock() sess, ok := m.sessions[sessionID] if !ok { return false } return sess.recorder != nil } // ListRecordings returns metadata for all saved recordings. func (m *Manager) ListRecordings() ([]recording.RecordingMeta, error) { if m.recordStore == nil { return nil, fmt.Errorf("sessions: recording store not configured") } return m.recordStore.List() } // GetRecording returns the full recording data (header + events) for playback. func (m *Manager) GetRecording(id string) (*recording.Header, []recording.Event, error) { if m.recordStore == nil { return nil, nil, fmt.Errorf("sessions: recording store not configured") } return m.recordStore.Get(id) } // DeleteRecording removes a saved recording. func (m *Manager) DeleteRecording(id string) error { if m.recordStore == nil { return fmt.Errorf("sessions: recording store not configured") } return m.recordStore.Delete(id) } // RecordingStorageInfo returns aggregate recording storage usage. func (m *Manager) RecordingStorageInfo() (recording.StorageInfo, error) { if m.recordStore == nil { return recording.StorageInfo{}, fmt.Errorf("sessions: recording store not configured") } return m.recordStore.StorageInfo() } // CleanupRecordings applies age and total-size retention limits. func (m *Manager) CleanupRecordings(maxAgeDays int, maxTotalBytes int64) (recording.CleanupResult, error) { if m.recordStore == nil { return recording.CleanupResult{}, fmt.Errorf("sessions: recording store not configured") } return m.recordStore.Cleanup(maxAgeDays, maxTotalBytes) } // --------------------------------------------------------------------------- // SFTP file browser // --------------------------------------------------------------------------- // getSFTP returns or lazily creates the SFTP client for an SSH session. func (m *Manager) getSFTP(sessionID string) (*sftpclient.Client, error) { m.mu.Lock() sess, ok := m.sessions[sessionID] if !ok { m.mu.Unlock() return nil, fmt.Errorf("sessions: session %q not found", sessionID) } if sess.sshClient == nil { m.mu.Unlock() return nil, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) } // Return existing SFTP client if already open. if sess.sftpClient != nil { c := sess.sftpClient m.mu.Unlock() return c, nil } m.mu.Unlock() // Create a new SFTP subsystem. sshConn := sess.sshClient.SSHConn() if sshConn == nil { return nil, fmt.Errorf("sessions: SSH connection is not established") } sc, err := sftpclient.NewClient(sshConn) if err != nil { return nil, err } m.mu.Lock() sess.sftpClient = sc m.mu.Unlock() return sc, nil } // SFTPList lists the contents of a remote directory. func (m *Manager) SFTPList(sessionID, remotePath string) ([]sftpclient.FileEntry, error) { sc, err := m.getSFTP(sessionID) if err != nil { return nil, err } return sc.List(remotePath) } // SFTPStat returns information about a remote path. func (m *Manager) SFTPStat(sessionID, remotePath string) (*sftpclient.FileEntry, error) { sc, err := m.getSFTP(sessionID) if err != nil { return nil, err } return sc.Stat(remotePath) } // SFTPMkdir creates a remote directory. func (m *Manager) SFTPMkdir(sessionID, remotePath string) error { sc, err := m.getSFTP(sessionID) if err != nil { return err } return sc.MkdirAll(remotePath) } // SFTPRemove deletes a remote file or empty directory. func (m *Manager) SFTPRemove(sessionID, remotePath string) error { sc, err := m.getSFTP(sessionID) if err != nil { return err } return sc.Remove(remotePath) } // SFTPRename moves or renames a remote file/directory. func (m *Manager) SFTPRename(sessionID, oldPath, newPath string) error { sc, err := m.getSFTP(sessionID) if err != nil { return err } return sc.Rename(oldPath, newPath) } // SFTPDownload downloads a remote file to a local path. func (m *Manager) SFTPDownload(sessionID, remotePath, localPath string) error { sc, err := m.getSFTP(sessionID) if err != nil { return err } return sc.Download(remotePath, localPath, func(p sftpclient.TransferProgress) { m.emitEvent("session:sftp:progress", map[string]interface{}{ "sessionID": sessionID, "direction": "download", "transferred": p.Transferred, "total": p.Total, "path": p.Path, "done": p.Done, }) }) } // SFTPUpload uploads a local file to a remote path. func (m *Manager) SFTPUpload(sessionID, localPath, remotePath string) error { sc, err := m.getSFTP(sessionID) if err != nil { return err } return sc.Upload(localPath, remotePath, func(p sftpclient.TransferProgress) { m.emitEvent("session:sftp:progress", map[string]interface{}{ "sessionID": sessionID, "direction": "upload", "transferred": p.Transferred, "total": p.Total, "path": p.Path, "done": p.Done, }) }) } // SFTPUploadBytes uploads in-memory data to a remote path. func (m *Manager) SFTPUploadBytes(sessionID, remotePath string, data []byte) error { sc, err := m.getSFTP(sessionID) if err != nil { return err } return sc.UploadBytes(remotePath, data, func(p sftpclient.TransferProgress) { m.emitEvent("session:sftp:progress", map[string]interface{}{ "sessionID": sessionID, "direction": "upload", "transferred": p.Transferred, "total": p.Total, "path": p.Path, "done": p.Done, }) }) } // SFTPOpenUpload opens a remote file for a caller-managed streaming upload. func (m *Manager) SFTPOpenUpload(sessionID, remotePath string) (io.WriteCloser, error) { sc, err := m.getSFTP(sessionID) if err != nil { return nil, err } return sc.CreateWriter(remotePath) } // SFTPPwd returns the current working directory on the remote host. func (m *Manager) SFTPPwd(sessionID string) (string, error) { sc, err := m.getSFTP(sessionID) if err != nil { return "", err } return sc.Pwd() } // --------------------------------------------------------------------------- // Port-forwarding management // --------------------------------------------------------------------------- // ListPortForwards returns the active port-forwarding rules for a session. func (m *Manager) ListPortForwards(sessionID string) ([]models.PortForward, error) { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return nil, fmt.Errorf("sessions: session %q not found", sessionID) } if sess.sshClient == nil { return nil, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) } fwdMgr := sess.sshClient.Forwards() if fwdMgr == nil { return []models.PortForward{}, nil } return fwdMgr.ActiveForwards(), nil } // StartPortForward activates a new forwarding tunnel on a live SSH session. func (m *Manager) StartPortForward(sessionID string, rule models.PortForward) error { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } if sess.sshClient == nil { return fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) } fwdMgr := sess.sshClient.Forwards() if fwdMgr == nil { return fmt.Errorf("sessions: forward manager not initialised") } if err := fwdMgr.StartForward(rule); err != nil { return err } m.emitEvent("session:forward:started", map[string]interface{}{ "sessionID": sessionID, "forward": rule, }) return nil } // StopPortForward terminates an active forwarding tunnel. func (m *Manager) StopPortForward(sessionID string, forwardID string) error { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return fmt.Errorf("sessions: session %q not found", sessionID) } if sess.sshClient == nil { return fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) } fwdMgr := sess.sshClient.Forwards() if fwdMgr == nil { return fmt.Errorf("sessions: forward manager not initialised") } if err := fwdMgr.StopForward(forwardID); err != nil { return err } m.emitEvent("session:forward:stopped", map[string]interface{}{ "sessionID": sessionID, "forwardID": forwardID, }) return nil } // PingSession sends a keepalive request to the server and returns the RTT in milliseconds. func (m *Manager) PingSession(sessionID string) (int64, error) { m.mu.RLock() sess, ok := m.sessions[sessionID] m.mu.RUnlock() if !ok { return 0, fmt.Errorf("sessions: session %q not found", sessionID) } if sess.sshClient == nil { return 0, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID) } return sess.sshClient.Ping() } // --------------------------------------------------------------------------- // Internal: helpers // --------------------------------------------------------------------------- func (m *Manager) hostKeyCallback(sessionID string, policy models.HostKeyPolicy) ssh.HostKeyCallback { if policy == "" { policy = models.HostKeyAsk } verifier := sshclient.NewHostKeyVerifier(m.store, policy) verifier.SetPromptHandler(func(host string, keyType string, fingerprint string) (bool, error) { return m.promptHostKey(sessionID, host, keyType, fingerprint) }) return verifier.HostKeyCallback() } func (m *Manager) promptHostKey(sessionID string, host string, keyType string, fingerprint string) (bool, error) { if m.ctx == nil { return false, fmt.Errorf("host key prompt unavailable") } requestID := uuid.New().String() ch := make(chan bool, 1) m.hostKeyMu.Lock() if m.hostKeyChans == nil { m.hostKeyChans = make(map[string]chan bool) } m.hostKeyChans[requestID] = ch m.hostKeyMu.Unlock() defer func() { m.hostKeyMu.Lock() delete(m.hostKeyChans, requestID) m.hostKeyMu.Unlock() }() m.emitEvent("session:hostkey:verify", map[string]interface{}{ "requestID": requestID, "sessionID": sessionID, "host": host, "keyType": keyType, "fingerprint": fingerprint, }) select { case accepted := <-ch: return accepted, nil case <-time.After(60 * time.Second): return false, fmt.Errorf("host key prompt timed out waiting for user response") } } func (m *Manager) registerHostKeyResponseListener() { ctx := m.ctx if ctx == nil { return } m.hostKeyMu.Lock() if m.hostKeyChans == nil { m.hostKeyChans = make(map[string]chan bool) } if m.hostKeyListenerRegistered { m.hostKeyMu.Unlock() return } m.hostKeyListenerRegistered = true m.hostKeyMu.Unlock() wailsRuntime.EventsOn(ctx, "session:hostkey:response", m.handleHostKeyResponse) } func (m *Manager) handleHostKeyResponse(optionalData ...interface{}) { if len(optionalData) == 0 { return } data, ok := optionalData[0].(map[string]interface{}) if !ok { return } requestID, _ := data["requestID"].(string) accepted, _ := data["accepted"].(bool) m.hostKeyMu.Lock() ch, exists := m.hostKeyChans[requestID] m.hostKeyMu.Unlock() if exists { select { case ch <- accepted: default: } } } // buildSecretCallback returns a func() (string, error) that resolves a // credential according to the profile's SecretMode: // - "saved": check keychain/vault first, prompt if missing, then persist // - "session-only": check an in-memory session cache, prompt once per session // - "ask-every-time" (or any other value): always prompt the user func (m *Manager) buildSecretCallback(profileID string, secretType string, mode models.SecretMode, getSecret func(prompt string) (string, error)) func() (string, error) { secretKey := fmt.Sprintf("profile:%s:%s", profileID, secretType) // Session-local cache for "session-only" mode. The closure captures this // variable so it survives across multiple calls within the same connection. var sessionCached string var sessionCachedSet bool return func() (string, error) { // ── "saved" mode: try persistent stores first ──────────────────── if mode == models.SecretModeSaved { // Try the OS keychain first. if m.keychainStore != nil { if val, err := m.keychainStore.GetSecret(secretKey); err == nil && val != "" { m.auditLog.LogSecretAccess(profileID, secretType) return val, nil } } // Try the encrypted file store. if m.secretStore != nil && m.secretStore.IsUnlocked() { if val, err := m.secretStore.GetSecret(secretKey); err == nil && val != "" { m.auditLog.LogSecretAccess(profileID, secretType) return val, nil } } } // ── "session-only" mode: return cached value if we already asked ─ if mode == models.SecretModeSessionOnly && sessionCachedSet { return sessionCached, nil } // ── Prompt the user ────────────────────────────────────────────── if getSecret == nil { return "", fmt.Errorf("no %s available and no prompt callback provided", secretType) } prompt := fmt.Sprintf("Enter %s:", secretType) val, err := getSecret(prompt) if err != nil { return "", err } // ── Persist according to mode ──────────────────────────────────── switch mode { case models.SecretModeSaved: // Prefer OS keychain, fall back to encrypted vault. stored := false if m.keychainStore != nil { if sErr := m.keychainStore.SetSecret(secretKey, val); sErr == nil { stored = true } } if !stored && m.secretStore != nil && m.secretStore.IsUnlocked() { _ = m.secretStore.SetSecret(secretKey, val) } case models.SecretModeSessionOnly: sessionCached = val sessionCachedSet = true } return val, nil } } // promptAndStoreSecret asks the user for a secret and stores it based on the mode. func (m *Manager) promptAndStoreSecret(profileID string, secretType string, mode models.SecretMode, getSecret func(prompt string) (string, error), prompt string) (string, error) { if getSecret == nil { return "", fmt.Errorf("no %s available and no prompt callback provided", secretType) } val, err := getSecret(prompt) if err != nil { return "", err } if val == "" { return "", fmt.Errorf("%s cannot be empty", secretType) } if mode == models.SecretModeSaved { stored := false secretKey := fmt.Sprintf("profile:%s:%s", profileID, secretType) if m.keychainStore != nil { if sErr := m.keychainStore.SetSecret(secretKey, val); sErr == nil { stored = true } } if !stored && m.secretStore != nil && m.secretStore.IsUnlocked() { _ = m.secretStore.SetSecret(secretKey, val) } } return val, nil } // isGitHubAuthError checks whether an error indicates authentication failure. func (m *Manager) isGitHubAuthError(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "token validation failed") || strings.Contains(msg, "authentication failed") || strings.Contains(msg, "http 401") || strings.Contains(msg, "http 403") } // closeSession shuts down the protocol client for a session. func (m *Manager) closeSession(sess *Session) { // Cancel any in-progress reconnect backoff. if sess.cancelReconn != nil { sess.cancelReconn() sess.cancelReconn = nil } // Stop any active recording. if sess.recorder != nil { sess.recorder.Close() sess.recorder = nil } // Close SFTP subsystem if open. if sess.sftpClient != nil { sess.sftpClient.Close() sess.sftpClient = nil } if sess.sshClient != nil { sess.sshClient.Close() } if sess.telnetClient != nil { sess.telnetClient.Close() } if sess.rawClient != nil { sess.rawClient.Close() } if sess.githubCancel != nil { sess.githubCancel() } if sess.vncClient != nil { sess.vncClient.Close() sess.vncClient = nil } if sess.localClient != nil { sess.localClient.Close() sess.localClient = nil } m.mu.Lock() sess.Info.State = models.SessionDisconnected m.mu.Unlock() } // --------------------------------------------------------------------------- // Login script execution (expect-style send/wait) // --------------------------------------------------------------------------- // executeLoginScripts processes an expect-style login script sequence against // a connected session. "wait" steps block until the specified text appears in // the session output (with a timeout). "send" steps write text to the session // input. The session's loginBuf/loginDone fields are used for pattern matching. func (m *Manager) executeLoginScripts(sess *Session, steps []models.LoginScriptStep) { // Initialise the login buffer so the onData hook starts accumulating. sess.loginBufMu.Lock() sess.loginBuf = nil sess.loginDone = make(chan struct{}) sess.loginBufMu.Unlock() defer func() { sess.loginBufMu.Lock() close(sess.loginDone) sess.loginBuf = nil sess.loginBufMu.Unlock() }() // Short delay to allow the shell to start and produce its initial prompt. time.Sleep(500 * time.Millisecond) for i, step := range steps { // Check session is still alive. m.mu.RLock() state := sess.Info.State m.mu.RUnlock() if state != models.SessionConnected { return } switch step.Type { case "wait": timeout := time.Duration(step.Timeout) * time.Second if timeout <= 0 { timeout = 10 * time.Second } if err := m.waitForOutput(sess, step.Value, timeout); err != nil { m.emitEvent("session:debug", map[string]interface{}{ "sessionID": sess.Info.ID, "message": fmt.Sprintf("login script step %d timed out waiting for %q", i+1, step.Value), }) return } case "send": data := []byte(step.Value + "\n") if err := m.WriteToSession(sess.Info.ID, data); err != nil { m.emitEvent("session:debug", map[string]interface{}{ "sessionID": sess.Info.ID, "message": fmt.Sprintf("login script step %d failed to send: %v", i+1, err), }) return } // Small inter-step delay for the remote to process. time.Sleep(200 * time.Millisecond) default: // Unknown step type — skip. } } } // waitForOutput polls the session's login buffer until the specified text // appears or the timeout expires. func (m *Manager) waitForOutput(sess *Session, pattern string, timeout time.Duration) error { if pattern == "" { return nil } deadline := time.After(timeout) ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case <-deadline: return fmt.Errorf("timeout waiting for %q", pattern) case <-ticker.C: sess.loginBufMu.Lock() found := strings.Contains(string(sess.loginBuf), pattern) if found { // Clear the buffer up to the match so subsequent waits start fresh. idx := strings.Index(string(sess.loginBuf), pattern) sess.loginBuf = sess.loginBuf[idx+len(pattern):] } sess.loginBufMu.Unlock() if found { return nil } } } } // handleSessionClosed is called when a protocol client reports closure. func (m *Manager) handleSessionClosed(sessionID string) { m.mu.Lock() sess, ok := m.sessions[sessionID] if ok { sess.Info.State = models.SessionDisconnected } m.mu.Unlock() m.auditLog.LogDisconnect(sessionID) // Check whether auto-reconnect is enabled for this session. if ok && m.shouldAutoReconnect(sess) { m.scheduleReconnect(sess, sessionID) return } m.emitEvent("session:closed", map[string]interface{}{ "sessionID": sessionID, }) m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionDisconnected), }) } // shouldAutoReconnect checks whether a session is configured for automatic reconnection. func (m *Manager) shouldAutoReconnect(sess *Session) bool { if sess.profile == nil { return false } switch sess.profile.Protocol { case models.ProtocolSSH: if sess.profile.SSH != nil { return sess.profile.SSH.AutoReconnect } case models.ProtocolTelnet: if sess.profile.Telnet != nil { return sess.profile.Telnet.AutoReconnect } case models.ProtocolRAW: if sess.profile.RAW != nil { return sess.profile.RAW.AutoReconnect } } return false } // maxReconnectAttempts returns the configured (or default) max retry count. func (m *Manager) maxReconnectAttempts(sess *Session) int { maxTries := 5 switch sess.profile.Protocol { case models.ProtocolSSH: if sess.profile.SSH != nil && sess.profile.SSH.MaxReconnectTries > 0 { maxTries = sess.profile.SSH.MaxReconnectTries } case models.ProtocolTelnet: if sess.profile.Telnet != nil && sess.profile.Telnet.MaxReconnectTries > 0 { maxTries = sess.profile.Telnet.MaxReconnectTries } } return maxTries } // scheduleReconnect initiates an exponential-backoff reconnection loop for a session. func (m *Manager) scheduleReconnect(sess *Session, sessionID string) { maxTries := m.maxReconnectAttempts(sess) m.mu.Lock() sess.reconnectCount++ attempt := sess.reconnectCount if attempt > maxTries { // Give up — too many attempts. sess.Info.State = models.SessionDisconnected m.mu.Unlock() m.emitEvent("session:closed", map[string]interface{}{ "sessionID": sessionID, }) m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionDisconnected), "error": "auto-reconnect failed after maximum attempts", }) return } sess.Info.State = models.SessionReconnecting sess.Info.Error = "" // Cancel any previous reconnect timer. if sess.cancelReconn != nil { sess.cancelReconn() } ctx, cancel := context.WithCancel(context.Background()) sess.cancelReconn = cancel m.mu.Unlock() // Exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s. delaySec := 1 << (attempt - 1) // 2^(attempt-1) if delaySec > 30 { delaySec = 30 } m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionReconnecting), "attempt": attempt, "maxRetries": maxTries, "delaySec": delaySec, }) go func() { select { case <-ctx.Done(): return case <-time.After(time.Duration(delaySec) * time.Second): } // Clear old clients before reconnecting. m.mu.Lock() sess.sshClient = nil sess.telnetClient = nil sess.rawClient = nil sess.sftpClient = nil sess.Info.State = models.SessionConnecting m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionConnecting), }) var err error switch sess.profile.Protocol { case models.ProtocolSSH: err = m.connectSSH(sess, m.buildInteractiveSecretCallback(sessionID)) case models.ProtocolTelnet: err = m.connectTelnet(sess) case models.ProtocolRAW: err = m.connectRAW(sess) default: err = fmt.Errorf("unsupported protocol for reconnect: %s", sess.profile.Protocol) } if err != nil { m.mu.Lock() sess.Info.State = models.SessionError sess.Info.Error = err.Error() m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionError), "error": err.Error(), }) // Schedule the next attempt. m.scheduleReconnect(sess, sessionID) return } // Success — reset the counter. m.mu.Lock() sess.reconnectCount = 0 sess.Info.State = models.SessionConnected sess.Info.ConnectedAt = time.Now() m.mu.Unlock() m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionConnected), }) }() } // buildInteractiveSecretCallback creates a getSecret callback that prompts // the user via Wails events. Used during auto-reconnect flows where the // original callback is no longer available. func (m *Manager) buildInteractiveSecretCallback(sessionID string) func(prompt string) (string, error) { return func(prompt string) (string, error) { // Use the same event-based flow as the initial connection. requestID := uuid.New().String() ch := make(chan string, 1) m.mu.Lock() // Store a temporary secret channel keyed by requestID. if m.ctx != nil { wailsRuntime.EventsEmit(m.ctx, "session:secret:prompt", map[string]interface{}{ "requestID": requestID, "sessionID": sessionID, "title": "Reconnection Credentials", "message": prompt, }) } m.mu.Unlock() // Wait for the response with a timeout. var response string cancel := make(chan struct{}) if m.ctx != nil { wailsRuntime.EventsOn(m.ctx, "session:secret:response", func(optionalData ...interface{}) { if len(optionalData) == 0 { return } data, ok := optionalData[0].(map[string]interface{}) if !ok { return } if rid, _ := data["requestID"].(string); rid == requestID { val, _ := data["value"].(string) select { case ch <- val: default: } } }) } select { case response = <-ch: case <-time.After(60 * time.Second): close(cancel) return "", fmt.Errorf("credential prompt timed out during reconnect") } return response, nil } } // CancelReconnect cancels any in-progress auto-reconnect for a session. func (m *Manager) CancelReconnect(sessionID string) { m.mu.Lock() sess, ok := m.sessions[sessionID] if ok && sess.cancelReconn != nil { sess.cancelReconn() sess.cancelReconn = nil sess.reconnectCount = 0 sess.Info.State = models.SessionDisconnected } m.mu.Unlock() if ok { m.emitEvent("session:state", map[string]interface{}{ "sessionID": sessionID, "state": string(models.SessionDisconnected), }) } } // createTabName generates a default tab name from the profile's display name // and host. func (m *Manager) createTabName(profile *models.Profile) string { if profile.DisplayName != "" { return profile.DisplayName } if profile.Username != "" { return fmt.Sprintf("%s@%s", profile.Username, profile.Host) } return profile.Host } // emitEvent sends an event to the Wails frontend via the runtime context. func (m *Manager) emitEvent(eventName string, data interface{}) { if m.ctx == nil { return } wailsRuntime.EventsEmit(m.ctx, eventName, data) }