mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-03-14 03:41:27 +01:00
feat: add SSH connection history with Ctrl+H navigation
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
This commit is contained in:
@@ -2,6 +2,7 @@ package history
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -306,3 +307,99 @@ func (hm *HistoryManager) GetPortForwardingConfig(hostName string) *PortForwardC
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
95
internal/history/parser.go
Normal file
95
internal/history/parser.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParseSSHArgs parses SSH command line arguments and extracts connection details
|
||||
// It handles formats like: user@host, -p port, -i identity, etc.
|
||||
func ParseSSHArgs(args []string) (*ManualConnection, bool) {
|
||||
if len(args) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
conn := &ManualConnection{
|
||||
Port: "22", // Default SSH port
|
||||
}
|
||||
|
||||
// Get current user as default
|
||||
currentUser, err := user.Current()
|
||||
if err == nil {
|
||||
conn.User = currentUser.Username
|
||||
}
|
||||
|
||||
// Parse arguments
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
|
||||
// Handle -p <port> or -p<port>
|
||||
if arg == "-p" {
|
||||
if i+1 < len(args) {
|
||||
conn.Port = args[i+1]
|
||||
i++
|
||||
}
|
||||
} else if strings.HasPrefix(arg, "-p") {
|
||||
conn.Port = arg[2:]
|
||||
} else if arg == "-i" {
|
||||
// Handle -i <identity>
|
||||
if i+1 < len(args) {
|
||||
conn.Identity = args[i+1]
|
||||
i++
|
||||
}
|
||||
} else if arg == "-F" || arg == "-c" || arg == "--config" {
|
||||
// Skip config file arguments - these are handled separately
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
}
|
||||
return nil, false
|
||||
} else if strings.HasPrefix(arg, "-") {
|
||||
// Skip other SSH options like -v, -A, -X, etc.
|
||||
// If they have a value, skip it too
|
||||
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
} else if strings.Contains(arg, "@") {
|
||||
// Parse user@hostname
|
||||
parts := strings.SplitN(arg, "@", 2)
|
||||
if len(parts) == 2 {
|
||||
conn.User = parts[0]
|
||||
conn.Hostname = parts[1]
|
||||
}
|
||||
} else if conn.Hostname == "" {
|
||||
// If no @, treat as just hostname
|
||||
conn.Hostname = arg
|
||||
}
|
||||
}
|
||||
|
||||
// If we got a hostname, this is a valid manual connection
|
||||
if conn.Hostname != "" {
|
||||
return conn, true
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// IsManualSSHCommand checks if the arguments represent a manual SSH connection
|
||||
// (not a configured host name)
|
||||
func IsManualSSHCommand(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for SSH flags that indicate manual connection
|
||||
for _, arg := range args {
|
||||
if arg == "-p" || strings.HasPrefix(arg, "-p") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(arg, "@") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
277
internal/history/parser_test.go
Normal file
277
internal/history/parser_test.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSSHArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantConn *ManualConnection
|
||||
wantOk bool
|
||||
}{
|
||||
{
|
||||
name: "user@host",
|
||||
args: []string{"user@example.com"},
|
||||
wantConn: &ManualConnection{
|
||||
User: "user",
|
||||
Hostname: "example.com",
|
||||
Port: "22",
|
||||
},
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "user@host with -p port",
|
||||
args: []string{"-p", "2222", "user@example.com"},
|
||||
wantConn: &ManualConnection{
|
||||
User: "user",
|
||||
Hostname: "example.com",
|
||||
Port: "2222",
|
||||
},
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "user@host with -p2222 (no space)",
|
||||
args: []string{"-p2222", "user@example.com"},
|
||||
wantConn: &ManualConnection{
|
||||
User: "user",
|
||||
Hostname: "example.com",
|
||||
Port: "2222",
|
||||
},
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "user@host with -i identity",
|
||||
args: []string{"-i", "~/.ssh/id_rsa", "user@example.com"},
|
||||
wantConn: &ManualConnection{
|
||||
User: "user",
|
||||
Hostname: "example.com",
|
||||
Port: "22",
|
||||
Identity: "~/.ssh/id_rsa",
|
||||
},
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "complete connection",
|
||||
args: []string{"-p", "2222", "-i", "~/.ssh/id_rsa", "guillaume@127.0.0.1"},
|
||||
wantConn: &ManualConnection{
|
||||
User: "guillaume",
|
||||
Hostname: "127.0.0.1",
|
||||
Port: "2222",
|
||||
Identity: "~/.ssh/id_rsa",
|
||||
},
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "just hostname (no user)",
|
||||
args: []string{"example.com"},
|
||||
wantConn: &ManualConnection{
|
||||
Hostname: "example.com",
|
||||
Port: "22",
|
||||
// User will be current system user, so we don't check it
|
||||
},
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "config file args should return false",
|
||||
args: []string{"-F", "~/.ssh/config", "host"},
|
||||
wantConn: nil,
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
name: "empty args",
|
||||
args: []string{},
|
||||
wantConn: nil,
|
||||
wantOk: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotConn, gotOk := ParseSSHArgs(tt.args)
|
||||
|
||||
if gotOk != tt.wantOk {
|
||||
t.Errorf("ParseSSHArgs() gotOk = %v, want %v", gotOk, tt.wantOk)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantOk {
|
||||
if gotConn != nil {
|
||||
t.Errorf("ParseSSHArgs() gotConn = %v, want nil", gotConn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if gotConn == nil {
|
||||
t.Errorf("ParseSSHArgs() gotConn = nil, want non-nil")
|
||||
return
|
||||
}
|
||||
|
||||
if gotConn.User != tt.wantConn.User {
|
||||
// Skip user check if wantConn.User is empty (current user)
|
||||
if tt.wantConn.User != "" {
|
||||
t.Errorf("ParseSSHArgs() User = %v, want %v", gotConn.User, tt.wantConn.User)
|
||||
}
|
||||
}
|
||||
if gotConn.Hostname != tt.wantConn.Hostname {
|
||||
t.Errorf("ParseSSHArgs() Hostname = %v, want %v", gotConn.Hostname, tt.wantConn.Hostname)
|
||||
}
|
||||
if gotConn.Port != tt.wantConn.Port {
|
||||
t.Errorf("ParseSSHArgs() Port = %v, want %v", gotConn.Port, tt.wantConn.Port)
|
||||
}
|
||||
if gotConn.Identity != tt.wantConn.Identity {
|
||||
t.Errorf("ParseSSHArgs() Identity = %v, want %v", gotConn.Identity, tt.wantConn.Identity)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsManualSSHCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "user@host is manual",
|
||||
args: []string{"user@example.com"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "with -p flag is manual",
|
||||
args: []string{"-p", "2222", "host"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "with -p2222 is manual",
|
||||
args: []string{"-p2222", "host"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "just hostname is not manual",
|
||||
args: []string{"myhost"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty is not manual",
|
||||
args: []string{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsManualSSHCommand(tt.args); got != tt.want {
|
||||
t.Errorf("IsManualSSHCommand() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualConnectionID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
conn ManualConnection
|
||||
wantHostID string
|
||||
wantUser string
|
||||
wantHostname string
|
||||
wantPort string
|
||||
}{
|
||||
{
|
||||
name: "complete connection",
|
||||
conn: ManualConnection{
|
||||
User: "guillaume",
|
||||
Hostname: "127.0.0.1",
|
||||
Port: "2222",
|
||||
Identity: "~/.ssh/id_rsa",
|
||||
},
|
||||
wantHostID: "manual:guillaume@127.0.0.1:2222",
|
||||
wantUser: "guillaume",
|
||||
wantHostname: "127.0.0.1",
|
||||
wantPort: "2222",
|
||||
},
|
||||
{
|
||||
name: "default port",
|
||||
conn: ManualConnection{
|
||||
User: "user",
|
||||
Hostname: "example.com",
|
||||
Port: "",
|
||||
},
|
||||
wantHostID: "manual:user@example.com:22",
|
||||
wantUser: "user",
|
||||
wantHostname: "example.com",
|
||||
wantPort: "22",
|
||||
},
|
||||
{
|
||||
name: "no user specified",
|
||||
conn: ManualConnection{
|
||||
Hostname: "example.com",
|
||||
Port: "2222",
|
||||
},
|
||||
wantHostID: "manual:default@example.com:2222",
|
||||
wantUser: "default",
|
||||
wantHostname: "example.com",
|
||||
wantPort: "2222",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test generation
|
||||
gotHostID := generateManualHostID(tt.conn)
|
||||
if gotHostID != tt.wantHostID {
|
||||
t.Errorf("generateManualHostID() = %v, want %v", gotHostID, tt.wantHostID)
|
||||
}
|
||||
|
||||
// Test IsManualConnection
|
||||
if !IsManualConnection(gotHostID) {
|
||||
t.Errorf("IsManualConnection(%v) = false, want true", gotHostID)
|
||||
}
|
||||
|
||||
// Test parsing
|
||||
user, hostname, port, ok := ParseManualConnectionID(gotHostID)
|
||||
if !ok {
|
||||
t.Errorf("ParseManualConnectionID() ok = false, want true")
|
||||
}
|
||||
if user != tt.wantUser {
|
||||
t.Errorf("ParseManualConnectionID() user = %v, want %v", user, tt.wantUser)
|
||||
}
|
||||
if hostname != tt.wantHostname {
|
||||
t.Errorf("ParseManualConnectionID() hostname = %v, want %v", hostname, tt.wantHostname)
|
||||
}
|
||||
if port != tt.wantPort {
|
||||
t.Errorf("ParseManualConnectionID() port = %v, want %v", port, tt.wantPort)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseManualConnectionID_Invalid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hostID string
|
||||
}{
|
||||
{
|
||||
name: "not a manual connection",
|
||||
hostID: "myhost",
|
||||
},
|
||||
{
|
||||
name: "missing components",
|
||||
hostID: "manual:invalid",
|
||||
},
|
||||
{
|
||||
name: "no @ sign",
|
||||
hostID: "manual:hostname:22",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, _, ok := ParseManualConnectionID(tt.hostID)
|
||||
if ok {
|
||||
t.Errorf("ParseManualConnectionID() ok = true, want false for invalid input")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user