mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-10-19 01:17:20 +02:00
Track SSH connections (configured + manual) with searchable history view. Press Ctrl+H to view history, Ctrl+L to return. Add manual connections to config with 'a'. - Parse and store manual SSH connections - History TUI with search and filtering - Connection count and timestamps
406 lines
11 KiB
Go
406 lines
11 KiB
Go
package history
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"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
|
|
}
|
|
|
|
// ManualConnection represents a manual SSH connection (e.g., ssh user@host -p 2222)
|
|
type ManualConnection struct {
|
|
User string
|
|
Hostname string
|
|
Port string
|
|
Identity string
|
|
}
|
|
|
|
// RecordManualConnection records a manual SSH connection (like ssh user@host -p 2222 -i key)
|
|
// These are stored with a generated host name like "manual:user@host:port"
|
|
func (hm *HistoryManager) RecordManualConnection(conn ManualConnection) error {
|
|
// Generate a unique identifier for this manual connection
|
|
hostID := generateManualHostID(conn)
|
|
|
|
now := time.Now()
|
|
|
|
if existingConn, exists := hm.history.Connections[hostID]; exists {
|
|
// Update existing connection
|
|
existingConn.LastConnect = now
|
|
existingConn.ConnectCount++
|
|
hm.history.Connections[hostID] = existingConn
|
|
} else {
|
|
// Create new connection record
|
|
hm.history.Connections[hostID] = ConnectionInfo{
|
|
HostName: hostID,
|
|
LastConnect: now,
|
|
ConnectCount: 1,
|
|
}
|
|
}
|
|
|
|
return hm.saveHistory()
|
|
}
|
|
|
|
// generateManualHostID generates a unique ID for manual connections
|
|
func generateManualHostID(conn ManualConnection) string {
|
|
// Format: manual:user@hostname:port
|
|
user := conn.User
|
|
if user == "" {
|
|
user = "default"
|
|
}
|
|
port := conn.Port
|
|
if port == "" {
|
|
port = "22"
|
|
}
|
|
return fmt.Sprintf("manual:%s@%s:%s", user, conn.Hostname, port)
|
|
}
|
|
|
|
// IsManualConnection checks if a hostname represents a manual connection
|
|
func IsManualConnection(hostName string) bool {
|
|
return len(hostName) > 7 && hostName[:7] == "manual:"
|
|
}
|
|
|
|
// ParseManualConnectionID parses a manual connection ID back into its components
|
|
func ParseManualConnectionID(hostID string) (user, hostname, port string, ok bool) {
|
|
if !IsManualConnection(hostID) {
|
|
return "", "", "", false
|
|
}
|
|
|
|
// Remove "manual:" prefix
|
|
parts := hostID[7:] // Skip "manual:"
|
|
|
|
// Split by last ':'
|
|
lastColon := -1
|
|
for i := len(parts) - 1; i >= 0; i-- {
|
|
if parts[i] == ':' {
|
|
lastColon = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if lastColon == -1 {
|
|
return "", "", "", false
|
|
}
|
|
|
|
port = parts[lastColon+1:]
|
|
userHost := parts[:lastColon]
|
|
|
|
// Split user@host
|
|
atSign := -1
|
|
for i := 0; i < len(userHost); i++ {
|
|
if userHost[i] == '@' {
|
|
atSign = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if atSign == -1 {
|
|
return "", "", "", false
|
|
}
|
|
|
|
user = userHost[:atSign]
|
|
hostname = userHost[atSign+1:]
|
|
|
|
return user, hostname, port, true
|
|
}
|