diff --git a/frontend/src/css/tabs.css b/frontend/src/css/tabs.css index ce3d38c..cd4eec7 100644 --- a/frontend/src/css/tabs.css +++ b/frontend/src/css/tabs.css @@ -125,9 +125,17 @@ .tab-status.is-connected { background: var(--success); } .tab-status.is-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; } +.tab-status.is-reconnecting { background: var(--warning); animation: pulse-dot 0.8s ease infinite; } .tab-status.is-error { background: var(--error); } .tab-status.is-disconnected { background: var(--text-tertiary); } +/* Also match the status-* pattern used by updateSessionState */ +.tab-status.status-connected { background: var(--success); } +.tab-status.status-connecting { background: var(--warning); animation: pulse-dot 1.2s ease infinite; } +.tab-status.status-reconnecting { background: var(--warning); animation: pulse-dot 0.8s ease infinite; } +.tab-status.status-error { background: var(--error); } +.tab-status.status-disconnected { background: var(--text-tertiary); } + /* ----------------------------------------------------------------------- SFTP toggle button — revealed on hover (SSH tabs only) ----------------------------------------------------------------------- */ diff --git a/frontend/src/js/app.js b/frontend/src/js/app.js index 8333afa..9279d81 100644 --- a/frontend/src/js/app.js +++ b/frontend/src/js/app.js @@ -134,10 +134,21 @@ class App { this.terminals.handleData(payload.sessionID, payload.data); }); - // Session state transitions (connecting → connected → disconnected) + // Session state transitions (connecting → connected → reconnecting → disconnected) window.runtime.EventsOn('session:state', (payload) => { this.tabs.updateSessionState(payload.sessionID, payload.state, payload.error); this.updateStatusBar(); + + // Show reconnecting notification + if (payload.state === 'reconnecting') { + const attempt = payload.attempt || 1; + const maxRetries = payload.maxRetries || 5; + const delaySec = payload.delaySec || 1; + this.notifications.info( + `Reconnecting (attempt ${attempt}/${maxRetries}) in ${delaySec}s…` + ); + } + // Once connected, ensure the backend PTY has the correct dimensions. // The initial fit happens before the SSH handshake completes, so the // onResize callback fires when the backend isn't ready. By the time diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index 8a86983..172aa74 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -406,6 +406,10 @@ class ProfileEditor { this._field('number', 'ssh.keepaliveInterval', 'Keepalive Interval (s)', ssh.keepaliveInterval, { min: 0 }), this._field('number', 'ssh.keepaliveCountMax', 'Max Missed Keepalives', ssh.keepaliveCountMax, { min: 0 }), this._field('toggle', 'ssh.compression', 'Compression', ssh.compression), + this._field('toggle', 'ssh.autoReconnect', 'Auto-Reconnect', ssh.autoReconnect, { + help: 'Automatically reconnect with exponential backoff when the connection drops', + }), + this._field('number', 'ssh.maxReconnectTries', 'Max Reconnect Attempts', ssh.maxReconnectTries || 5, { min: 1, max: 20 }), ], true)); return sections; @@ -447,6 +451,13 @@ class ProfileEditor { this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug), ], true)); + sections.push(this._renderSection('Reconnection', [ + this._field('toggle', 'telnet.autoReconnect', 'Auto-Reconnect', tel.autoReconnect, { + help: 'Automatically reconnect with exponential backoff when the connection drops', + }), + this._field('number', 'telnet.maxReconnectTries', 'Max Reconnect Attempts', tel.maxReconnectTries || 5, { min: 1, max: 20 }), + ], true)); + return sections; } @@ -908,6 +919,8 @@ class ProfileEditor { p.ssh.compression = val('ssh.compression') || false; p.ssh.agentAuth = val('ssh.agentAuth') || false; p.ssh.agentForwarding = val('ssh.agentForwarding') || false; + p.ssh.autoReconnect = val('ssh.autoReconnect') || false; + p.ssh.maxReconnectTries = val('ssh.maxReconnectTries') ?? 5; p.ssh.startupCommand = val('ssh.startupCommand') || ''; const authStr = val('ssh.authMethods'); @@ -970,6 +983,8 @@ class ProfileEditor { p.telnet.encoding = val('telnet.encoding') || 'utf-8'; p.telnet.lineEnding = val('telnet.lineEnding') || 'crlf'; p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false; + p.telnet.autoReconnect = val('telnet.autoReconnect') || false; + p.telnet.maxReconnectTries = val('telnet.maxReconnectTries') ?? 5; } // RAW-specific diff --git a/frontend/src/js/utils.js b/frontend/src/js/utils.js index 1c75249..31de57c 100644 --- a/frontend/src/js/utils.js +++ b/frontend/src/js/utils.js @@ -251,6 +251,7 @@ function stateLabel(state) { case 'connecting': return 'Connecting'; case 'connected': return 'Connected'; case 'disconnected': return 'Disconnected'; + case 'reconnecting': return 'Reconnecting'; case 'error': return 'Error'; default: return state || 'Unknown'; } diff --git a/frontend/wailsjs/go/api/Service.d.ts b/frontend/wailsjs/go/api/Service.d.ts index 49bf848..e5d120e 100755 --- a/frontend/wailsjs/go/api/Service.d.ts +++ b/frontend/wailsjs/go/api/Service.d.ts @@ -3,6 +3,8 @@ import {models} from '../models'; import {context} from '../models'; +export function CancelReconnect(arg1:string):Promise; + export function CheckForUpdates():Promise>; export function ClearSessionHistory(arg1:string):Promise; diff --git a/frontend/wailsjs/go/api/Service.js b/frontend/wailsjs/go/api/Service.js index 7bdb3d6..765a3a3 100755 --- a/frontend/wailsjs/go/api/Service.js +++ b/frontend/wailsjs/go/api/Service.js @@ -2,6 +2,10 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function CancelReconnect(arg1) { + return window['go']['api']['Service']['CancelReconnect'](arg1); +} + export function CheckForUpdates() { return window['go']['api']['Service']['CheckForUpdates'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 2b97936..10937e7 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -284,6 +284,8 @@ export namespace models { encoding: string; negotiationDebug: boolean; lineMode: boolean; + autoReconnect: boolean; + maxReconnectTries: number; static createFrom(source: any = {}) { return new TelnetConfig(source); @@ -297,6 +299,8 @@ export namespace models { this.encoding = source["encoding"]; this.negotiationDebug = source["negotiationDebug"]; this.lineMode = source["lineMode"]; + this.autoReconnect = source["autoReconnect"]; + this.maxReconnectTries = source["maxReconnectTries"]; } } export class SSHConfig { @@ -311,6 +315,8 @@ export namespace models { compression: boolean; agentAuth: boolean; agentForwarding: boolean; + autoReconnect: boolean; + maxReconnectTries: number; jumpHosts: JumpHost[]; portForwards: PortForward[]; startupCommand: string; @@ -333,6 +339,8 @@ export namespace models { this.compression = source["compression"]; this.agentAuth = source["agentAuth"]; this.agentForwarding = source["agentForwarding"]; + this.autoReconnect = source["autoReconnect"]; + this.maxReconnectTries = source["maxReconnectTries"]; this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost); this.portForwards = this.convertValues(source["portForwards"], PortForward); this.startupCommand = source["startupCommand"]; diff --git a/internal/api/api.go b/internal/api/api.go index 7e3eb2f..2bdbf58 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -276,6 +276,11 @@ func (s *Service) ReconnectSession(sessionID string) error { return s.sessions.Reconnect(sessionID, s.promptSecret) } +// CancelReconnect cancels an in-progress auto-reconnect for a session. +func (s *Service) CancelReconnect(sessionID string) { + s.sessions.CancelReconnect(sessionID) +} + // WriteSession writes base64-encoded data to a session. func (s *Service) WriteSession(sessionID string, data string) error { decoded, err := base64.StdEncoding.DecodeString(data) diff --git a/internal/models/models.go b/internal/models/models.go index 81b05f1..bb19b38 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -95,6 +95,8 @@ type SSHConfig struct { Compression bool `json:"compression"` AgentAuth bool `json:"agentAuth"` // use SSH agent for authentication AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host + AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect + MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5) JumpHosts []JumpHost `json:"jumpHosts"` PortForwards []PortForward `json:"portForwards"` StartupCommand string `json:"startupCommand"` @@ -133,12 +135,14 @@ type PortForward struct { // TelnetConfig holds Telnet-specific settings for a connection profile. type TelnetConfig struct { - LocalEcho bool `json:"localEcho"` - LineEnding LineEnding `json:"lineEnding"` - TerminalType string `json:"terminalType"` - Encoding string `json:"encoding"` - NegotiationDebug bool `json:"negotiationDebug"` - LineMode bool `json:"lineMode"` + LocalEcho bool `json:"localEcho"` + LineEnding LineEnding `json:"lineEnding"` + TerminalType string `json:"terminalType"` + Encoding string `json:"encoding"` + NegotiationDebug bool `json:"negotiationDebug"` + LineMode bool `json:"lineMode"` + AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect + MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5) } // RAWConfig holds RAW TCP-specific settings for a connection profile. @@ -196,6 +200,7 @@ const ( SessionConnecting SessionState = "connecting" SessionConnected SessionState = "connected" SessionDisconnected SessionState = "disconnected" + SessionReconnecting SessionState = "reconnecting" SessionError SessionState = "error" ) diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index 10b2dfd..0c1020c 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -27,15 +27,17 @@ import ( // 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 - sftpClient *sftpclient.Client // SFTP subsystem, nil until requested - profile *models.Profile - recorder *recording.Recorder // active recording, nil if not recording + Info models.SessionInfo + sshClient *sshclient.Client + telnetClient *telnetclient.Client + rawClient *rawclient.Client + githubClient *githubclient.Client + githubCancel context.CancelFunc // stops the auto-refresh goroutine + 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 } // Manager handles the lifecycle of all active sessions. @@ -1119,6 +1121,12 @@ func (m *Manager) buildSecretCallback(profileID string, secretType string, mode // 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() @@ -1160,6 +1168,12 @@ func (m *Manager) handleSessionClosed(sessionID string) { 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, }) @@ -1169,6 +1183,231 @@ func (m *Manager) handleSessionClosed(sessionID string) { }) } +// 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 {