diff --git a/frontend/index.html b/frontend/index.html index 40f9dfe..979b2a6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -17,6 +17,7 @@ + @@ -61,7 +62,7 @@ - + + + + + + `; + } + listHTML += ''; + } + + modal.innerHTML = ` + + + + `; + + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => backdrop.classList.add('is-visible')); + + const close = () => { + backdrop.classList.remove('is-visible'); + setTimeout(() => backdrop.remove(), 200); + }; + + modal.querySelector('.modal-close').addEventListener('click', close); + modal.querySelector('.modal-close-btn').addEventListener('click', close); + + // Play buttons + modal.querySelectorAll('.rec-play').forEach(btn => { + btn.addEventListener('click', () => { + close(); + this.playback(btn.dataset.id); + }); + }); + + // Delete buttons + modal.querySelectorAll('.rec-delete').forEach(btn => { + btn.addEventListener('click', async () => { + await this.deleteRecording(btn.dataset.id); + const item = btn.closest('.recording-item'); + if (item) item.remove(); + }); + }); + } + + // ── Playback modal with embedded terminal ──────────────────────── + + /** + * Shows a playback modal with an xterm.js terminal, progress bar, and speed controls. + * @param {Object} data — { header, events } + * @private + */ + _showPlaybackModal(data) { + const header = data.header; + const events = data.events || []; + if (events.length === 0) { + this.app.notifications.info('Recording is empty — nothing to play back.'); + return; + } + + const totalDuration = events[events.length - 1][0] || 1; + + const backdrop = createElement('div', 'modal-backdrop'); + const modal = createElement('div', 'modal modal-playback'); + + modal.innerHTML = ` + + + `; + + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => backdrop.classList.add('is-visible')); + + // Create xterm instance for playback. + const termContainer = modal.querySelector('#playback-terminal'); + const { getTerminalTheme } = window.NexusTerminal; + const themeName = (this.app.state.preferences && this.app.state.preferences.terminalTheme) || 'charcoal'; + const theme = getTerminalTheme ? getTerminalTheme(themeName) : {}; + + const term = new window.Terminal({ + cols: header.width || 80, + rows: header.height || 24, + theme: theme, + fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", "Menlo", monospace', + fontSize: 13, + cursorBlink: false, + disableStdin: true, + scrollback: 10000, + }); + term.open(termContainer); + + const fitAddon = new window.FitAddon.FitAddon(); + term.loadAddon(fitAddon); + setTimeout(() => fitAddon.fit(), 50); + + // Playback state. + let playing = false; + let speed = 1; + let eventIndex = 0; + let playbackTimer = null; + let startRealTime = 0; + let pausedAt = 0; // elapsed virtual time when paused + + const playPauseBtn = modal.querySelector('.playback-play-pause'); + const restartBtn = modal.querySelector('.playback-restart'); + const progressInput = modal.querySelector('.playback-progress'); + const timeLabel = modal.querySelector('.playback-time'); + const speedSelect = modal.querySelector('.playback-speed'); + + const updateTimeDisplay = (elapsed) => { + timeLabel.textContent = `${_formatSeconds(elapsed)} / ${_formatSeconds(totalDuration)}`; + progressInput.value = Math.round((elapsed / totalDuration) * 1000); + }; + + const scheduleNext = () => { + if (!playing || eventIndex >= events.length) { + if (eventIndex >= events.length) { + playing = false; + playPauseBtn.textContent = '▶'; + } + return; + } + + const evt = events[eventIndex]; + const evtTime = evt[0]; // seconds from start + const realElapsed = (Date.now() - startRealTime) / 1000; + const virtualElapsed = pausedAt + realElapsed * speed; + const delay = Math.max(0, (evtTime - virtualElapsed) / speed * 1000); + + playbackTimer = setTimeout(() => { + if (!playing) return; + + // Write output events to the terminal. + if (evt[1] === 'o') { + term.write(evt[2]); + } + + eventIndex++; + const currentVirtual = pausedAt + ((Date.now() - startRealTime) / 1000) * speed; + updateTimeDisplay(Math.min(currentVirtual, totalDuration)); + scheduleNext(); + }, delay); + }; + + const play = () => { + if (eventIndex >= events.length) { + // Restart from beginning. + eventIndex = 0; + pausedAt = 0; + term.reset(); + } + playing = true; + playPauseBtn.textContent = '⏸'; + startRealTime = Date.now(); + scheduleNext(); + }; + + const pause = () => { + playing = false; + playPauseBtn.textContent = '▶'; + if (playbackTimer) { + clearTimeout(playbackTimer); + playbackTimer = null; + } + const realElapsed = (Date.now() - startRealTime) / 1000; + pausedAt = pausedAt + realElapsed * speed; + }; + + const seekTo = (fraction) => { + const wasPlaying = playing; + if (playing) pause(); + + const targetTime = fraction * totalDuration; + term.reset(); + eventIndex = 0; + + // Replay all events up to targetTime. + for (let i = 0; i < events.length; i++) { + if (events[i][0] > targetTime) break; + if (events[i][1] === 'o') { + term.write(events[i][2]); + } + eventIndex = i + 1; + } + + pausedAt = targetTime; + updateTimeDisplay(targetTime); + + if (wasPlaying) play(); + }; + + playPauseBtn.addEventListener('click', () => { + if (playing) pause(); else play(); + }); + + restartBtn.addEventListener('click', () => { + if (playing) pause(); + term.reset(); + eventIndex = 0; + pausedAt = 0; + updateTimeDisplay(0); + }); + + progressInput.addEventListener('input', () => { + seekTo(parseInt(progressInput.value, 10) / 1000); + }); + + speedSelect.addEventListener('change', () => { + const newSpeed = parseFloat(speedSelect.value); + if (playing) { + const realElapsed = (Date.now() - startRealTime) / 1000; + pausedAt = pausedAt + realElapsed * speed; + speed = newSpeed; + startRealTime = Date.now(); + if (playbackTimer) clearTimeout(playbackTimer); + scheduleNext(); + } else { + speed = newSpeed; + } + }); + + // Auto-play on open. + setTimeout(play, 200); + + // Close handler. + const close = () => { + if (playing) pause(); + term.dispose(); + backdrop.classList.remove('is-visible'); + setTimeout(() => backdrop.remove(), 200); + }; + + modal.querySelector('.modal-close').addEventListener('click', close); + } +} + +// --------------------------------------------------------------------------- +// Encrypted Export/Import UI +// --------------------------------------------------------------------------- + +class ExportImportUI { + constructor(app) { + this.app = app; + } + + /** + * Shows the encrypted export modal. + */ + async showExportModal() { + const profiles = this.app.state.profiles || []; + if (profiles.length === 0) { + this.app.notifications.info('No profiles to export.'); + return; + } + + const backdrop = createElement('div', 'modal-backdrop'); + const modal = createElement('div', 'modal modal-md'); + + let profileListHTML = profiles.map(p => ` + + `).join(''); + + modal.innerHTML = ` + + + + `; + + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => backdrop.classList.add('is-visible')); + + const close = () => { + backdrop.classList.remove('is-visible'); + setTimeout(() => backdrop.remove(), 200); + }; + + modal.querySelector('.modal-close').addEventListener('click', close); + modal.querySelector('.export-cancel').addEventListener('click', close); + + modal.querySelector('.export-confirm').addEventListener('click', async () => { + const password = modal.querySelector('.export-password').value; + const confirm = modal.querySelector('.export-password-confirm').value; + + if (!password) { + this.app.notifications.error('Please enter a password.'); + return; + } + if (password !== confirm) { + this.app.notifications.error('Passwords do not match.'); + return; + } + + const checkboxes = modal.querySelectorAll('.export-profile-list input[type="checkbox"]:checked'); + const selectedIds = Array.from(checkboxes).map(cb => cb.value); + + if (selectedIds.length === 0) { + this.app.notifications.error('Please select at least one profile.'); + return; + } + + try { + const path = await window.go.api.Service.ExportProfilesEncrypted(selectedIds, password); + this.app.notifications.success(`Exported ${selectedIds.length} profile(s) to ${path}`); + close(); + } catch (err) { + if (String(err).includes('cancelled')) { + return; + } + this.app.notifications.error('Export failed: ' + err); + } + }); + } + + /** + * Shows the encrypted import modal. + */ + async showImportModal() { + const backdrop = createElement('div', 'modal-backdrop'); + const modal = createElement('div', 'modal modal-md'); + + modal.innerHTML = ` + + + + `; + + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => backdrop.classList.add('is-visible')); + + const close = () => { + backdrop.classList.remove('is-visible'); + setTimeout(() => backdrop.remove(), 200); + }; + + modal.querySelector('.modal-close').addEventListener('click', close); + modal.querySelector('.import-cancel').addEventListener('click', close); + + modal.querySelector('.import-confirm').addEventListener('click', async () => { + const password = modal.querySelector('.import-password').value; + const strategy = modal.querySelector('.import-strategy').value; + + if (!password) { + this.app.notifications.error('Please enter the decryption password.'); + return; + } + + try { + const count = await window.go.api.Service.ImportProfilesEncrypted(password, strategy); + this.app.notifications.success(`Imported ${count} profile(s)`); + await this.app.loadProfiles(); + close(); + } catch (err) { + if (String(err).includes('cancelled')) { + return; + } + this.app.notifications.error('Import failed: ' + err); + } + }); + } +} + +// --------------------------------------------------------------------------- +// Utility helpers +// --------------------------------------------------------------------------- + +function _esc(s) { + const el = document.createElement('span'); + el.textContent = s || ''; + return el.innerHTML; +} + +function _formatSeconds(secs) { + if (!secs || secs <= 0) return '0:00'; + const m = Math.floor(secs / 60); + const s = Math.floor(secs % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +function _formatBytes(bytes) { + if (!bytes || bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + let i = 0; + let val = bytes; + while (val >= 1024 && i < units.length - 1) { + val /= 1024; + i++; + } + return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +// --------------------------------------------------------------------------- +// Export to global namespace +// --------------------------------------------------------------------------- + +window.NexusTerminal = window.NexusTerminal || {}; +window.NexusTerminal.RecordingManager = RecordingManager; +window.NexusTerminal.ExportImportUI = ExportImportUI; + +})(); diff --git a/frontend/wailsjs/go/api/Service.d.ts b/frontend/wailsjs/go/api/Service.d.ts index ac42e42..3ef2d38 100755 --- a/frontend/wailsjs/go/api/Service.d.ts +++ b/frontend/wailsjs/go/api/Service.d.ts @@ -19,24 +19,34 @@ export function DeleteProfile(arg1:string):Promise; export function DeleteProfileSecrets(arg1:string):Promise; +export function DeleteRecording(arg1:string):Promise; + export function DisconnectSession(arg1:string):Promise; export function DuplicateProfile(arg1:string):Promise; export function ExportProfiles(arg1:boolean):Promise; +export function ExportProfilesEncrypted(arg1:Array,arg2:string):Promise; + export function GetAppInfo():Promise>; export function GetPreferences():Promise; export function GetProfile(arg1:string):Promise; +export function GetRecording(arg1:string):Promise>; + export function GetSessionHistory(arg1:string):Promise>>; export function ImportProfiles(arg1:string,arg2:string):Promise; +export function ImportProfilesEncrypted(arg1:string,arg2:string):Promise; + export function InitialiseSecretStore(arg1:string):Promise; +export function IsRecording(arg1:string):Promise; + export function IsSecretStoreInitialised():Promise; export function IsSecretStoreUnlocked():Promise; @@ -57,6 +67,8 @@ export function ListProfilesByProtocol(arg1:string):Promise>; +export function ListRecordings():Promise>>; + export function ListSessions():Promise>; export function LockSecretStore():Promise; @@ -95,8 +107,12 @@ export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise; +export function StartRecording(arg1:string,arg2:number,arg3:number):Promise; + export function StopPortForward(arg1:string,arg2:string):Promise; +export function StopRecording(arg1:string):Promise; + export function TestConnection(arg1:string,arg2:number):Promise>; export function UnlockSecretStore(arg1:string):Promise; diff --git a/frontend/wailsjs/go/api/Service.js b/frontend/wailsjs/go/api/Service.js index b80377d..cb4ad15 100755 --- a/frontend/wailsjs/go/api/Service.js +++ b/frontend/wailsjs/go/api/Service.js @@ -34,6 +34,10 @@ export function DeleteProfileSecrets(arg1) { return window['go']['api']['Service']['DeleteProfileSecrets'](arg1); } +export function DeleteRecording(arg1) { + return window['go']['api']['Service']['DeleteRecording'](arg1); +} + export function DisconnectSession(arg1) { return window['go']['api']['Service']['DisconnectSession'](arg1); } @@ -46,6 +50,10 @@ export function ExportProfiles(arg1) { return window['go']['api']['Service']['ExportProfiles'](arg1); } +export function ExportProfilesEncrypted(arg1, arg2) { + return window['go']['api']['Service']['ExportProfilesEncrypted'](arg1, arg2); +} + export function GetAppInfo() { return window['go']['api']['Service']['GetAppInfo'](); } @@ -58,6 +66,10 @@ export function GetProfile(arg1) { return window['go']['api']['Service']['GetProfile'](arg1); } +export function GetRecording(arg1) { + return window['go']['api']['Service']['GetRecording'](arg1); +} + export function GetSessionHistory(arg1) { return window['go']['api']['Service']['GetSessionHistory'](arg1); } @@ -66,10 +78,18 @@ export function ImportProfiles(arg1, arg2) { return window['go']['api']['Service']['ImportProfiles'](arg1, arg2); } +export function ImportProfilesEncrypted(arg1, arg2) { + return window['go']['api']['Service']['ImportProfilesEncrypted'](arg1, arg2); +} + export function InitialiseSecretStore(arg1) { return window['go']['api']['Service']['InitialiseSecretStore'](arg1); } +export function IsRecording(arg1) { + return window['go']['api']['Service']['IsRecording'](arg1); +} + export function IsSecretStoreInitialised() { return window['go']['api']['Service']['IsSecretStoreInitialised'](); } @@ -110,6 +130,10 @@ export function ListProfilesByTag(arg1) { return window['go']['api']['Service']['ListProfilesByTag'](arg1); } +export function ListRecordings() { + return window['go']['api']['Service']['ListRecordings'](); +} + export function ListSessions() { return window['go']['api']['Service']['ListSessions'](); } @@ -186,10 +210,18 @@ export function StartPortForward(arg1, arg2) { return window['go']['api']['Service']['StartPortForward'](arg1, arg2); } +export function StartRecording(arg1, arg2, arg3) { + return window['go']['api']['Service']['StartRecording'](arg1, arg2, arg3); +} + export function StopPortForward(arg1, arg2) { return window['go']['api']['Service']['StopPortForward'](arg1, arg2); } +export function StopRecording(arg1) { + return window['go']['api']['Service']['StopRecording'](arg1); +} + export function TestConnection(arg1, arg2) { return window['go']['api']['Service']['TestConnection'](arg1, arg2); } diff --git a/internal/api/api.go b/internal/api/api.go index bf4736c..4a85336 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "net" + "path/filepath" "runtime" "sync" "time" @@ -15,6 +16,7 @@ import ( wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" "github.com/copilot-ssh-client/app/internal/audit" + "github.com/copilot-ssh-client/app/internal/exportimport" "github.com/copilot-ssh-client/app/internal/models" "github.com/copilot-ssh-client/app/internal/preferences" "github.com/copilot-ssh-client/app/internal/secrets" @@ -643,6 +645,213 @@ func (s *Service) StopPortForward(sessionID string, forwardID string) error { return s.sessions.StopPortForward(sessionID, forwardID) } +// --------------------------------------------------------------------------- +// Session recording +// --------------------------------------------------------------------------- + +// StartRecording begins recording terminal I/O for a session. +func (s *Service) StartRecording(sessionID string, cols int, rows int) (string, error) { + return s.sessions.StartRecording(sessionID, cols, rows) +} + +// StopRecording stops an active recording for a session. +func (s *Service) StopRecording(sessionID string) error { + return s.sessions.StopRecording(sessionID) +} + +// IsRecording reports whether a session is currently being recorded. +func (s *Service) IsRecording(sessionID string) bool { + return s.sessions.IsRecording(sessionID) +} + +// ListRecordings returns metadata for all saved recordings. +func (s *Service) ListRecordings() ([]map[string]interface{}, error) { + metas, err := s.sessions.ListRecordings() + if err != nil { + return nil, err + } + + result := make([]map[string]interface{}, len(metas)) + for i, m := range metas { + result[i] = map[string]interface{}{ + "id": m.ID, + "sessionId": m.SessionID, + "title": m.Title, + "width": m.Width, + "height": m.Height, + "duration": m.Duration, + "size": m.Size, + "createdAt": m.CreatedAt.Format(time.RFC3339), + "filePath": m.FilePath, + } + } + return result, nil +} + +// GetRecording returns the full recording data for playback. +func (s *Service) GetRecording(id string) (map[string]interface{}, error) { + hdr, events, err := s.sessions.GetRecording(id) + if err != nil { + return nil, err + } + + evtList := make([][]interface{}, len(events)) + for i, e := range events { + evtList[i] = []interface{}{e.Elapsed, string(e.Type), e.Data} + } + + return map[string]interface{}{ + "header": map[string]interface{}{ + "version": hdr.Version, + "width": hdr.Width, + "height": hdr.Height, + "timestamp": hdr.Timestamp, + "title": hdr.Title, + }, + "events": evtList, + }, nil +} + +// DeleteRecording removes a saved recording. +func (s *Service) DeleteRecording(id string) error { + return s.sessions.DeleteRecording(id) +} + +// --------------------------------------------------------------------------- +// Encrypted export / import +// --------------------------------------------------------------------------- + +// ExportProfilesEncrypted encrypts and exports selected profiles to a file. +// The profileIDs parameter selects which profiles to include; if empty, all +// profiles are exported. Returns the file path on success. +func (s *Service) ExportProfilesEncrypted(profileIDs []string, password string) (string, error) { + if s.ctx == nil { + return "", fmt.Errorf("application context not set") + } + + var profiles []models.Profile + if len(profileIDs) == 0 { + // Export all profiles. + all, err := s.store.ListProfiles() + if err != nil { + return "", fmt.Errorf("failed to list profiles: %w", err) + } + for _, p := range all { + profiles = append(profiles, *p) + } + } else { + for _, id := range profileIDs { + p, err := s.store.GetProfile(id) + if err != nil { + continue // skip missing profiles + } + profiles = append(profiles, *p) + } + } + + if len(profiles) == 0 { + return "", fmt.Errorf("no profiles to export") + } + + // Prompt for save location. + path, err := wailsRuntime.SaveFileDialog(s.ctx, wailsRuntime.SaveDialogOptions{ + Title: "Export Encrypted Profiles", + DefaultFilename: "nexus-profiles.nexus", + Filters: []wailsRuntime.FileFilter{ + {DisplayName: "Nexus Export (*.nexus)", Pattern: "*.nexus"}, + }, + }) + if err != nil { + return "", fmt.Errorf("save dialog failed: %w", err) + } + if path == "" { + return "", fmt.Errorf("export cancelled") + } + + // Ensure .nexus extension. + if filepath.Ext(path) != ".nexus" { + path += ".nexus" + } + + if err := exportimport.EncryptAndExport(profiles, password, path); err != nil { + return "", err + } + + s.auditLog.LogExport(len(profiles), false) + return path, nil +} + +// ImportProfilesEncrypted decrypts and imports profiles from an encrypted file. +// Returns the number of profiles successfully imported. +func (s *Service) ImportProfilesEncrypted(password string, strategy string) (int, error) { + if s.ctx == nil { + return 0, fmt.Errorf("application context not set") + } + + // Prompt for file selection. + path, err := wailsRuntime.OpenFileDialog(s.ctx, wailsRuntime.OpenDialogOptions{ + Title: "Import Encrypted Profiles", + Filters: []wailsRuntime.FileFilter{ + {DisplayName: "Nexus Export (*.nexus)", Pattern: "*.nexus"}, + }, + }) + if err != nil { + return 0, fmt.Errorf("open dialog failed: %w", err) + } + if path == "" { + return 0, fmt.Errorf("import cancelled") + } + + profiles, err := exportimport.DecryptAndImport(path, password) + if err != nil { + return 0, err + } + + // Import using the existing strategy logic. + imported := 0 + for _, p := range profiles { + switch strategy { + case "skip": + if existing, _ := s.store.GetProfile(p.ID); existing != nil { + continue + } + if err := s.store.CreateProfile(&p); err != nil { + continue + } + imported++ + + case "overwrite": + p.UpdatedAt = time.Now() + if existing, _ := s.store.GetProfile(p.ID); existing != nil { + if err := s.store.UpdateProfile(&p); err != nil { + continue + } + } else { + if err := s.store.CreateProfile(&p); err != nil { + continue + } + } + imported++ + + case "duplicate": + p.ID = uuid.New().String() + now := time.Now() + p.CreatedAt = now + p.UpdatedAt = now + if err := s.store.CreateProfile(&p); err != nil { + continue + } + imported++ + + default: + return 0, fmt.Errorf("unknown import strategy %q", strategy) + } + } + + s.auditLog.LogImport(imported) + return imported, nil +} + // TestConnection performs a basic TCP reachability check to the given host and port. // This verifies the network path without establishing a full protocol session. func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) { diff --git a/internal/app/app.go b/internal/app/app.go index f4e5825..c13a6b4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,6 +6,7 @@ package app import ( "context" "log" + "path/filepath" "sync" "github.com/wailsapp/wails/v2/pkg/runtime" @@ -15,6 +16,7 @@ import ( "github.com/copilot-ssh-client/app/internal/config" "github.com/copilot-ssh-client/app/internal/knownhosts" "github.com/copilot-ssh-client/app/internal/preferences" + "github.com/copilot-ssh-client/app/internal/recording" "github.com/copilot-ssh-client/app/internal/secrets" "github.com/copilot-ssh-client/app/internal/sessions" "github.com/copilot-ssh-client/app/internal/storage" @@ -97,8 +99,12 @@ func (a *App) Startup(ctx context.Context) { log.Printf("warning: failed to load preferences, using defaults: %v", err) } + // Initialise session recording store. + recordDir := filepath.Join(cfg.DataDir, "recordings") + recordStore := recording.NewStore(recordDir) + // Initialise the session manager. - a.SessionManager = sessions.NewManager(store, a.SecretStore, a.KeychainStore, a.AuditLog) + a.SessionManager = sessions.NewManager(store, a.SecretStore, a.KeychainStore, a.AuditLog, recordStore) a.SessionManager.SetContext(ctx) // Wire the API service with all backing stores now that they exist. diff --git a/internal/exportimport/exportimport.go b/internal/exportimport/exportimport.go new file mode 100644 index 0000000..01c872a --- /dev/null +++ b/internal/exportimport/exportimport.go @@ -0,0 +1,188 @@ +// Package exportimport provides encrypted profile export and import. +// Profiles are serialised to JSON, then encrypted with AES-256-GCM using +// an Argon2id-derived key from a user-supplied password. No hard-coded +// secrets are used. The file format includes a version byte, salt, nonce, +// and ciphertext — everything needed for self-contained decryption. +package exportimport + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "time" + + "golang.org/x/crypto/argon2" + + "github.com/copilot-ssh-client/app/internal/models" +) + +const ( + // File format version. Increment if the envelope format changes. + formatVersion byte = 1 + + // Argon2id parameters for export encryption (same strength as the vault). + exportArgonTime = 3 + exportArgonMemory = 128 * 1024 // 128 MiB + exportArgonThreads = 4 + exportArgonKeyLen = 32 // AES-256 + + saltLength = 32 + nonceLength = 12 // AES-GCM standard nonce size +) + +// ExportBundle is the cleartext payload that gets encrypted. +type ExportBundle struct { + Version int `json:"version"` + ExportedAt string `json:"exportedAt"` // ISO 8601 + Profiles []models.Profile `json:"profiles"` + Count int `json:"count"` +} + +// EncryptAndExport serialises the given profiles, encrypts the JSON with +// AES-256-GCM (Argon2id-derived key), and writes the result to the file. +// +// File layout: [1 byte version][32 bytes salt][12 bytes nonce][ciphertext] +func EncryptAndExport(profiles []models.Profile, password, filePath string) error { + if password == "" { + return errors.New("exportimport: password cannot be empty") + } + if len(profiles) == 0 { + return errors.New("exportimport: no profiles to export") + } + + bundle := ExportBundle{ + Version: 1, + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Profiles: profiles, + Count: len(profiles), + } + + plaintext, err := json.MarshalIndent(bundle, "", " ") + if err != nil { + return fmt.Errorf("exportimport: failed to serialise profiles: %w", err) + } + + // Generate random salt and nonce. + salt := make([]byte, saltLength) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return fmt.Errorf("exportimport: failed to generate salt: %w", err) + } + + nonce := make([]byte, nonceLength) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return fmt.Errorf("exportimport: failed to generate nonce: %w", err) + } + + // Derive encryption key from password. + key := argon2.IDKey( + []byte(password), + salt, + exportArgonTime, + exportArgonMemory, + exportArgonThreads, + exportArgonKeyLen, + ) + + // Encrypt with AES-256-GCM. + block, err := aes.NewCipher(key) + if err != nil { + return fmt.Errorf("exportimport: cipher init failed: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return fmt.Errorf("exportimport: GCM init failed: %w", err) + } + + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + + // Write the file: version + salt + nonce + ciphertext. + f, err := os.Create(filePath) + if err != nil { + return fmt.Errorf("exportimport: failed to create file: %w", err) + } + defer f.Close() + + if _, err := f.Write([]byte{formatVersion}); err != nil { + return fmt.Errorf("exportimport: write version failed: %w", err) + } + if _, err := f.Write(salt); err != nil { + return fmt.Errorf("exportimport: write salt failed: %w", err) + } + if _, err := f.Write(nonce); err != nil { + return fmt.Errorf("exportimport: write nonce failed: %w", err) + } + if _, err := f.Write(ciphertext); err != nil { + return fmt.Errorf("exportimport: write ciphertext failed: %w", err) + } + + return f.Sync() +} + +// DecryptAndImport reads an encrypted export file, decrypts it using the +// given password, and returns the contained profiles. +func DecryptAndImport(filePath, password string) ([]models.Profile, error) { + if password == "" { + return nil, errors.New("exportimport: password cannot be empty") + } + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("exportimport: failed to read file: %w", err) + } + + // Minimum size: version(1) + salt(32) + nonce(12) + at least 1 byte ciphertext + GCM tag(16). + minSize := 1 + saltLength + nonceLength + 1 + 16 + if len(data) < minSize { + return nil, errors.New("exportimport: file is too small or corrupt") + } + + // Parse envelope. + version := data[0] + if version != formatVersion { + return nil, fmt.Errorf("exportimport: unsupported format version %d", version) + } + + salt := data[1 : 1+saltLength] + nonce := data[1+saltLength : 1+saltLength+nonceLength] + ciphertext := data[1+saltLength+nonceLength:] + + // Derive decryption key. + key := argon2.IDKey( + []byte(password), + salt, + exportArgonTime, + exportArgonMemory, + exportArgonThreads, + exportArgonKeyLen, + ) + + // Decrypt with AES-256-GCM. + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("exportimport: cipher init failed: %w", err) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("exportimport: GCM init failed: %w", err) + } + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, errors.New("exportimport: decryption failed — wrong password or corrupt file") + } + + // Parse the JSON bundle. + var bundle ExportBundle + if err := json.Unmarshal(plaintext, &bundle); err != nil { + return nil, fmt.Errorf("exportimport: invalid bundle JSON: %w", err) + } + + return bundle.Profiles, nil +} diff --git a/internal/exportimport/exportimport_test.go b/internal/exportimport/exportimport_test.go new file mode 100644 index 0000000..9c00007 --- /dev/null +++ b/internal/exportimport/exportimport_test.go @@ -0,0 +1,131 @@ +package exportimport + +import ( + "os" + "path/filepath" + "testing" + + "github.com/copilot-ssh-client/app/internal/models" +) + +func TestEncryptDecryptRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test-export.nexus") + + profiles := []models.Profile{ + { + ID: "test-1", + DisplayName: "Test Server", + Protocol: models.ProtocolSSH, + Host: "example.com", + Port: 22, + Username: "admin", + }, + { + ID: "test-2", + DisplayName: "Dev Box", + Protocol: models.ProtocolSSH, + Host: "dev.example.com", + Port: 2222, + Username: "developer", + }, + } + + password := "strong-test-password-2024!" + + // Encrypt and export. + if err := EncryptAndExport(profiles, password, path); err != nil { + t.Fatalf("EncryptAndExport failed: %v", err) + } + + // Verify file exists and has content. + info, err := os.Stat(path) + if err != nil { + t.Fatalf("exported file not found: %v", err) + } + if info.Size() == 0 { + t.Fatal("exported file is empty") + } + + // Decrypt and import. + imported, err := DecryptAndImport(path, password) + if err != nil { + t.Fatalf("DecryptAndImport failed: %v", err) + } + + if len(imported) != len(profiles) { + t.Fatalf("expected %d profiles, got %d", len(profiles), len(imported)) + } + + for i, p := range imported { + if p.ID != profiles[i].ID { + t.Errorf("profile %d: expected ID %q, got %q", i, profiles[i].ID, p.ID) + } + if p.DisplayName != profiles[i].DisplayName { + t.Errorf("profile %d: expected DisplayName %q, got %q", i, profiles[i].DisplayName, p.DisplayName) + } + if p.Host != profiles[i].Host { + t.Errorf("profile %d: expected Host %q, got %q", i, profiles[i].Host, p.Host) + } + } +} + +func TestDecryptWrongPassword(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test-export.nexus") + + profiles := []models.Profile{ + { + ID: "test-1", + DisplayName: "Test", + Protocol: models.ProtocolSSH, + Host: "example.com", + Port: 22, + }, + } + + if err := EncryptAndExport(profiles, "correct-password", path); err != nil { + t.Fatalf("EncryptAndExport failed: %v", err) + } + + _, err := DecryptAndImport(path, "wrong-password") + if err == nil { + t.Fatal("expected error with wrong password, got nil") + } +} + +func TestEncryptEmptyProfiles(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.nexus") + + err := EncryptAndExport(nil, "password", path) + if err == nil { + t.Fatal("expected error for empty profiles, got nil") + } +} + +func TestEncryptEmptyPassword(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.nexus") + + profiles := []models.Profile{{ID: "1"}} + err := EncryptAndExport(profiles, "", path) + if err == nil { + t.Fatal("expected error for empty password, got nil") + } +} + +func TestDecryptCorruptFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "corrupt.nexus") + + // Write garbage data. + if err := os.WriteFile(path, []byte("corrupt"), 0600); err != nil { + t.Fatal(err) + } + + _, err := DecryptAndImport(path, "password") + if err == nil { + t.Fatal("expected error for corrupt file, got nil") + } +} diff --git a/internal/recording/recorder.go b/internal/recording/recorder.go new file mode 100644 index 0000000..42a0a9c --- /dev/null +++ b/internal/recording/recorder.go @@ -0,0 +1,368 @@ +// Package recording provides session recording in asciicast v2 format. +// Recordings capture terminal I/O with timestamps, enabling playback of +// complete sessions for auditing, training, or troubleshooting. +// +// File format: https://docs.asciinema.org/manual/asciicast/v2/ +// +// The header line is a JSON object with metadata (version, dimensions, etc.) +// followed by one JSON array per event: [elapsed_secs, event_type, data]. +package recording + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// EventType distinguishes input from output events. +type EventType string + +const ( + // EventOutput represents data sent from the remote host to the terminal. + EventOutput EventType = "o" + // EventInput represents data sent from the user to the remote host. + EventInput EventType = "i" +) + +// Header is the asciicast v2 file header (first line). +type Header struct { + Version int `json:"version"` + Width int `json:"width"` + Height int `json:"height"` + Timestamp int64 `json:"timestamp"` + Title string `json:"title,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +// Event is a single recorded I/O event: [elapsed_seconds, type, data]. +type Event struct { + Elapsed float64 `json:"-"` + Type EventType `json:"-"` + Data string `json:"-"` +} + +// MarshalJSON serialises an event as the asciicast v2 array format. +func (e Event) MarshalJSON() ([]byte, error) { + return json.Marshal([]interface{}{e.Elapsed, string(e.Type), e.Data}) +} + +// RecordingMeta describes a saved recording without loading the full data. +type RecordingMeta struct { + ID string `json:"id"` + SessionID string `json:"sessionId"` + Title string `json:"title"` + Width int `json:"width"` + Height int `json:"height"` + Duration float64 `json:"duration"` // seconds + Size int64 `json:"size"` // file size in bytes + CreatedAt time.Time `json:"createdAt"` + FilePath string `json:"filePath"` +} + +// Recorder captures terminal I/O for a single session. +type Recorder struct { + mu sync.Mutex + file *os.File + encoder *json.Encoder + startTime time.Time + header Header + eventCount int + closed bool + filePath string +} + +// NewRecorder creates a new recording file and writes the header. +func NewRecorder(dir, id, title string, cols, rows int) (*Recorder, error) { + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("recording: failed to create directory: %w", err) + } + + filename := fmt.Sprintf("%s.cast", id) + path := filepath.Join(dir, filename) + + f, err := os.Create(path) + if err != nil { + return nil, fmt.Errorf("recording: failed to create file: %w", err) + } + + now := time.Now() + hdr := Header{ + Version: 2, + Width: cols, + Height: rows, + Timestamp: now.Unix(), + Title: title, + Env: map[string]string{ + "TERM": "xterm-256color", + }, + } + + enc := json.NewEncoder(f) + enc.SetEscapeHTML(false) + if err := enc.Encode(hdr); err != nil { + f.Close() + os.Remove(path) + return nil, fmt.Errorf("recording: failed to write header: %w", err) + } + + return &Recorder{ + file: f, + encoder: enc, + startTime: now, + header: hdr, + filePath: path, + }, nil +} + +// WriteOutput records terminal output (data from remote host). +func (r *Recorder) WriteOutput(data []byte) { + r.writeEvent(EventOutput, data) +} + +// WriteInput records terminal input (data from user). +func (r *Recorder) WriteInput(data []byte) { + r.writeEvent(EventInput, data) +} + +// writeEvent appends a timestamped event to the recording file. +func (r *Recorder) writeEvent(eventType EventType, data []byte) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.closed || r.file == nil { + return + } + + elapsed := time.Since(r.startTime).Seconds() + evt := Event{ + Elapsed: elapsed, + Type: eventType, + Data: string(data), + } + + // Write as a JSON array line: [elapsed, "o"/"i", "data"] + if err := r.encoder.Encode(evt); err != nil { + // Non-fatal — continue recording even if one event fails. + return + } + r.eventCount++ +} + +// UpdateSize records a terminal resize (not part of asciicast v2 events, +// but useful for future playback fidelity). Currently a no-op placeholder. +func (r *Recorder) UpdateSize(cols, rows int) { + // asciicast v2 does not have a standard resize event. Future extension + // could use a custom event type like "r" with JSON dimensions. +} + +// Close flushes and closes the recording file. +func (r *Recorder) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.closed { + return nil + } + r.closed = true + + if r.file != nil { + if err := r.file.Sync(); err != nil { + r.file.Close() + return fmt.Errorf("recording: sync failed: %w", err) + } + return r.file.Close() + } + return nil +} + +// Duration returns the elapsed time since recording started. +func (r *Recorder) Duration() float64 { + r.mu.Lock() + defer r.mu.Unlock() + return time.Since(r.startTime).Seconds() +} + +// FilePath returns the absolute path to the recording file. +func (r *Recorder) FilePath() string { + return r.filePath +} + +// EventCount returns the number of events recorded so far. +func (r *Recorder) EventCount() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.eventCount +} + +// --------------------------------------------------------------------------- +// Recording store — manages listing, reading, and deleting recordings. +// --------------------------------------------------------------------------- + +// Store manages the recording directory. +type Store struct { + dir string +} + +// NewStore creates a recording store at the given directory. +func NewStore(dir string) *Store { + return &Store{dir: dir} +} + +// Dir returns the recordings directory path. +func (s *Store) Dir() string { + return s.dir +} + +// List returns metadata for all recordings in the store. +func (s *Store) List() ([]RecordingMeta, error) { + if err := os.MkdirAll(s.dir, 0700); err != nil { + return nil, err + } + + entries, err := os.ReadDir(s.dir) + if err != nil { + return nil, fmt.Errorf("recording: failed to read directory: %w", err) + } + + var results []RecordingMeta + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".cast") { + continue + } + + meta, err := s.readMeta(entry.Name()) + if err != nil { + continue // skip unreadable files + } + results = append(results, *meta) + } + + // Sort by creation time, newest first. + sort.Slice(results, func(i, j int) bool { + return results[i].CreatedAt.After(results[j].CreatedAt) + }) + + return results, nil +} + +// Get reads the full recording (header + events) from a file. +func (s *Store) Get(id string) (*Header, []Event, error) { + path := filepath.Join(s.dir, id+".cast") + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, fmt.Errorf("recording: failed to read file: %w", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 1 { + return nil, nil, fmt.Errorf("recording: empty file") + } + + // Parse header (first line). + var hdr Header + if err := json.Unmarshal([]byte(lines[0]), &hdr); err != nil { + return nil, nil, fmt.Errorf("recording: invalid header: %w", err) + } + + // Parse events (remaining lines). + events := make([]Event, 0, len(lines)-1) + for _, line := range lines[1:] { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var raw []json.RawMessage + if err := json.Unmarshal([]byte(line), &raw); err != nil { + continue + } + if len(raw) != 3 { + continue + } + + var elapsed float64 + var evtType, evtData string + if err := json.Unmarshal(raw[0], &elapsed); err != nil { + continue + } + if err := json.Unmarshal(raw[1], &evtType); err != nil { + continue + } + if err := json.Unmarshal(raw[2], &evtData); err != nil { + continue + } + + events = append(events, Event{ + Elapsed: elapsed, + Type: EventType(evtType), + Data: evtData, + }) + } + + return &hdr, events, nil +} + +// Delete removes a recording file. +func (s *Store) Delete(id string) error { + path := filepath.Join(s.dir, id+".cast") + if err := os.Remove(path); err != nil { + return fmt.Errorf("recording: failed to delete: %w", err) + } + return nil +} + +// readMeta reads just the header from a .cast file to produce metadata. +func (s *Store) readMeta(filename string) (*RecordingMeta, error) { + path := filepath.Join(s.dir, filename) + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return nil, err + } + + var hdr Header + dec := json.NewDecoder(f) + if err := dec.Decode(&hdr); err != nil { + return nil, err + } + + // Calculate duration by scanning for last event timestamp. + var duration float64 + for dec.More() { + var raw json.RawMessage + if err := dec.Decode(&raw); err != nil { + break + } + var arr []json.RawMessage + if err := json.Unmarshal(raw, &arr); err != nil || len(arr) < 1 { + continue + } + var elapsed float64 + if err := json.Unmarshal(arr[0], &elapsed); err == nil { + duration = elapsed + } + } + + id := strings.TrimSuffix(filename, ".cast") + + return &RecordingMeta{ + ID: id, + SessionID: id, // recording ID matches session ID by default + Title: hdr.Title, + Width: hdr.Width, + Height: hdr.Height, + Duration: duration, + Size: info.Size(), + CreatedAt: time.Unix(hdr.Timestamp, 0), + FilePath: path, + }, nil +} diff --git a/internal/recording/recorder_test.go b/internal/recording/recorder_test.go new file mode 100644 index 0000000..44fd15b --- /dev/null +++ b/internal/recording/recorder_test.go @@ -0,0 +1,165 @@ +package recording + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestRecorderWriteAndClose(t *testing.T) { + dir := t.TempDir() + id := "test-session-001" + + rec, err := NewRecorder(dir, id, "Test Recording", 80, 24) + if err != nil { + t.Fatalf("NewRecorder failed: %v", err) + } + + // Write some output events. + rec.WriteOutput([]byte("Welcome to the server\r\n")) + rec.WriteOutput([]byte("$ ")) + + // Write some input events. + rec.WriteInput([]byte("ls -la\r\n")) + + // Write more output. + rec.WriteOutput([]byte("total 42\r\n")) + + if rec.EventCount() != 4 { + t.Errorf("expected 4 events, got %d", rec.EventCount()) + } + + if err := rec.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + + // Verify the file exists and has correct format. + path := filepath.Join(dir, id+".cast") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read recording: %v", err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) < 5 { // 1 header + 4 events + t.Fatalf("expected at least 5 lines, got %d", len(lines)) + } + + // Verify header. + var hdr Header + if err := json.Unmarshal([]byte(lines[0]), &hdr); err != nil { + t.Fatalf("failed to parse header: %v", err) + } + if hdr.Version != 2 { + t.Errorf("expected version 2, got %d", hdr.Version) + } + if hdr.Width != 80 || hdr.Height != 24 { + t.Errorf("expected 80x24, got %dx%d", hdr.Width, hdr.Height) + } + + // Verify first event is output. + var evt []json.RawMessage + if err := json.Unmarshal([]byte(lines[1]), &evt); err != nil { + t.Fatalf("failed to parse first event: %v", err) + } + if len(evt) != 3 { + t.Fatalf("expected 3-element array, got %d", len(evt)) + } + var evtType string + json.Unmarshal(evt[1], &evtType) + if evtType != "o" { + t.Errorf("expected event type 'o', got %q", evtType) + } +} + +func TestStoreListAndGet(t *testing.T) { + dir := t.TempDir() + + // Create a recording. + rec, err := NewRecorder(dir, "session-abc", "My Session", 120, 40) + if err != nil { + t.Fatalf("NewRecorder failed: %v", err) + } + rec.WriteOutput([]byte("hello world\r\n")) + time.Sleep(10 * time.Millisecond) + rec.WriteOutput([]byte("done\r\n")) + rec.Close() + + // Use the store to list. + store := NewStore(dir) + metas, err := store.List() + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(metas) != 1 { + t.Fatalf("expected 1 recording, got %d", len(metas)) + } + if metas[0].ID != "session-abc" { + t.Errorf("expected ID 'session-abc', got %q", metas[0].ID) + } + if metas[0].Width != 120 || metas[0].Height != 40 { + t.Errorf("unexpected dimensions: %dx%d", metas[0].Width, metas[0].Height) + } + + // Read full recording. + hdr, events, err := store.Get("session-abc") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + if hdr.Title != "My Session" { + t.Errorf("expected title 'My Session', got %q", hdr.Title) + } + if len(events) != 2 { + t.Errorf("expected 2 events, got %d", len(events)) + } +} + +func TestStoreDelete(t *testing.T) { + dir := t.TempDir() + + rec, err := NewRecorder(dir, "to-delete", "Delete Me", 80, 24) + if err != nil { + t.Fatalf("NewRecorder failed: %v", err) + } + rec.WriteOutput([]byte("test\n")) + rec.Close() + + store := NewStore(dir) + + // Verify it exists. + metas, _ := store.List() + if len(metas) != 1 { + t.Fatalf("expected 1 recording, got %d", len(metas)) + } + + // Delete. + if err := store.Delete("to-delete"); err != nil { + t.Fatalf("Delete failed: %v", err) + } + + // Verify it's gone. + metas, _ = store.List() + if len(metas) != 0 { + t.Errorf("expected 0 recordings, got %d", len(metas)) + } +} + +func TestDoubleClose(t *testing.T) { + dir := t.TempDir() + rec, err := NewRecorder(dir, "double-close", "Test", 80, 24) + if err != nil { + t.Fatalf("NewRecorder failed: %v", err) + } + rec.WriteOutput([]byte("test")) + + if err := rec.Close(); err != nil { + t.Fatalf("first Close failed: %v", err) + } + // Second close should be a no-op. + if err := rec.Close(); err != nil { + t.Fatalf("second Close failed: %v", err) + } +} diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index cc5b616..24c9128 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -17,6 +17,7 @@ import ( "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" @@ -32,6 +33,7 @@ type Session struct { 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. @@ -44,17 +46,19 @@ type Manager struct { 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) *Manager { +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, } } @@ -205,6 +209,14 @@ func (m *Manager) WriteToSession(sessionID string, data []byte) error { 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) @@ -440,6 +452,13 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin "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) { @@ -512,6 +531,13 @@ func (m *Manager) connectTelnet(sess *Session) error { "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) { @@ -695,6 +721,111 @@ func (m *Manager) FetchGitHubRepos(sessionID string) ([]models.GitHubRepo, error 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 // --------------------------------------------------------------------------- @@ -855,6 +986,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) { + // Stop any active recording. + if sess.recorder != nil { + sess.recorder.Close() + sess.recorder = nil + } + if sess.sshClient != nil { sess.sshClient.Close() } diff --git a/internal/sessions/manager_test.go b/internal/sessions/manager_test.go index 39103dc..0fd980a 100644 --- a/internal/sessions/manager_test.go +++ b/internal/sessions/manager_test.go @@ -13,7 +13,7 @@ import ( func TestNewManager(t *testing.T) { // NewManager requires backing services; pass nil for lightweight tests // where no actual connections are made. - mgr := NewManager(nil, nil, nil, nil) + mgr := NewManager(nil, nil, nil, nil, nil) if mgr == nil { t.Fatal("NewManager() returned nil") } @@ -30,7 +30,7 @@ func TestNewManager(t *testing.T) { // --------------------------------------------------------------------------- func TestListSessionsEmpty(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil) + mgr := NewManager(nil, nil, nil, nil, nil) sessions := mgr.ListSessions() if len(sessions) != 0 { t.Errorf("expected 0 sessions, got %d", len(sessions)) @@ -42,7 +42,7 @@ func TestListSessionsEmpty(t *testing.T) { // --------------------------------------------------------------------------- func TestCreateTabName(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil) + mgr := NewManager(nil, nil, nil, nil, nil) tests := []struct { name string @@ -93,7 +93,7 @@ func TestCreateTabName(t *testing.T) { // --------------------------------------------------------------------------- func TestRenameSessionNotFound(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil) + mgr := NewManager(nil, nil, nil, nil, nil) err := mgr.RenameSession("nonexistent-id", "New Name") if err == nil { t.Error("expected error when renaming non-existent session") @@ -105,7 +105,7 @@ func TestRenameSessionNotFound(t *testing.T) { // --------------------------------------------------------------------------- func TestPinSessionNotFound(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil) + mgr := NewManager(nil, nil, nil, nil, nil) err := mgr.PinSession("nonexistent-id", true) if err == nil { t.Error("expected error when pinning non-existent session") @@ -117,7 +117,7 @@ func TestPinSessionNotFound(t *testing.T) { // --------------------------------------------------------------------------- func TestGetSessionNotFound(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil) + mgr := NewManager(nil, nil, nil, nil, nil) _, err := mgr.GetSession("nonexistent-id") if err == nil { t.Error("expected error when getting non-existent session") @@ -129,7 +129,7 @@ func TestGetSessionNotFound(t *testing.T) { // --------------------------------------------------------------------------- func TestDisconnectNotFound(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil) + mgr := NewManager(nil, nil, nil, nil, nil) err := mgr.Disconnect("nonexistent-id") if err == nil { t.Error("expected error when disconnecting non-existent session") @@ -141,7 +141,7 @@ func TestDisconnectNotFound(t *testing.T) { // --------------------------------------------------------------------------- func TestCloseAllEmpty(t *testing.T) { - mgr := NewManager(nil, nil, nil, nil) + mgr := NewManager(nil, nil, nil, nil, nil) // Should not panic. mgr.CloseAll() if len(mgr.ListSessions()) != 0 {