mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2026-01-27 03:04:21 +01:00
Compare commits
6 Commits
1cea3795e4
...
1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b67f5abbbc | |||
| b587defabc | |||
| 22586484c7 | |||
| 420db56ff5 | |||
|
|
7600eaaa9b | ||
|
|
e0dd32993a |
50
README.md
50
README.md
@@ -34,6 +34,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
|
||||
- **🔍 Smart Search** - Find hosts quickly with built-in filtering and search
|
||||
- **🔒 Secure** - Works directly with your existing `~/.ssh/config` file
|
||||
- **📁 Custom Config Support** - Use any SSH configuration file with the `-c` flag
|
||||
- **📂 SSH Include Support** - Full support for SSH Include directives to organize configurations across multiple files
|
||||
- **⚙️ SSH Options Support** - Add any SSH configuration option through intuitive forms
|
||||
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
|
||||
|
||||
@@ -272,7 +273,55 @@ sshm edit hostname -c /path/to/custom/ssh_config
|
||||
|
||||
SSHM works directly with your standard SSH configuration file (`~/.ssh/config`). It adds special comment tags for enhanced functionality while maintaining full compatibility with standard SSH tools.
|
||||
|
||||
### SSH Include Support
|
||||
|
||||
SSHM fully supports SSH Include directives, allowing you to organize your SSH configurations across multiple files. This is particularly useful for managing large numbers of hosts or organizing configurations by environment, project, or team.
|
||||
|
||||
**Include Examples:**
|
||||
```ssh
|
||||
# Main ~/.ssh/config file
|
||||
Host personal-server
|
||||
HostName personal.example.com
|
||||
User myuser
|
||||
|
||||
# Include work-related configurations
|
||||
Include work-servers.conf
|
||||
|
||||
# Include all configurations from a directory
|
||||
Include projects/*
|
||||
|
||||
# Include with relative paths
|
||||
Include ~/.ssh/configs/production.conf
|
||||
```
|
||||
|
||||
**Organization Examples:**
|
||||
|
||||
*work-servers.conf:*
|
||||
```ssh
|
||||
# Tags: work, production
|
||||
Host prod-web-01
|
||||
HostName 10.0.1.10
|
||||
User deploy
|
||||
ProxyJump bastion.company.com
|
||||
|
||||
# Tags: work, staging
|
||||
Host staging-api
|
||||
HostName staging-api.company.com
|
||||
User developer
|
||||
```
|
||||
|
||||
*projects/client-alpha.conf:*
|
||||
```ssh
|
||||
# Tags: client, development
|
||||
Host client-alpha-dev
|
||||
HostName dev.client-alpha.com
|
||||
User admin
|
||||
Port 2222
|
||||
```
|
||||
|
||||
**Example configuration:**
|
||||
Include ~/.ssh/conf.d/*
|
||||
|
||||
```ssh
|
||||
# Tags: production, web, frontend
|
||||
Host web-prod-01
|
||||
@@ -452,6 +501,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
|
||||
|
||||
- [Charm](https://charm.sh/) for the amazing TUI libraries
|
||||
- [Cobra](https://cobra.dev/) for the excellent CLI framework
|
||||
- [@yimeng](https://github.com/yimeng) for contributing SSH Include directive support
|
||||
- The Go community for building such fantastic tools
|
||||
|
||||
---
|
||||
|
||||
@@ -96,25 +96,45 @@ func ParseSSHConfig() ([]SSHHost, error) {
|
||||
|
||||
// ParseSSHConfigFile parses a specific SSH config file and returns the list of hosts
|
||||
func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||
return parseSSHConfigFileWithProcessedFiles(configPath, make(map[string]bool))
|
||||
}
|
||||
|
||||
// parseSSHConfigFileWithProcessedFiles parses SSH config with include support
|
||||
func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[string]bool) ([]SSHHost, error) {
|
||||
// Resolve absolute path to prevent infinite recursion
|
||||
absPath, err := filepath.Abs(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
// Check for circular includes
|
||||
if processedFiles[absPath] {
|
||||
return []SSHHost{}, nil // Skip already processed files silently
|
||||
}
|
||||
processedFiles[absPath] = true
|
||||
|
||||
// Check if the file exists, otherwise create it (and the parent directory if needed)
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
// Ensure .ssh directory exists with proper permissions
|
||||
if err := ensureSSHDirectory(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
||||
// Only create the main config file, not included files
|
||||
if absPath == getMainConfigPath() {
|
||||
// Ensure .ssh directory exists with proper permissions
|
||||
if err := ensureSSHDirectory(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create .ssh directory: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
|
||||
}
|
||||
file.Close()
|
||||
|
||||
// Set secure permissions on the config file
|
||||
if err := SetSecureFilePermissions(configPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to set secure permissions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(configPath, os.O_CREATE|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SSH config file: %w", err)
|
||||
}
|
||||
file.Close()
|
||||
|
||||
// Set secure permissions on the config file
|
||||
if err := SetSecureFilePermissions(configPath); err != nil {
|
||||
return nil, fmt.Errorf("failed to set secure permissions: %w", err)
|
||||
}
|
||||
|
||||
// File created, return empty host list
|
||||
// File doesn't exist, return empty host list
|
||||
return []SSHHost{}, nil
|
||||
}
|
||||
|
||||
@@ -168,11 +188,25 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||
value := strings.Join(parts[1:], " ")
|
||||
|
||||
switch key {
|
||||
case "include":
|
||||
// Handle Include directive
|
||||
includeHosts, err := processIncludeDirective(value, configPath, processedFiles)
|
||||
if err != nil {
|
||||
// Don't fail the entire parse if include fails, just skip it
|
||||
continue
|
||||
}
|
||||
hosts = append(hosts, includeHosts...)
|
||||
case "host":
|
||||
// New host, save previous one if it exists
|
||||
if currentHost != nil {
|
||||
hosts = append(hosts, *currentHost)
|
||||
}
|
||||
// Skip hosts with wildcards (*, ?) as they are typically patterns, not actual hosts
|
||||
if strings.ContainsAny(value, "*?") {
|
||||
currentHost = nil
|
||||
pendingTags = nil
|
||||
continue
|
||||
}
|
||||
// Create new host
|
||||
currentHost = &SSHHost{
|
||||
Name: value,
|
||||
@@ -222,6 +256,55 @@ func ParseSSHConfigFile(configPath string) ([]SSHHost, error) {
|
||||
return hosts, scanner.Err()
|
||||
}
|
||||
|
||||
// processIncludeDirective processes an Include directive and returns hosts from included files
|
||||
func processIncludeDirective(pattern string, baseConfigPath string, processedFiles map[string]bool) ([]SSHHost, error) {
|
||||
// Expand tilde to home directory
|
||||
if strings.HasPrefix(pattern, "~") {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
pattern = filepath.Join(homeDir, pattern[1:])
|
||||
}
|
||||
|
||||
// If pattern is not absolute, make it relative to the base config directory
|
||||
if !filepath.IsAbs(pattern) {
|
||||
baseDir := filepath.Dir(baseConfigPath)
|
||||
pattern = filepath.Join(baseDir, pattern)
|
||||
}
|
||||
|
||||
// Use glob to find matching files
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to glob pattern %s: %w", pattern, err)
|
||||
}
|
||||
|
||||
var allHosts []SSHHost
|
||||
for _, match := range matches {
|
||||
// Skip directories
|
||||
if info, err := os.Stat(match); err == nil && info.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Recursively parse the included file
|
||||
hosts, err := parseSSHConfigFileWithProcessedFiles(match, processedFiles)
|
||||
if err != nil {
|
||||
// Skip files that can't be parsed rather than failing completely
|
||||
continue
|
||||
}
|
||||
allHosts = append(allHosts, hosts...)
|
||||
}
|
||||
|
||||
return allHosts, nil
|
||||
}
|
||||
|
||||
// getMainConfigPath returns the main SSH config path for comparison
|
||||
func getMainConfigPath() string {
|
||||
configPath, _ := GetDefaultSSHConfigPath()
|
||||
absPath, _ := filepath.Abs(configPath)
|
||||
return absPath
|
||||
}
|
||||
|
||||
// AddSSHHost adds a new SSH host to the config file
|
||||
func AddSSHHost(host SSHHost) error {
|
||||
configPath, err := GetDefaultSSHConfigPath()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -71,3 +72,296 @@ func TestEnsureSSHDirectory(t *testing.T) {
|
||||
t.Fatalf("ensureSSHDirectory() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHConfigWithInclude(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create main config file
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
HostName example.com
|
||||
User mainuser
|
||||
|
||||
Include included.conf
|
||||
Include subdir/*
|
||||
|
||||
Host another-host
|
||||
HostName another.example.com
|
||||
User anotheruser
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Create included file
|
||||
includedConfig := filepath.Join(tempDir, "included.conf")
|
||||
includedConfigContent := `Host included-host
|
||||
HostName included.example.com
|
||||
User includeduser
|
||||
Port 2222
|
||||
`
|
||||
|
||||
err = os.WriteFile(includedConfig, []byte(includedConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create included config: %v", err)
|
||||
}
|
||||
|
||||
// Create subdirectory with another config file
|
||||
subDir := filepath.Join(tempDir, "subdir")
|
||||
err = os.MkdirAll(subDir, 0700)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create subdir: %v", err)
|
||||
}
|
||||
|
||||
subConfig := filepath.Join(subDir, "sub.conf")
|
||||
subConfigContent := `Host sub-host
|
||||
HostName sub.example.com
|
||||
User subuser
|
||||
IdentityFile ~/.ssh/sub_key
|
||||
`
|
||||
|
||||
err = os.WriteFile(subConfig, []byte(subConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create sub config: %v", err)
|
||||
}
|
||||
|
||||
// Parse the main config file
|
||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Check that we got all expected hosts
|
||||
expectedHosts := map[string]struct{}{
|
||||
"main-host": {},
|
||||
"included-host": {},
|
||||
"sub-host": {},
|
||||
"another-host": {},
|
||||
}
|
||||
|
||||
if len(hosts) != len(expectedHosts) {
|
||||
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if _, exists := expectedHosts[host.Name]; !exists {
|
||||
t.Errorf("Unexpected host found: %s", host.Name)
|
||||
}
|
||||
delete(expectedHosts, host.Name)
|
||||
|
||||
// Validate specific host properties
|
||||
switch host.Name {
|
||||
case "main-host":
|
||||
if host.Hostname != "example.com" || host.User != "mainuser" {
|
||||
t.Errorf("main-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||
}
|
||||
case "included-host":
|
||||
if host.Hostname != "included.example.com" || host.User != "includeduser" || host.Port != "2222" {
|
||||
t.Errorf("included-host properties incorrect: hostname=%s, user=%s, port=%s", host.Hostname, host.User, host.Port)
|
||||
}
|
||||
case "sub-host":
|
||||
if host.Hostname != "sub.example.com" || host.User != "subuser" || host.Identity != "~/.ssh/sub_key" {
|
||||
t.Errorf("sub-host properties incorrect: hostname=%s, user=%s, identity=%s", host.Hostname, host.User, host.Identity)
|
||||
}
|
||||
case "another-host":
|
||||
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
||||
t.Errorf("another-host properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check that all expected hosts were found
|
||||
if len(expectedHosts) > 0 {
|
||||
var missing []string
|
||||
for host := range expectedHosts {
|
||||
missing = append(missing, host)
|
||||
}
|
||||
t.Errorf("Missing hosts: %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHConfigWithCircularInclude(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create config1 that includes config2
|
||||
config1 := filepath.Join(tempDir, "config1")
|
||||
config1Content := `Host host1
|
||||
HostName example1.com
|
||||
|
||||
Include config2
|
||||
`
|
||||
|
||||
err := os.WriteFile(config1, []byte(config1Content), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config1: %v", err)
|
||||
}
|
||||
|
||||
// Create config2 that includes config1 (circular)
|
||||
config2 := filepath.Join(tempDir, "config2")
|
||||
config2Content := `Host host2
|
||||
HostName example2.com
|
||||
|
||||
Include config1
|
||||
`
|
||||
|
||||
err = os.WriteFile(config2, []byte(config2Content), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config2: %v", err)
|
||||
}
|
||||
|
||||
// Parse the config file - should not cause infinite recursion
|
||||
hosts, err := ParseSSHConfigFile(config1)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Should get both hosts exactly once
|
||||
expectedHosts := map[string]bool{
|
||||
"host1": false,
|
||||
"host2": false,
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if _, exists := expectedHosts[host.Name]; !exists {
|
||||
t.Errorf("Unexpected host found: %s", host.Name)
|
||||
} else {
|
||||
if expectedHosts[host.Name] {
|
||||
t.Errorf("Host %s found multiple times", host.Name)
|
||||
}
|
||||
expectedHosts[host.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check all hosts were found
|
||||
for hostName, found := range expectedHosts {
|
||||
if !found {
|
||||
t.Errorf("Host %s not found", hostName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHConfigWithNonExistentInclude(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create main config file with non-existent include
|
||||
mainConfig := filepath.Join(tempDir, "config")
|
||||
mainConfigContent := `Host main-host
|
||||
HostName example.com
|
||||
|
||||
Include non-existent-file.conf
|
||||
|
||||
Host another-host
|
||||
HostName another.example.com
|
||||
`
|
||||
|
||||
err := os.WriteFile(mainConfig, []byte(mainConfigContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create main config: %v", err)
|
||||
}
|
||||
|
||||
// Parse should succeed and ignore the non-existent include
|
||||
hosts, err := ParseSSHConfigFile(mainConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Should get the hosts that exist, ignoring the failed include
|
||||
if len(hosts) != 2 {
|
||||
t.Errorf("Expected 2 hosts, got %d", len(hosts))
|
||||
}
|
||||
|
||||
hostNames := make(map[string]bool)
|
||||
for _, host := range hosts {
|
||||
hostNames[host.Name] = true
|
||||
}
|
||||
|
||||
if !hostNames["main-host"] || !hostNames["another-host"] {
|
||||
t.Errorf("Expected main-host and another-host, got: %v", hostNames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSSHConfigWithWildcardHosts(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create config file with wildcard hosts
|
||||
configFile := filepath.Join(tempDir, "config")
|
||||
configContent := `# Wildcard patterns should be ignored
|
||||
Host *.example.com
|
||||
User defaultuser
|
||||
IdentityFile ~/.ssh/id_rsa
|
||||
|
||||
Host server-*
|
||||
Port 2222
|
||||
|
||||
Host *
|
||||
ServerAliveInterval 60
|
||||
|
||||
# Real hosts should be included
|
||||
Host real-server
|
||||
HostName real.example.com
|
||||
User realuser
|
||||
|
||||
Host another-real-server
|
||||
HostName another.example.com
|
||||
User anotheruser
|
||||
`
|
||||
|
||||
err := os.WriteFile(configFile, []byte(configContent), 0600)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create config: %v", err)
|
||||
}
|
||||
|
||||
// Parse the config file
|
||||
hosts, err := ParseSSHConfigFile(configFile)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSSHConfigFile() error = %v", err)
|
||||
}
|
||||
|
||||
// Should only get real hosts, not wildcard patterns
|
||||
expectedHosts := map[string]bool{
|
||||
"real-server": false,
|
||||
"another-real-server": false,
|
||||
}
|
||||
|
||||
if len(hosts) != len(expectedHosts) {
|
||||
t.Errorf("Expected %d hosts, got %d", len(expectedHosts), len(hosts))
|
||||
for _, host := range hosts {
|
||||
t.Logf("Found host: %s", host.Name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, host := range hosts {
|
||||
if _, expected := expectedHosts[host.Name]; !expected {
|
||||
t.Errorf("Unexpected host found: %s", host.Name)
|
||||
} else {
|
||||
expectedHosts[host.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check that all expected hosts were found
|
||||
for hostName, found := range expectedHosts {
|
||||
if !found {
|
||||
t.Errorf("Expected host %s not found", hostName)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify host properties
|
||||
for _, host := range hosts {
|
||||
switch host.Name {
|
||||
case "real-server":
|
||||
if host.Hostname != "real.example.com" || host.User != "realuser" {
|
||||
t.Errorf("real-server properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||
}
|
||||
case "another-real-server":
|
||||
if host.Hostname != "another.example.com" || host.User != "anotheruser" {
|
||||
t.Errorf("another-real-server properties incorrect: hostname=%s, user=%s", host.Hostname, host.User)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,24 +142,42 @@ func (m *Model) updateTableHeight() {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate dynamic table height based on terminal size
|
||||
// Layout breakdown:
|
||||
// - ASCII title: 5 lines (1 empty + 4 text lines)
|
||||
// - Search bar: 1 line
|
||||
// - Help text: 1 line
|
||||
// - App margins/spacing: 3 lines
|
||||
// - Safety margin: 3 lines (to ensure UI elements are always visible)
|
||||
// Total reserved: 13 lines minimum to preserve essential UI elements
|
||||
reservedHeight := 13
|
||||
availableHeight := m.height - reservedHeight
|
||||
hostCount := len(m.table.Rows())
|
||||
|
||||
// Calculate exactly what we need:
|
||||
// 1 line for header + actual number of host rows + 1 extra line for better UX
|
||||
tableHeight := 1 + hostCount + 1
|
||||
|
||||
// Set a reasonable maximum based on terminal height
|
||||
// Leave space for: title (5) + search (1) + help (1) + margins (2) = 9 lines
|
||||
// But be less conservative, use 7 lines instead of 9
|
||||
maxPossibleHeight := m.height - 7
|
||||
if maxPossibleHeight < 4 {
|
||||
maxPossibleHeight = 4 // Minimum: header + 3 rows
|
||||
// Minimum height should be at least 3 rows for basic usability
|
||||
// Even in very small terminals, we want to show at least header + 2 hosts
|
||||
minTableHeight := 4 // 1 header + 3 data rows minimum
|
||||
maxTableHeight := availableHeight
|
||||
if maxTableHeight < minTableHeight {
|
||||
maxTableHeight = minTableHeight
|
||||
}
|
||||
|
||||
if tableHeight > maxPossibleHeight {
|
||||
tableHeight = maxPossibleHeight
|
||||
tableHeight := 1 // header
|
||||
dataRowsNeeded := hostCount
|
||||
maxDataRows := maxTableHeight - 1 // subtract 1 for header
|
||||
|
||||
if dataRowsNeeded <= maxDataRows {
|
||||
// We have enough space for all hosts
|
||||
tableHeight += dataRowsNeeded
|
||||
} else {
|
||||
// We need to limit to available space
|
||||
tableHeight += maxDataRows
|
||||
}
|
||||
|
||||
// Add one extra line to prevent the last host from being hidden
|
||||
// This compensates for table rendering quirks in bubble tea
|
||||
tableHeight += 1
|
||||
|
||||
// Update table height
|
||||
m.table.SetHeight(tableHeight)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user