// 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" "sync" "time" "github.com/google/uuid" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" "github.com/copilot-ssh-client/app/internal/audit" "github.com/copilot-ssh-client/app/internal/githubclient" "github.com/copilot-ssh-client/app/internal/models" "github.com/copilot-ssh-client/app/internal/rawclient" "github.com/copilot-ssh-client/app/internal/secrets" "github.com/copilot-ssh-client/app/internal/sshclient" "github.com/copilot-ssh-client/app/internal/storage" "github.com/copilot-ssh-client/app/internal/telnetclient" ) // 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 profile *models.Profile } // 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 ctx context.Context // Wails app context for event emission } // NewManager creates a Manager wired to the given backing services. func NewManager(store *storage.Store, secretStore *secrets.SecretStore, keychainStore *secrets.KeychainStore, auditLog *audit.Logger) *Manager { return &Manager{ sessions: make(map[string]*Session), store: store, secretStore: secretStore, keychainStore: keychainStore, auditLog: auditLog, } } // SetContext stores the Wails runtime context for event emission. func (m *Manager) SetContext(ctx context.Context) { m.ctx = ctx } // 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) 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 } // 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) } 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) } 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) } // RAW sessions have no concept of terminal size. return 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), }) }, // 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) if err := client.Connect(getPassword, getPassphrase); err != nil { return err } sess.sshClient = client 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), }) }, // 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 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 { return fmt.Errorf("GitHub authentication failed: %w", err) } sess.githubClient = client // Perform the initial fetch. repos, err := client.FetchRepos(context.Background()) 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: updated, fetchErr := client.FetchRepos(ctx) 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 } // --------------------------------------------------------------------------- // Internal: helpers // --------------------------------------------------------------------------- // 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 } } // closeSession shuts down the protocol client for a session. func (m *Manager) closeSession(sess *Session) { 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() } m.mu.Lock() sess.Info.State = models.SessionDisconnected m.mu.Unlock() } // 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) m.emitEvent("session:closed", map[string]interface{}{ "sessionID": sessionID, }) 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) }