package history import ( "encoding/json" "os" "path/filepath" "sort" "time" "github.com/Gu1llaum-3/sshm/internal/config" ) // ConnectionHistory represents the history of SSH connections type ConnectionHistory struct { Connections map[string]ConnectionInfo `json:"connections"` } // PortForwardConfig stores port forwarding configuration type PortForwardConfig struct { Type string `json:"type"` // "local", "remote", "dynamic" LocalPort string `json:"local_port"` RemoteHost string `json:"remote_host"` RemotePort string `json:"remote_port"` BindAddress string `json:"bind_address"` } // ConnectionInfo stores information about a specific connection type ConnectionInfo struct { HostName string `json:"host_name"` LastConnect time.Time `json:"last_connect"` ConnectCount int `json:"connect_count"` PortForwarding *PortForwardConfig `json:"port_forwarding,omitempty"` } // HistoryManager manages the connection history type HistoryManager struct { historyPath string history *ConnectionHistory } // NewHistoryManager creates a new history manager func NewHistoryManager() (*HistoryManager, error) { configDir, err := config.GetSSHMConfigDir() if err != nil { return nil, err } // Ensure config dir exists if err := os.MkdirAll(configDir, 0755); err != nil { return nil, err } historyPath := filepath.Join(configDir, "sshm_history.json") // Migration: check if old history file exists and migrate it if err := migrateOldHistoryFile(historyPath); err != nil { // Don't fail if migration fails, just log it // In a production environment, you might want to log this properly } hm := &HistoryManager{ historyPath: historyPath, history: &ConnectionHistory{Connections: make(map[string]ConnectionInfo)}, } // Load existing history if it exists err = hm.loadHistory() if err != nil { // If file doesn't exist, that's okay - we'll create it when needed if !os.IsNotExist(err) { return nil, err } } return hm, nil } // migrateOldHistoryFile migrates the old history file from ~/.ssh to ~/.config/sshm // TODO: Remove this migration logic in v2.0.0 (introduced in v1.6.0) func migrateOldHistoryFile(newHistoryPath string) error { // Check if new file already exists, skip migration if _, err := os.Stat(newHistoryPath); err == nil { return nil // New file exists, no migration needed } // Get old history file path - use same logic as SSH config location sshDir, err := config.GetSSHDirectory() if err != nil { return err } oldHistoryPath := filepath.Join(sshDir, "sshm_history.json") // Check if old file exists if _, err := os.Stat(oldHistoryPath); os.IsNotExist(err) { return nil // Old file doesn't exist, nothing to migrate } // Read old file data, err := os.ReadFile(oldHistoryPath) if err != nil { return err } // Write to new location if err := os.WriteFile(newHistoryPath, data, 0644); err != nil { return err } // Remove old file only if write was successful if err := os.Remove(oldHistoryPath); err != nil { // Don't fail if we can't remove the old file // The migration was successful even if cleanup failed } return nil } // loadHistory loads the connection history from the JSON file func (hm *HistoryManager) loadHistory() error { data, err := os.ReadFile(hm.historyPath) if err != nil { return err } return json.Unmarshal(data, hm.history) } // saveHistory saves the connection history to the JSON file func (hm *HistoryManager) saveHistory() error { // Ensure the directory exists dir := filepath.Dir(hm.historyPath) if err := os.MkdirAll(dir, 0700); err != nil { return err } data, err := json.MarshalIndent(hm.history, "", " ") if err != nil { return err } return os.WriteFile(hm.historyPath, data, 0600) } // RecordConnection records a new connection for the specified host func (hm *HistoryManager) RecordConnection(hostName string) error { now := time.Now() if conn, exists := hm.history.Connections[hostName]; exists { // Update existing connection conn.LastConnect = now conn.ConnectCount++ hm.history.Connections[hostName] = conn } else { // Create new connection record hm.history.Connections[hostName] = ConnectionInfo{ HostName: hostName, LastConnect: now, ConnectCount: 1, } } return hm.saveHistory() } // GetLastConnectionTime returns the last connection time for a host func (hm *HistoryManager) GetLastConnectionTime(hostName string) (time.Time, bool) { if conn, exists := hm.history.Connections[hostName]; exists { return conn.LastConnect, true } return time.Time{}, false } // GetConnectionCount returns the total number of connections for a host func (hm *HistoryManager) GetConnectionCount(hostName string) int { if conn, exists := hm.history.Connections[hostName]; exists { return conn.ConnectCount } return 0 } // SortHostsByLastUsed sorts hosts by their last connection time (most recent first) func (hm *HistoryManager) SortHostsByLastUsed(hosts []config.SSHHost) []config.SSHHost { sorted := make([]config.SSHHost, len(hosts)) copy(sorted, hosts) sort.Slice(sorted, func(i, j int) bool { timeI, existsI := hm.GetLastConnectionTime(sorted[i].Name) timeJ, existsJ := hm.GetLastConnectionTime(sorted[j].Name) // If both have history, sort by most recent first if existsI && existsJ { return timeI.After(timeJ) } // Hosts with history come before hosts without history if existsI && !existsJ { return true } if !existsI && existsJ { return false } // If neither has history, sort alphabetically return sorted[i].Name < sorted[j].Name }) return sorted } // SortHostsByMostUsed sorts hosts by their connection count (most used first) func (hm *HistoryManager) SortHostsByMostUsed(hosts []config.SSHHost) []config.SSHHost { sorted := make([]config.SSHHost, len(hosts)) copy(sorted, hosts) sort.Slice(sorted, func(i, j int) bool { countI := hm.GetConnectionCount(sorted[i].Name) countJ := hm.GetConnectionCount(sorted[j].Name) // If counts are different, sort by count (highest first) if countI != countJ { return countI > countJ } // If counts are equal, sort by most recent timeI, existsI := hm.GetLastConnectionTime(sorted[i].Name) timeJ, existsJ := hm.GetLastConnectionTime(sorted[j].Name) if existsI && existsJ { return timeI.After(timeJ) } // If neither has history, sort alphabetically return sorted[i].Name < sorted[j].Name }) return sorted } // CleanupOldEntries removes connection history for hosts that no longer exist func (hm *HistoryManager) CleanupOldEntries(currentHosts []config.SSHHost) error { // Create a set of current host names currentHostNames := make(map[string]bool) for _, host := range currentHosts { currentHostNames[host.Name] = true } // Remove entries for hosts that no longer exist for hostName := range hm.history.Connections { if !currentHostNames[hostName] { delete(hm.history.Connections, hostName) } } return hm.saveHistory() } // GetAllConnectionsInfo returns all connection information sorted by last connection time func (hm *HistoryManager) GetAllConnectionsInfo() []ConnectionInfo { var connections []ConnectionInfo for _, conn := range hm.history.Connections { connections = append(connections, conn) } sort.Slice(connections, func(i, j int) bool { return connections[i].LastConnect.After(connections[j].LastConnect) }) return connections } // RecordPortForwarding saves port forwarding configuration for a host func (hm *HistoryManager) RecordPortForwarding(hostName, forwardType, localPort, remoteHost, remotePort, bindAddress string) error { now := time.Now() portForwardConfig := &PortForwardConfig{ Type: forwardType, LocalPort: localPort, RemoteHost: remoteHost, RemotePort: remotePort, BindAddress: bindAddress, } if conn, exists := hm.history.Connections[hostName]; exists { // Update existing connection conn.LastConnect = now conn.ConnectCount++ conn.PortForwarding = portForwardConfig hm.history.Connections[hostName] = conn } else { // Create new connection record hm.history.Connections[hostName] = ConnectionInfo{ HostName: hostName, LastConnect: now, ConnectCount: 1, PortForwarding: portForwardConfig, } } return hm.saveHistory() } // GetPortForwardingConfig retrieves the last used port forwarding configuration for a host func (hm *HistoryManager) GetPortForwardingConfig(hostName string) *PortForwardConfig { if conn, exists := hm.history.Connections[hostName]; exists { return conn.PortForwarding } return nil }