Files
nexus/internal/sessions/manager.go
T
andy 8c9ce67684 Phase 3: Session recording/playback and encrypted profile export/import
Session Recording & Playback:
- Add recording package with asciicast v2 format writer and reader
- Recorder captures terminal I/O with timestamps from SSH and Telnet sessions
- Input and output events recorded via hooks in session manager callbacks
- Recording store manages .cast files in ~/Library/Application Support/NexusTerminal/recordings
- Recording browser modal lists all saved recordings with metadata
- Full playback viewer with embedded xterm.js terminal, progress scrubber,
  play/pause/restart controls, and 0.5x-10x speed adjustment
- Recording indicator (pulsing red dot) shown on active tab during capture
- Keyboard shortcut Cmd+Shift+R to toggle recording on active session
- Tests for recording, store listing, deletion, and double-close safety

Encrypted Profile Export/Import:
- Add exportimport package with AES-256-GCM encryption (Argon2id key derivation)
- File format: version byte + 32-byte salt + 12-byte nonce + ciphertext
- Same Argon2id parameters as vault (128 MiB, 3 iterations, 4 threads)
- Export modal with profile selection checkboxes and password entry
- Import modal with password entry and conflict strategy (skip/overwrite/duplicate)
- Native save/open file dialogs for .nexus files
- Tests for round-trip encryption, wrong password rejection, corrupt files
- Replaced placeholder import/export with full encrypted implementation

UI:
- Added export and recordings buttons to sidebar footer
- Recording and playback CSS with charcoal design tokens
- Wired RecordingManager and ExportImportUI into app.js
2026-04-16 00:00:00 +01:00

1052 lines
28 KiB
Go

// 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/recording"
"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
recorder *recording.Recorder // active recording, nil if not recording
}
// 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
}
// 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
}
// 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)
}
// 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)
}
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),
})
// Write to active recording if present.
m.mu.RLock()
rec := sess.recorder
m.mu.RUnlock()
if rec != nil {
rec.WriteOutput(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
// 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,
})
}
}
}
}
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)
}
},
// 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
}
// ---------------------------------------------------------------------------
// 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)
}
// ---------------------------------------------------------------------------
// 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
}
// ---------------------------------------------------------------------------
// 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) {
// Stop any active recording.
if sess.recorder != nil {
sess.recorder.Close()
sess.recorder = 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()
}
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)
}