Merge 8f502e54fefbc05550c1fb9ee78ca76ffd4a7b14 into 825c534ebe1b5683a57522329e26ad37a182bead

This commit is contained in:
Simon Gaufreteau 2025-10-15 13:09:26 +02:00 committed by GitHub
commit 9e901e486f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 87 additions and 32 deletions

View File

@ -44,7 +44,7 @@ SSHM is a beautiful command-line tool that transforms how you manage and connect
- **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats - **🔄 Automatic Conversion** - Seamlessly converts between command-line and config formats
- **🔄 Automatic Backups** - Backup configurations automatically before changes - **🔄 Automatic Backups** - Backup configurations automatically before changes
- **✅ Validation** - Prevent configuration errors with built-in validation - **✅ Validation** - Prevent configuration errors with built-in validation
- **🔗 ProxyJump Support** - Secure connection tunneling through bastion hosts - **🔗 ProxyJump/ProxyCommand Support** - Secure connection tunneling through bastion hosts
- **⌨️ Keyboard Shortcuts** - Power user navigation with vim-like shortcuts - **⌨️ Keyboard Shortcuts** - Power user navigation with vim-like shortcuts
- **🌐 Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows - **🌐 Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows
- **⚡ Lightweight** - Single binary with no dependencies, zero configuration required - **⚡ Lightweight** - Single binary with no dependencies, zero configuration required
@ -129,6 +129,7 @@ The interactive forms will guide you through configuration:
- **Port** - SSH port (default: 22) - **Port** - SSH port (default: 22)
- **Identity File** - Private key path - **Identity File** - Private key path
- **ProxyJump** - Jump server for connection tunneling - **ProxyJump** - Jump server for connection tunneling
- **ProxyCommand** - Jump command for connection tunneling
- **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`) - **SSH Options** - Additional SSH options in `-o` format (e.g., `-o Compression=yes -o ServerAliveInterval=60`)
- **Tags** - Comma-separated tags for organization - **Tags** - Comma-separated tags for organization
@ -504,6 +505,7 @@ Host backend-prod
User app User app
Port 22 Port 22
ProxyJump bastion.company.com ProxyJump bastion.company.com
ProxyCommand ssh -W %h:%p Jumphost
IdentityFile ~/.ssh/production_key IdentityFile ~/.ssh/production_key
Compression yes Compression yes
ServerAliveInterval 300 ServerAliveInterval 300
@ -520,6 +522,7 @@ SSHM supports all standard SSH configuration options:
- `Port` - SSH port number - `Port` - SSH port number
- `IdentityFile` - Path to private key file - `IdentityFile` - Path to private key file
- `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`) - `ProxyJump` - Jump server for connection tunneling (e.g., `user@jumphost:port`)
- `ProxyCommand` - Jump command for connection tunneling (e.g, `ssh -W %h:%p Jumphost`)
- `Tags` - Custom tags (SSHM extension) - `Tags` - Custom tags (SSHM extension)
**Additional SSH Options:** **Additional SSH Options:**

View File

@ -205,6 +205,7 @@ func outputJSON(hosts []config.SSHHost) {
fmt.Printf(" \"port\": \"%s\",\n", escapeJSON(host.Port)) fmt.Printf(" \"port\": \"%s\",\n", escapeJSON(host.Port))
fmt.Printf(" \"identity\": \"%s\",\n", escapeJSON(host.Identity)) fmt.Printf(" \"identity\": \"%s\",\n", escapeJSON(host.Identity))
fmt.Printf(" \"proxy_jump\": \"%s\",\n", escapeJSON(host.ProxyJump)) fmt.Printf(" \"proxy_jump\": \"%s\",\n", escapeJSON(host.ProxyJump))
fmt.Printf(" \"proxy_command\": \"%s\",\n", escapeJSON(host.ProxyCommand))
fmt.Printf(" \"options\": \"%s\",\n", escapeJSON(host.Options)) fmt.Printf(" \"options\": \"%s\",\n", escapeJSON(host.Options))
fmt.Printf(" \"tags\": [") fmt.Printf(" \"tags\": [")
for j, tag := range host.Tags { for j, tag := range host.Tags {

View File

@ -19,6 +19,7 @@ type SSHHost struct {
Port string Port string
Identity string Identity string
ProxyJump string ProxyJump string
ProxyCommand string
Options string Options string
RemoteCommand string // Command to execute after SSH connection RemoteCommand string // Command to execute after SSH connection
RequestTTY string // Request TTY (yes, no, force, auto) RequestTTY string // Request TTY (yes, no, force, auto)
@ -328,6 +329,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[
if currentHost != nil { if currentHost != nil {
currentHost.ProxyJump = value currentHost.ProxyJump = value
} }
case "proxycommand":
if currentHost != nil {
currentHost.ProxyCommand = value
}
case "remotecommand": case "remotecommand":
if currentHost != nil { if currentHost != nil {
currentHost.RemoteCommand = value currentHost.RemoteCommand = value
@ -613,6 +618,13 @@ func AddSSHHostToFile(host SSHHost, configPath string) error {
} }
} }
if host.ProxyCommand != "" {
_, err = file.WriteString(fmt.Sprintf(" ProxyCommand=%s\n", host.ProxyCommand))
if err != nil {
return err
}
}
if host.RemoteCommand != "" { if host.RemoteCommand != "" {
_, err = file.WriteString(fmt.Sprintf(" RemoteCommand %s\n", host.RemoteCommand)) _, err = file.WriteString(fmt.Sprintf(" RemoteCommand %s\n", host.RemoteCommand))
if err != nil { if err != nil {
@ -1044,6 +1056,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
} }
if newHost.ProxyCommand != "" {
newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand)
}
if newHost.RemoteCommand != "" { if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand) newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
} }
@ -1098,6 +1113,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
} }
if newHost.ProxyCommand != "" {
newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand)
}
if newHost.RemoteCommand != "" { if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand) newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
} }
@ -1188,6 +1206,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
} }
if newHost.ProxyCommand != "" {
newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand)
}
if newHost.RemoteCommand != "" { if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand) newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
} }
@ -1242,6 +1263,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err
if newHost.ProxyJump != "" { if newHost.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) newLines = append(newLines, " ProxyJump "+newHost.ProxyJump)
} }
if newHost.ProxyCommand != "" {
newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand)
}
if newHost.RemoteCommand != "" { if newHost.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand) newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand)
} }
@ -1742,6 +1766,9 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH
if commonProperties.ProxyJump != "" { if commonProperties.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump) newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump)
} }
if commonProperties.ProxyCommand != "" {
newLines = append(newLines, " ProxyCommand="+commonProperties.ProxyCommand)
}
if commonProperties.RemoteCommand != "" { if commonProperties.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand) newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand)
} }
@ -1828,6 +1855,9 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH
if commonProperties.ProxyJump != "" { if commonProperties.ProxyJump != "" {
newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump) newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump)
} }
if commonProperties.ProxyCommand != "" {
newLines = append(newLines, " ProxyCommand="+commonProperties.ProxyCommand)
}
if commonProperties.RemoteCommand != "" { if commonProperties.RemoteCommand != "" {
newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand) newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand)
} }

View File

@ -49,7 +49,7 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
} }
} }
inputs := make([]textinput.Model, 10) // Increased from 9 to 10 for RequestTTY inputs := make([]textinput.Model, 11)
// Name input // Name input
inputs[nameInput] = textinput.New() inputs[nameInput] = textinput.New()
@ -91,6 +91,12 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st
inputs[proxyJumpInput].CharLimit = 200 inputs[proxyJumpInput].CharLimit = 200
inputs[proxyJumpInput].Width = 50 inputs[proxyJumpInput].Width = 50
// ProxyCommand input
inputs[proxyCommandInput] = textinput.New()
inputs[proxyCommandInput].Placeholder = "ssh -W %h:%p Jumphost"
inputs[proxyCommandInput].CharLimit = 200
inputs[proxyCommandInput].Width = 50
// SSH Options input // SSH Options input
inputs[optionsInput] = textinput.New() inputs[optionsInput] = textinput.New()
inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60" inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60"
@ -138,6 +144,7 @@ const (
portInput portInput
identityInput identityInput
proxyJumpInput proxyJumpInput
proxyCommandInput
tagsInput tagsInput
// Advanced tab inputs // Advanced tab inputs
optionsInput optionsInput
@ -229,11 +236,11 @@ func (m *addFormModel) getFirstInputForTab(tab int) int {
func (m *addFormModel) getInputsForCurrentTab() []int { func (m *addFormModel) getInputsForCurrentTab() []int {
switch m.currentTab { switch m.currentTab {
case tabGeneral: case tabGeneral:
return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput} return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, proxyCommandInput, tagsInput}
case tabAdvanced: case tabAdvanced:
return []int{optionsInput, remoteCommandInput, requestTTYInput} return []int{optionsInput, remoteCommandInput, requestTTYInput}
default: default:
return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput} return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, proxyCommandInput, tagsInput}
} }
} }
@ -401,6 +408,7 @@ func (m *addFormModel) renderGeneralTab() string {
{portInput, "Port"}, {portInput, "Port"},
{identityInput, "Identity File"}, {identityInput, "Identity File"},
{proxyJumpInput, "ProxyJump"}, {proxyJumpInput, "ProxyJump"},
{proxyCommandInput, "ProxyCommand"},
{tagsInput, "Tags (comma-separated)"}, {tagsInput, "Tags (comma-separated)"},
} }
@ -489,6 +497,7 @@ func (m *addFormModel) submitForm() tea.Cmd {
port := strings.TrimSpace(m.inputs[portInput].Value()) port := strings.TrimSpace(m.inputs[portInput].Value())
identity := strings.TrimSpace(m.inputs[identityInput].Value()) identity := strings.TrimSpace(m.inputs[identityInput].Value())
proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value()) proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value())
proxyCommand := strings.TrimSpace(m.inputs[proxyCommandInput].Value())
options := strings.TrimSpace(m.inputs[optionsInput].Value()) options := strings.TrimSpace(m.inputs[optionsInput].Value())
remoteCommand := strings.TrimSpace(m.inputs[remoteCommandInput].Value()) remoteCommand := strings.TrimSpace(m.inputs[remoteCommandInput].Value())
requestTTY := strings.TrimSpace(m.inputs[requestTTYInput].Value()) requestTTY := strings.TrimSpace(m.inputs[requestTTYInput].Value())
@ -526,6 +535,7 @@ func (m *addFormModel) submitForm() tea.Cmd {
Port: port, Port: port,
Identity: identity, Identity: identity,
ProxyJump: proxyJump, ProxyJump: proxyJump,
ProxyCommand: proxyCommand,
Options: config.ParseSSHOptionsFromCommand(options), Options: config.ParseSSHOptionsFromCommand(options),
RemoteCommand: remoteCommand, RemoteCommand: remoteCommand,
RequestTTY: requestTTY, RequestTTY: requestTTY,

View File

@ -91,7 +91,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
} }
} }
inputs := make([]textinput.Model, 9) // Increased from 8 to 9 for RequestTTY inputs := make([]textinput.Model, 10)
// Hostname input // Hostname input
inputs[0] = textinput.New() inputs[0] = textinput.New()
@ -128,37 +128,44 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s
inputs[4].Width = 30 inputs[4].Width = 30
inputs[4].SetValue(host.ProxyJump) inputs[4].SetValue(host.ProxyJump)
// Options input // ProxyCommand input
inputs[5] = textinput.New() inputs[5] = textinput.New()
inputs[5].Placeholder = "-o StrictHostKeyChecking=no" inputs[5].Placeholder = "ssh -W %h:%p Jumphost"
inputs[5].CharLimit = 200 inputs[5].CharLimit = 200
inputs[5].Width = 50 inputs[5].Width = 50
inputs[5].SetValue(host.ProxyCommand)
// Options input
inputs[6] = textinput.New()
inputs[6].Placeholder = "-o StrictHostKeyChecking=no"
inputs[6].CharLimit = 200
inputs[6].Width = 50
if host.Options != "" { if host.Options != "" {
inputs[5].SetValue(config.FormatSSHOptionsForCommand(host.Options)) inputs[6].SetValue(config.FormatSSHOptionsForCommand(host.Options))
} }
// Tags input // Tags input
inputs[6] = textinput.New() inputs[7] = textinput.New()
inputs[6].Placeholder = "production, web, database" inputs[7].Placeholder = "production, web, database"
inputs[6].CharLimit = 200 inputs[7].CharLimit = 200
inputs[6].Width = 50 inputs[7].Width = 50
if len(host.Tags) > 0 { if len(host.Tags) > 0 {
inputs[6].SetValue(strings.Join(host.Tags, ", ")) inputs[7].SetValue(strings.Join(host.Tags, ", "))
} }
// Remote Command input // Remote Command input
inputs[7] = textinput.New() inputs[8] = textinput.New()
inputs[7].Placeholder = "ls -la, htop, bash" inputs[8].Placeholder = "ls -la, htop, bash"
inputs[7].CharLimit = 300 inputs[8].CharLimit = 300
inputs[7].Width = 70 inputs[8].Width = 70
inputs[7].SetValue(host.RemoteCommand) inputs[8].SetValue(host.RemoteCommand)
// RequestTTY input // RequestTTY input
inputs[8] = textinput.New() inputs[9] = textinput.New()
inputs[8].Placeholder = "yes, no, force, auto" inputs[9].Placeholder = "yes, no, force, auto"
inputs[8].CharLimit = 10 inputs[9].CharLimit = 10
inputs[8].Width = 30 inputs[9].Width = 30
inputs[8].SetValue(host.RequestTTY) inputs[9].SetValue(host.RequestTTY)
return &editFormModel{ return &editFormModel{
hostInputs: hostInputs, hostInputs: hostInputs,
@ -253,19 +260,19 @@ func (m *editFormModel) updateFocus() tea.Cmd {
func (m *editFormModel) getPropertiesForCurrentTab() []int { func (m *editFormModel) getPropertiesForCurrentTab() []int {
switch m.currentTab { switch m.currentTab {
case 0: // General case 0: // General
return []int{0, 1, 2, 3, 4, 6} // hostname, user, port, identity, proxyjump, tags return []int{0, 1, 2, 3, 4, 5, 7} // hostname, user, port, identity, proxyjump, proxycommand, tags
case 1: // Advanced case 1: // Advanced
return []int{5, 7, 8} // options, remotecommand, requesttty return []int{6, 8, 9} // options, remotecommand, requesttty
default: default:
return []int{0, 1, 2, 3, 4, 6} return []int{0, 1, 2, 3, 4, 5, 7}
} }
} }
// getFirstPropertyForTab returns the first property index for a given tab // getFirstPropertyForTab returns the first property index for a given tab
func (m *editFormModel) getFirstPropertyForTab(tab int) int { func (m *editFormModel) getFirstPropertyForTab(tab int) int {
properties := []int{0, 1, 2, 3, 4, 6} // General tab properties := []int{0, 1, 2, 3, 4, 5, 7} // General tab
if tab == 1 { if tab == 1 {
properties = []int{5, 7, 8} // Advanced tab properties = []int{6, 8, 9} // Advanced tab
} }
if len(properties) > 0 { if len(properties) > 0 {
return properties[0] return properties[0]
@ -580,7 +587,8 @@ func (m *editFormModel) renderEditGeneralTab() string {
{2, "Port"}, {2, "Port"},
{3, "Identity File"}, {3, "Identity File"},
{4, "Proxy Jump"}, {4, "Proxy Jump"},
{6, "Tags (comma-separated)"}, {5, "Proxy Command"},
{7, "Tags (comma-separated)"},
} }
for _, field := range fields { for _, field := range fields {
@ -683,9 +691,10 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
port := strings.TrimSpace(m.inputs[2].Value()) // portInput port := strings.TrimSpace(m.inputs[2].Value()) // portInput
identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput
proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput
options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput proxyCommand := strings.TrimSpace(m.inputs[5].Value()) // proxyCommandInput
remoteCommand := strings.TrimSpace(m.inputs[7].Value()) // remoteCommandInput options := strings.TrimSpace(m.inputs[6].Value()) // optionsInput
requestTTY := strings.TrimSpace(m.inputs[8].Value()) // requestTTYInput remoteCommand := strings.TrimSpace(m.inputs[8].Value()) // remoteCommandInput
requestTTY := strings.TrimSpace(m.inputs[9].Value()) // requestTTYInput
// Set defaults // Set defaults
if port == "" { if port == "" {
@ -723,6 +732,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd {
Port: port, Port: port,
Identity: identity, Identity: identity,
ProxyJump: proxyJump, ProxyJump: proxyJump,
ProxyCommand: proxyCommand,
Options: options, Options: options,
RemoteCommand: remoteCommand, RemoteCommand: remoteCommand,
RequestTTY: requestTTY, RequestTTY: requestTTY,

View File

@ -97,6 +97,7 @@ func (m *infoFormModel) View() string {
{"Port", formatOptionalValue(m.host.Port)}, {"Port", formatOptionalValue(m.host.Port)},
{"Identity File", formatOptionalValue(m.host.Identity)}, {"Identity File", formatOptionalValue(m.host.Identity)},
{"ProxyJump", formatOptionalValue(m.host.ProxyJump)}, {"ProxyJump", formatOptionalValue(m.host.ProxyJump)},
{"ProxyCommand", formatOptionalValue(m.host.ProxyCommand)},
{"SSH Options", formatSSHOptions(m.host.Options)}, {"SSH Options", formatSSHOptions(m.host.Options)},
{"Tags", formatTags(m.host.Tags)}, {"Tags", formatTags(m.host.Tags)},
} }