From dcf3199464131db1f551c19d87ce539cec225858 Mon Sep 17 00:00:00 2001 From: Simon Gaufreteau Date: Wed, 15 Oct 2025 11:49:41 +0200 Subject: [PATCH 1/3] Add base for ProxyCommand --- cmd/search.go | 1 + internal/config/ssh.go | 30 +++++++++++++++++++++++ internal/ui/add_form.go | 14 +++++++++-- internal/ui/edit_form.go | 53 +++++++++++++++++++++++----------------- internal/ui/info_form.go | 1 + 5 files changed, 75 insertions(+), 24 deletions(-) diff --git a/cmd/search.go b/cmd/search.go index 1636f5d..d9afc10 100644 --- a/cmd/search.go +++ b/cmd/search.go @@ -205,6 +205,7 @@ func outputJSON(hosts []config.SSHHost) { fmt.Printf(" \"port\": \"%s\",\n", escapeJSON(host.Port)) fmt.Printf(" \"identity\": \"%s\",\n", escapeJSON(host.Identity)) 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(" \"tags\": [") for j, tag := range host.Tags { diff --git a/internal/config/ssh.go b/internal/config/ssh.go index 9f7ae91..d308d56 100644 --- a/internal/config/ssh.go +++ b/internal/config/ssh.go @@ -19,6 +19,7 @@ type SSHHost struct { Port string Identity string ProxyJump string + ProxyCommand string Options string RemoteCommand string // Command to execute after SSH connection RequestTTY string // Request TTY (yes, no, force, auto) @@ -328,6 +329,10 @@ func parseSSHConfigFileWithProcessedFiles(configPath string, processedFiles map[ if currentHost != nil { currentHost.ProxyJump = value } + case "proxycommand": + if currentHost != nil { + currentHost.ProxyCommand = value + } case "remotecommand": if currentHost != nil { 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 != "" { _, err = file.WriteString(fmt.Sprintf(" RemoteCommand %s\n", host.RemoteCommand)) if err != nil { @@ -1044,6 +1056,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err if newHost.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) } + if newHost.ProxyCommand != "" { + newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand) + } if newHost.RemoteCommand != "" { newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand) } @@ -1098,6 +1113,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err if newHost.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) } + if newHost.ProxyCommand != "" { + newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand) + } if newHost.RemoteCommand != "" { newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand) } @@ -1188,6 +1206,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err if newHost.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) } + if newHost.ProxyCommand != "" { + newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand) + } if newHost.RemoteCommand != "" { newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand) } @@ -1242,6 +1263,9 @@ func UpdateSSHHostInFile(oldName string, newHost SSHHost, configPath string) err if newHost.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+newHost.ProxyJump) } + if newHost.ProxyCommand != "" { + newLines = append(newLines, " ProxyCommand="+newHost.ProxyCommand) + } if newHost.RemoteCommand != "" { newLines = append(newLines, " RemoteCommand "+newHost.RemoteCommand) } @@ -1742,6 +1766,9 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH if commonProperties.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump) } + if commonProperties.ProxyCommand != "" { + newLines = append(newLines, " ProxyCommand="+commonProperties.ProxyCommand) + } if commonProperties.RemoteCommand != "" { newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand) } @@ -1828,6 +1855,9 @@ func UpdateMultiHostBlock(originalHosts, newHosts []string, commonProperties SSH if commonProperties.ProxyJump != "" { newLines = append(newLines, " ProxyJump "+commonProperties.ProxyJump) } + if commonProperties.ProxyCommand != "" { + newLines = append(newLines, " ProxyCommand="+commonProperties.ProxyCommand) + } if commonProperties.RemoteCommand != "" { newLines = append(newLines, " RemoteCommand "+commonProperties.RemoteCommand) } diff --git a/internal/ui/add_form.go b/internal/ui/add_form.go index 75a7cb4..eb3717b 100644 --- a/internal/ui/add_form.go +++ b/internal/ui/add_form.go @@ -91,6 +91,12 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st inputs[proxyJumpInput].CharLimit = 200 inputs[proxyJumpInput].Width = 50 + // ProxyCommand input + inputs[proxyCommandInput] = textinput.New() + inputs[proxyCommandInput].Placeholder = "/usr/local/bin/wssh proxy %h" + inputs[proxyCommandInput].CharLimit = 200 + inputs[proxyCommandInput].Width = 50 + // SSH Options input inputs[optionsInput] = textinput.New() inputs[optionsInput].Placeholder = "-o Compression=yes -o ServerAliveInterval=60" @@ -138,6 +144,7 @@ const ( portInput identityInput proxyJumpInput + proxyCommandInput tagsInput // Advanced tab inputs optionsInput @@ -229,11 +236,11 @@ func (m *addFormModel) getFirstInputForTab(tab int) int { func (m *addFormModel) getInputsForCurrentTab() []int { switch m.currentTab { case tabGeneral: - return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, tagsInput} + return []int{nameInput, hostnameInput, userInput, portInput, identityInput, proxyJumpInput, proxyCommandInput, tagsInput} case tabAdvanced: return []int{optionsInput, remoteCommandInput, requestTTYInput} 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"}, {identityInput, "Identity File"}, {proxyJumpInput, "ProxyJump"}, + {proxyCommandInput, "ProxyCommand"} {tagsInput, "Tags (comma-separated)"}, } @@ -489,6 +497,7 @@ func (m *addFormModel) submitForm() tea.Cmd { port := strings.TrimSpace(m.inputs[portInput].Value()) identity := strings.TrimSpace(m.inputs[identityInput].Value()) proxyJump := strings.TrimSpace(m.inputs[proxyJumpInput].Value()) + proxyCommand := strings.TrimSpace(m.inputs[proxyCommandInput].Value()) options := strings.TrimSpace(m.inputs[optionsInput].Value()) remoteCommand := strings.TrimSpace(m.inputs[remoteCommandInput].Value()) requestTTY := strings.TrimSpace(m.inputs[requestTTYInput].Value()) @@ -526,6 +535,7 @@ func (m *addFormModel) submitForm() tea.Cmd { Port: port, Identity: identity, ProxyJump: proxyJump, + ProxyCommand: proxyCommand, Options: config.ParseSSHOptionsFromCommand(options), RemoteCommand: remoteCommand, RequestTTY: requestTTY, diff --git a/internal/ui/edit_form.go b/internal/ui/edit_form.go index fdac316..eacba95 100644 --- a/internal/ui/edit_form.go +++ b/internal/ui/edit_form.go @@ -128,37 +128,44 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s inputs[4].Width = 30 inputs[4].SetValue(host.ProxyJump) - // Options input + // ProxyCommand input inputs[5] = textinput.New() - inputs[5].Placeholder = "-o StrictHostKeyChecking=no" + inputs[5].Placeholder = "/bin/wssh proxy" inputs[5].CharLimit = 200 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 != "" { - inputs[5].SetValue(config.FormatSSHOptionsForCommand(host.Options)) + inputs[6].SetValue(config.FormatSSHOptionsForCommand(host.Options)) } // Tags input - inputs[6] = textinput.New() - inputs[6].Placeholder = "production, web, database" - inputs[6].CharLimit = 200 - inputs[6].Width = 50 + inputs[7] = textinput.New() + inputs[7].Placeholder = "production, web, database" + inputs[7].CharLimit = 200 + inputs[7].Width = 50 if len(host.Tags) > 0 { - inputs[6].SetValue(strings.Join(host.Tags, ", ")) + inputs[7].SetValue(strings.Join(host.Tags, ", ")) } // Remote Command input - inputs[7] = textinput.New() - inputs[7].Placeholder = "ls -la, htop, bash" - inputs[7].CharLimit = 300 - inputs[7].Width = 70 - inputs[7].SetValue(host.RemoteCommand) + inputs[8] = textinput.New() + inputs[8].Placeholder = "ls -la, htop, bash" + inputs[8].CharLimit = 300 + inputs[8].Width = 70 + inputs[8].SetValue(host.RemoteCommand) // RequestTTY input - inputs[8] = textinput.New() - inputs[8].Placeholder = "yes, no, force, auto" - inputs[8].CharLimit = 10 - inputs[8].Width = 30 - inputs[8].SetValue(host.RequestTTY) + inputs[9] = textinput.New() + inputs[9].Placeholder = "yes, no, force, auto" + inputs[9].CharLimit = 10 + inputs[9].Width = 30 + inputs[9].SetValue(host.RequestTTY) return &editFormModel{ hostInputs: hostInputs, @@ -253,7 +260,7 @@ func (m *editFormModel) updateFocus() tea.Cmd { func (m *editFormModel) getPropertiesForCurrentTab() []int { switch m.currentTab { 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, 6} // hostname, user, port, identity, proxyjump, proxycommand, tags case 1: // Advanced return []int{5, 7, 8} // options, remotecommand, requesttty default: @@ -683,9 +690,10 @@ func (m *editFormModel) submitEditForm() tea.Cmd { port := strings.TrimSpace(m.inputs[2].Value()) // portInput identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput - options := strings.TrimSpace(m.inputs[5].Value()) // optionsInput - remoteCommand := strings.TrimSpace(m.inputs[7].Value()) // remoteCommandInput - requestTTY := strings.TrimSpace(m.inputs[8].Value()) // requestTTYInput + proxyCommand := strings.TrimSpace(m.inputs[5].Value()) // proxyCommandInput + options := strings.TrimSpace(m.inputs[6].Value()) // optionsInput + remoteCommand := strings.TrimSpace(m.inputs[8].Value()) // remoteCommandInput + requestTTY := strings.TrimSpace(m.inputs[9].Value()) // requestTTYInput // Set defaults if port == "" { @@ -723,6 +731,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd { Port: port, Identity: identity, ProxyJump: proxyJump, + ProxyCommand: proxyCommand, Options: options, RemoteCommand: remoteCommand, RequestTTY: requestTTY, diff --git a/internal/ui/info_form.go b/internal/ui/info_form.go index 8b50364..50f386e 100644 --- a/internal/ui/info_form.go +++ b/internal/ui/info_form.go @@ -97,6 +97,7 @@ func (m *infoFormModel) View() string { {"Port", formatOptionalValue(m.host.Port)}, {"Identity File", formatOptionalValue(m.host.Identity)}, {"ProxyJump", formatOptionalValue(m.host.ProxyJump)}, + {"ProxyCommand", formatOptionalValue(m.host.ProxyCommand)}, {"SSH Options", formatSSHOptions(m.host.Options)}, {"Tags", formatTags(m.host.Tags)}, } From 8278a3752bc887be0aeb51961d7b6ba46e54c4e5 Mon Sep 17 00:00:00 2001 From: SimonGaufreteau Date: Wed, 15 Oct 2025 12:58:10 +0200 Subject: [PATCH 2/3] Fix crashes with ProxyCommand --- internal/ui/add_form.go | 6 +++--- internal/ui/edit_form.go | 19 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/internal/ui/add_form.go b/internal/ui/add_form.go index eb3717b..8cbeda4 100644 --- a/internal/ui/add_form.go +++ b/internal/ui/add_form.go @@ -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 inputs[nameInput] = textinput.New() @@ -93,7 +93,7 @@ func NewAddForm(hostname string, styles Styles, width, height int, configFile st // ProxyCommand input inputs[proxyCommandInput] = textinput.New() - inputs[proxyCommandInput].Placeholder = "/usr/local/bin/wssh proxy %h" + inputs[proxyCommandInput].Placeholder = "ssh -W %h:%p Jumphost" inputs[proxyCommandInput].CharLimit = 200 inputs[proxyCommandInput].Width = 50 @@ -408,7 +408,7 @@ func (m *addFormModel) renderGeneralTab() string { {portInput, "Port"}, {identityInput, "Identity File"}, {proxyJumpInput, "ProxyJump"}, - {proxyCommandInput, "ProxyCommand"} + {proxyCommandInput, "ProxyCommand"}, {tagsInput, "Tags (comma-separated)"}, } diff --git a/internal/ui/edit_form.go b/internal/ui/edit_form.go index eacba95..7dfa48d 100644 --- a/internal/ui/edit_form.go +++ b/internal/ui/edit_form.go @@ -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 inputs[0] = textinput.New() @@ -130,7 +130,7 @@ func NewEditForm(hostName string, styles Styles, width, height int, configFile s // ProxyCommand input inputs[5] = textinput.New() - inputs[5].Placeholder = "/bin/wssh proxy" + inputs[5].Placeholder = "ssh -W %h:%p Jumphost" inputs[5].CharLimit = 200 inputs[5].Width = 50 inputs[5].SetValue(host.ProxyCommand) @@ -260,19 +260,19 @@ func (m *editFormModel) updateFocus() tea.Cmd { func (m *editFormModel) getPropertiesForCurrentTab() []int { switch m.currentTab { case 0: // General - return []int{0, 1, 2, 3, 4, 5, 6} // hostname, user, port, identity, proxyjump, proxycommand, tags + return []int{0, 1, 2, 3, 4, 5, 7} // hostname, user, port, identity, proxyjump, proxycommand, tags case 1: // Advanced - return []int{5, 7, 8} // options, remotecommand, requesttty + return []int{6, 8, 9} // options, remotecommand, requesttty 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 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 { - properties = []int{5, 7, 8} // Advanced tab + properties = []int{6, 8, 9} // Advanced tab } if len(properties) > 0 { return properties[0] @@ -587,7 +587,8 @@ func (m *editFormModel) renderEditGeneralTab() string { {2, "Port"}, {3, "Identity File"}, {4, "Proxy Jump"}, - {6, "Tags (comma-separated)"}, + {5, "Proxy Command"}, + {7, "Tags (comma-separated)"}, } for _, field := range fields { @@ -690,7 +691,7 @@ func (m *editFormModel) submitEditForm() tea.Cmd { port := strings.TrimSpace(m.inputs[2].Value()) // portInput identity := strings.TrimSpace(m.inputs[3].Value()) // identityInput proxyJump := strings.TrimSpace(m.inputs[4].Value()) // proxyJumpInput - proxyCommand := strings.TrimSpace(m.inputs[5].Value()) // proxyCommandInput + proxyCommand := strings.TrimSpace(m.inputs[5].Value()) // proxyCommandInput options := strings.TrimSpace(m.inputs[6].Value()) // optionsInput remoteCommand := strings.TrimSpace(m.inputs[8].Value()) // remoteCommandInput requestTTY := strings.TrimSpace(m.inputs[9].Value()) // requestTTYInput From 8f502e54fefbc05550c1fb9ee78ca76ffd4a7b14 Mon Sep 17 00:00:00 2001 From: SimonGaufreteau Date: Wed, 15 Oct 2025 12:58:18 +0200 Subject: [PATCH 3/3] Add ProxyCommand to README --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ae96fa2..c54c51a 100644 --- a/README.md +++ b/README.md @@ -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 Backups** - Backup configurations automatically before changes - **✅ 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 - **🌐 Cross-platform** - Supports Linux, macOS (Intel & Apple Silicon), and Windows - **⚡ 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) - **Identity File** - Private key path - **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`) - **Tags** - Comma-separated tags for organization @@ -504,6 +505,7 @@ Host backend-prod User app Port 22 ProxyJump bastion.company.com + ProxyCommand ssh -W %h:%p Jumphost IdentityFile ~/.ssh/production_key Compression yes ServerAliveInterval 300 @@ -520,6 +522,7 @@ SSHM supports all standard SSH configuration options: - `Port` - SSH port number - `IdentityFile` - Path to private key file - `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) **Additional SSH Options:**