feat: Add 60+ SSH config field support with enhanced UI (#50)

This commit is contained in:
Pei-Tang Huang
2025-09-18 20:36:08 +01:00
committed by GitHub
parent e75b61e819
commit 3b9f866d55
20 changed files with 6019 additions and 156 deletions
+3
View File
@@ -36,3 +36,6 @@ bin
.DS_Store
# Added by goreleaser init:
dist/
# Binary output
lazyssh
+30 -6
View File
@@ -14,8 +14,8 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw
### Server Management
- 📜 Read & display servers from your `~/.ssh/config` in a scrollable list.
- Add a new server from the UI by specifying alias, host/IP, username, port, identity file.
- ✏ Edit existing server entries directly from the UI.
- Add a new server from the UI with comprehensive SSH configuration options.
- ✏ Edit existing server entries directly from the UI with a tabbed interface.
- 🗑 Delete server entries safely.
- 📌 Pin / unpin servers to keep favorites at the top.
- 🏓 Ping server to check status.
@@ -26,11 +26,22 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw
- 🏷 Tag servers (e.g., prod, dev, test) for quick filtering.
- ↕️ Sort by alias or last SSH (toggle + reverse).
### Advanced SSH Configuration
- 🔗 Port forwarding (LocalForward, RemoteForward, DynamicForward).
- 🚀 Connection multiplexing for faster subsequent connections.
- 🔐 Advanced authentication options (public key, password, agent forwarding).
- 🔒 Security settings (ciphers, MACs, key exchange algorithms).
- 🌐 Proxy settings (ProxyJump, ProxyCommand).
- ⚙️ Extensive SSH config options organized in tabbed interface.
### Key Management
- 🔑 SSH key autocomplete with automatic detection of available keys.
- 📝 Smart key selection with support for multiple keys.
### Upcoming
- 📁 Copy files between local and servers with an easy picker UI.
- 📡 Port forwarding (local↔remote) from the UI.
- 🔑 Enhanced Key Management:
- 🔑 SSH Key Deployment Features:
- Use default local public key (`~/.ssh/id_ed25519.pub` or `~/.ssh/id_rsa.pub`)
- Paste custom public keys manually
- Generate new keypairs and deploy them
@@ -86,10 +97,15 @@ Fuzzy search functionality to quickly find servers by name, IP address, or tags
---
### Add Server
### Add/Edit Server
<img src="./docs/add server.png" alt="Add a new server" width="900" />
User-friendly form interface for adding new SSH connections.
Tabbed interface for managing SSH connections with extensive configuration options organized into:
- **Basic** - Host, user, port, keys, tags
- **Connection** - Proxy, timeouts, multiplexing, canonicalization
- **Forwarding** - Port forwarding, X11, agent
- **Authentication** - Keys, passwords, methods, algorithm settings
- **Advanced** - Security, cryptography, environment, debugging
---
@@ -163,6 +179,14 @@ make run
| S | Reverse sort order |
| q | Quit |
**In Server Form:**
| Key | Action |
| ------ | -------------------- |
| Ctrl+H | Previous tab |
| Ctrl+L | Next tab |
| Ctrl+S | Save |
| Esc | Cancel |
Tip: The hint bar at the top of the list shows the most useful shortcuts.
---
+351 -25
View File
@@ -103,13 +103,89 @@ func (r *Repository) createHostFromServer(server domain.Server) *ssh_config.Host
SpaceBeforeComment: strings.Repeat(" ", 4),
}
// Basic config - always present
r.addKVNodeIfNotEmpty(host, "HostName", server.Host)
r.addKVNodeIfNotEmpty(host, "User", server.User)
r.addKVNodeIfNotEmpty(host, "Port", fmt.Sprintf("%d", server.Port))
if server.Port != 0 {
r.addKVNodeIfNotEmpty(host, "Port", fmt.Sprintf("%d", server.Port))
}
for _, identityFile := range server.IdentityFiles {
r.addKVNodeIfNotEmpty(host, "IdentityFile", identityFile)
}
// Connection and proxy settings
r.addKVNodeIfNotEmpty(host, "ProxyJump", server.ProxyJump)
r.addKVNodeIfNotEmpty(host, "ProxyCommand", server.ProxyCommand)
r.addKVNodeIfNotEmpty(host, "RemoteCommand", server.RemoteCommand)
r.addKVNodeIfNotEmpty(host, "RequestTTY", server.RequestTTY)
r.addKVNodeIfNotEmpty(host, "ConnectTimeout", server.ConnectTimeout)
r.addKVNodeIfNotEmpty(host, "ConnectionAttempts", server.ConnectionAttempts)
// Port forwarding
for _, forward := range server.LocalForward {
configFormat := r.convertCLIForwardToConfigFormat(forward)
r.addKVNodeIfNotEmpty(host, "LocalForward", configFormat)
}
for _, forward := range server.RemoteForward {
configFormat := r.convertCLIForwardToConfigFormat(forward)
r.addKVNodeIfNotEmpty(host, "RemoteForward", configFormat)
}
for _, forward := range server.DynamicForward {
r.addKVNodeIfNotEmpty(host, "DynamicForward", forward)
}
// Authentication and key management
r.addKVNodeIfNotEmpty(host, "PubkeyAuthentication", server.PubkeyAuthentication)
r.addKVNodeIfNotEmpty(host, "PubkeyAcceptedAlgorithms", server.PubkeyAcceptedAlgorithms)
r.addKVNodeIfNotEmpty(host, "HostbasedAcceptedAlgorithms", server.HostbasedAcceptedAlgorithms)
r.addKVNodeIfNotEmpty(host, "PasswordAuthentication", server.PasswordAuthentication)
r.addKVNodeIfNotEmpty(host, "PreferredAuthentications", server.PreferredAuthentications)
r.addKVNodeIfNotEmpty(host, "IdentitiesOnly", server.IdentitiesOnly)
r.addKVNodeIfNotEmpty(host, "AddKeysToAgent", server.AddKeysToAgent)
r.addKVNodeIfNotEmpty(host, "IdentityAgent", server.IdentityAgent)
// Agent and X11 forwarding
r.addKVNodeIfNotEmpty(host, "ForwardAgent", server.ForwardAgent)
r.addKVNodeIfNotEmpty(host, "ForwardX11", server.ForwardX11)
r.addKVNodeIfNotEmpty(host, "ForwardX11Trusted", server.ForwardX11Trusted)
// Connection multiplexing
r.addKVNodeIfNotEmpty(host, "ControlMaster", server.ControlMaster)
r.addKVNodeIfNotEmpty(host, "ControlPath", server.ControlPath)
r.addKVNodeIfNotEmpty(host, "ControlPersist", server.ControlPersist)
// Connection reliability
r.addKVNodeIfNotEmpty(host, "ServerAliveInterval", server.ServerAliveInterval)
r.addKVNodeIfNotEmpty(host, "ServerAliveCountMax", server.ServerAliveCountMax)
r.addKVNodeIfNotEmpty(host, "Compression", server.Compression)
r.addKVNodeIfNotEmpty(host, "TCPKeepAlive", server.TCPKeepAlive)
r.addKVNodeIfNotEmpty(host, "BatchMode", server.BatchMode)
// Security
r.addKVNodeIfNotEmpty(host, "StrictHostKeyChecking", server.StrictHostKeyChecking)
r.addKVNodeIfNotEmpty(host, "UserKnownHostsFile", server.UserKnownHostsFile)
r.addKVNodeIfNotEmpty(host, "HostKeyAlgorithms", server.HostKeyAlgorithms)
r.addKVNodeIfNotEmpty(host, "VerifyHostKeyDNS", server.VerifyHostKeyDNS)
r.addKVNodeIfNotEmpty(host, "UpdateHostKeys", server.UpdateHostKeys)
r.addKVNodeIfNotEmpty(host, "HashKnownHosts", server.HashKnownHosts)
r.addKVNodeIfNotEmpty(host, "VisualHostKey", server.VisualHostKey)
// Command execution
r.addKVNodeIfNotEmpty(host, "LocalCommand", server.LocalCommand)
r.addKVNodeIfNotEmpty(host, "PermitLocalCommand", server.PermitLocalCommand)
r.addKVNodeIfNotEmpty(host, "EscapeChar", server.EscapeChar)
// Environment settings
for _, env := range server.SendEnv {
r.addKVNodeIfNotEmpty(host, "SendEnv", env)
}
for _, env := range server.SetEnv {
r.addKVNodeIfNotEmpty(host, "SetEnv", env)
}
// Debugging
r.addKVNodeIfNotEmpty(host, "LogLevel", server.LogLevel)
return host
}
@@ -127,38 +203,136 @@ func (r *Repository) addKVNodeIfNotEmpty(host *ssh_config.Host, key, value strin
host.Nodes = append(host.Nodes, kvNode)
}
// removeNodesByKey removes all nodes with the specified key from the nodes slice
func removeNodesByKey(nodes []ssh_config.Node, key string) []ssh_config.Node {
filtered := make([]ssh_config.Node, 0, len(nodes))
for _, node := range nodes {
if kv, ok := node.(*ssh_config.KV); ok {
if strings.EqualFold(kv.Key, key) {
continue // skip nodes with matching key
}
}
filtered = append(filtered, node)
}
return filtered
}
// updateHostNodes updates the nodes of an existing host with new server details.
func (r *Repository) updateHostNodes(host *ssh_config.Host, newServer domain.Server) {
updates := map[string]string{
"hostname": newServer.Host,
"user": newServer.User,
"port": fmt.Sprintf("%d", newServer.Port),
// Handle Port - include if explicitly set (even if it's 22)
portValue := ""
if newServer.Port != 0 {
portValue = fmt.Sprintf("%d", newServer.Port)
}
updates := map[string]string{
"hostname": newServer.Host,
"user": newServer.User,
"port": portValue,
"proxycommand": newServer.ProxyCommand,
"proxyjump": newServer.ProxyJump,
"remotecommand": newServer.RemoteCommand,
"requesttty": newServer.RequestTTY,
"sessiontype": newServer.SessionType,
"connecttimeout": newServer.ConnectTimeout,
"connectionattempts": newServer.ConnectionAttempts,
"bindaddress": newServer.BindAddress,
"bindinterface": newServer.BindInterface,
"addressfamily": newServer.AddressFamily,
"exitonforwardfailure": newServer.ExitOnForwardFailure,
"ipqos": newServer.IPQoS,
"canonicalizehostname": newServer.CanonicalizeHostname,
"canonicaldomains": newServer.CanonicalDomains,
"canonicalizefallbacklocal": newServer.CanonicalizeFallbackLocal,
"canonicalizemaxdots": newServer.CanonicalizeMaxDots,
"canonicalizepermittedcnames": newServer.CanonicalizePermittedCNAMEs,
"clearallforwardings": newServer.ClearAllForwardings,
"gatewayports": newServer.GatewayPorts,
"pubkeyauthentication": newServer.PubkeyAuthentication,
"passwordauthentication": newServer.PasswordAuthentication,
"preferredauthentications": newServer.PreferredAuthentications,
"pubkeyacceptedalgorithms": newServer.PubkeyAcceptedAlgorithms,
"pubkeyacceptedkeytypes": newServer.PubkeyAcceptedAlgorithms, // Deprecated alias (since OpenSSH 8.5)
"hostbasedacceptedalgorithms": newServer.HostbasedAcceptedAlgorithms,
"hostbasedkeytypes": newServer.HostbasedAcceptedAlgorithms, // Deprecated alias (since OpenSSH 8.5)
"hostbasedacceptedkeytypes": newServer.HostbasedAcceptedAlgorithms, // Deprecated alias (since OpenSSH 8.5)
"identitiesonly": newServer.IdentitiesOnly,
"addkeystoagent": newServer.AddKeysToAgent,
"identityagent": newServer.IdentityAgent,
"kbdinteractiveauthentication": newServer.KbdInteractiveAuthentication,
"challengeresponseauthentication": newServer.KbdInteractiveAuthentication, // Deprecated alias
"numberofpasswordprompts": newServer.NumberOfPasswordPrompts,
"forwardagent": newServer.ForwardAgent,
"forwardx11": newServer.ForwardX11,
"forwardx11trusted": newServer.ForwardX11Trusted,
"controlmaster": newServer.ControlMaster,
"controlpath": newServer.ControlPath,
"controlpersist": newServer.ControlPersist,
"serveraliveinterval": newServer.ServerAliveInterval,
"serveralivecountmax": newServer.ServerAliveCountMax,
"compression": newServer.Compression,
"tcpkeepalive": newServer.TCPKeepAlive,
"batchmode": newServer.BatchMode,
"stricthostkeychecking": newServer.StrictHostKeyChecking,
"checkhostip": newServer.CheckHostIP,
"fingerprinthash": newServer.FingerprintHash,
"userknownhostsfile": newServer.UserKnownHostsFile,
"hostkeyalgorithms": newServer.HostKeyAlgorithms,
"macs": newServer.MACs,
"ciphers": newServer.Ciphers,
"kexalgorithms": newServer.KexAlgorithms,
"verifyhostkeydns": newServer.VerifyHostKeyDNS,
"updatehostkeys": newServer.UpdateHostKeys,
"hashknownhosts": newServer.HashKnownHosts,
"visualhostkey": newServer.VisualHostKey,
"localcommand": newServer.LocalCommand,
"permitlocalcommand": newServer.PermitLocalCommand,
"escapechar": newServer.EscapeChar,
"loglevel": newServer.LogLevel,
}
// Update or remove nodes based on value
for key, value := range updates {
if value != "" {
r.updateOrAddKVNode(host, key, value)
} else {
// Remove the key if value is empty (user selected default)
r.removeKVNode(host, key)
}
}
// Replace IdentityFile entries entirely to reflect the new state.
// This ensures removing/clearing identity files works as expected.
removeKey := func(nodes []ssh_config.Node, key string) []ssh_config.Node {
filtered := make([]ssh_config.Node, 0, len(nodes))
for _, node := range nodes {
if kv, ok := node.(*ssh_config.KV); ok {
if strings.EqualFold(kv.Key, key) {
continue // skip existing IdentityFile
}
}
filtered = append(filtered, node)
}
return filtered
}
host.Nodes = removeKey(host.Nodes, "IdentityFile")
// Replace multi-value entries entirely to reflect the new state
host.Nodes = removeNodesByKey(host.Nodes, "IdentityFile")
for _, identityFile := range newServer.IdentityFiles {
r.addKVNodeIfNotEmpty(host, "IdentityFile", identityFile)
}
host.Nodes = removeNodesByKey(host.Nodes, "LocalForward")
for _, forward := range newServer.LocalForward {
configFormat := r.convertCLIForwardToConfigFormat(forward)
r.addKVNodeIfNotEmpty(host, "LocalForward", configFormat)
}
host.Nodes = removeNodesByKey(host.Nodes, "RemoteForward")
for _, forward := range newServer.RemoteForward {
configFormat := r.convertCLIForwardToConfigFormat(forward)
r.addKVNodeIfNotEmpty(host, "RemoteForward", configFormat)
}
host.Nodes = removeNodesByKey(host.Nodes, "DynamicForward")
for _, forward := range newServer.DynamicForward {
r.addKVNodeIfNotEmpty(host, "DynamicForward", forward)
}
host.Nodes = removeNodesByKey(host.Nodes, "SendEnv")
for _, env := range newServer.SendEnv {
r.addKVNodeIfNotEmpty(host, "SendEnv", env)
}
host.Nodes = removeNodesByKey(host.Nodes, "SetEnv")
for _, env := range newServer.SetEnv {
r.addKVNodeIfNotEmpty(host, "SetEnv", env)
}
}
// updateOrAddKVNode updates an existing key-value node or adds a new one if it doesn't exist.
@@ -183,14 +357,93 @@ func (r *Repository) updateOrAddKVNode(host *ssh_config.Host, key, newValue stri
host.Nodes = append(host.Nodes, kvNode)
}
// removeKVNode removes a key-value node from the host if it exists.
func (r *Repository) removeKVNode(host *ssh_config.Host, key string) {
filtered := make([]ssh_config.Node, 0, len(host.Nodes))
for _, node := range host.Nodes {
if kvNode, ok := node.(*ssh_config.KV); ok {
if strings.EqualFold(kvNode.Key, key) {
continue // Skip this node (remove it)
}
}
filtered = append(filtered, node)
}
host.Nodes = filtered
}
// getProperKeyCase returns the proper case for known SSH config keys.
// Reference: https://www.ssh.com/academy/ssh/config
func (r *Repository) getProperKeyCase(key string) string {
keyMap := map[string]string{
"hostname": "HostName",
"user": "User",
"port": "Port",
"identityfile": "IdentityFile",
"hostname": "HostName",
"user": "User",
"port": "Port",
"identityfile": "IdentityFile",
"proxycommand": "ProxyCommand",
"proxyjump": "ProxyJump",
"remotecommand": "RemoteCommand",
"requesttty": "RequestTTY",
"sessiontype": "SessionType",
"connecttimeout": "ConnectTimeout",
"connectionattempts": "ConnectionAttempts",
"bindaddress": "BindAddress",
"bindinterface": "BindInterface",
"addressfamily": "AddressFamily",
"exitonforwardfailure": "ExitOnForwardFailure",
"ipqos": "IPQoS",
"canonicalizehostname": "CanonicalizeHostname",
"canonicaldomains": "CanonicalDomains",
"canonicalizefallbacklocal": "CanonicalizeFallbackLocal",
"canonicalizemaxdots": "CanonicalizeMaxDots",
"canonicalizepermittedcnames": "CanonicalizePermittedCNAMEs",
"localforward": "LocalForward",
"remoteforward": "RemoteForward",
"dynamicforward": "DynamicForward",
"clearallforwardings": "ClearAllForwardings",
"gatewayports": "GatewayPorts",
"pubkeyauthentication": "PubkeyAuthentication",
"passwordauthentication": "PasswordAuthentication",
"preferredauthentications": "PreferredAuthentications",
"pubkeyacceptedalgorithms": "PubkeyAcceptedAlgorithms",
"pubkeyacceptedkeytypes": "PubkeyAcceptedAlgorithms", // Deprecated alias (since OpenSSH 8.5)
"hostbasedacceptedalgorithms": "HostbasedAcceptedAlgorithms",
"hostbasedkeytypes": "HostbasedAcceptedAlgorithms", // Deprecated alias (since OpenSSH 8.5)
"hostbasedacceptedkeytypes": "HostbasedAcceptedAlgorithms", // Deprecated alias (since OpenSSH 8.5)
"identitiesonly": "IdentitiesOnly",
"addkeystoagent": "AddKeysToAgent",
"identityagent": "IdentityAgent",
"kbdinteractiveauthentication": "KbdInteractiveAuthentication",
"challengeresponseauthentication": "KbdInteractiveAuthentication", // Deprecated alias
"numberofpasswordprompts": "NumberOfPasswordPrompts",
"forwardagent": "ForwardAgent",
"forwardx11": "ForwardX11",
"forwardx11trusted": "ForwardX11Trusted",
"controlmaster": "ControlMaster",
"controlpath": "ControlPath",
"controlpersist": "ControlPersist",
"serveraliveinterval": "ServerAliveInterval",
"serveralivecountmax": "ServerAliveCountMax",
"compression": "Compression",
"tcpkeepalive": "TCPKeepAlive",
"stricthostkeychecking": "StrictHostKeyChecking",
"checkhostip": "CheckHostIP",
"fingerprinthash": "FingerprintHash",
"verifyhostkeydns": "VerifyHostKeyDNS",
"updatehostkeys": "UpdateHostKeys",
"hashknownhosts": "HashKnownHosts",
"visualhostkey": "VisualHostKey",
"userknownhostsfile": "UserKnownHostsFile",
"hostkeyalgorithms": "HostKeyAlgorithms",
"macs": "MACs",
"ciphers": "Ciphers",
"kexalgorithms": "KexAlgorithms",
"localcommand": "LocalCommand",
"permitlocalcommand": "PermitLocalCommand",
"escapechar": "EscapeChar",
"sendenv": "SendEnv",
"setenv": "SetEnv",
"loglevel": "LogLevel",
"batchmode": "BatchMode",
}
if properCase, exists := keyMap[strings.ToLower(key)]; exists {
@@ -199,6 +452,79 @@ func (r *Repository) getProperKeyCase(key string) string {
return key
}
// convertCLIForwardToConfigFormat converts CLI format forwarding spec to SSH config format.
// CLI format: [bind_address:]port:host:hostport
// Config format: [bind_address:]port host:hostport
func (r *Repository) convertCLIForwardToConfigFormat(forward string) string {
// Handle IPv6 addresses in brackets like [2001:db8::1]
// These should be treated as a single unit
// Find the last `:digits` that represents the final port
lastPortStart := -1
for i := len(forward) - 1; i >= 0; i-- {
if forward[i] == ':' {
// Check if everything after this colon is digits
if i+1 < len(forward) {
allDigits := true
hasDigits := false
for j := i + 1; j < len(forward); j++ {
if forward[j] >= '0' && forward[j] <= '9' {
hasDigits = true
} else {
allDigits = false
break
}
}
if allDigits && hasDigits {
lastPortStart = i
break
}
}
}
}
if lastPortStart == -1 {
// No port at the end, return as-is
return forward
}
// Now find the split point between local and remote parts
// We need to handle bracket-enclosed addresses specially
inBrackets := 0
for i := lastPortStart - 1; i >= 0; i-- {
switch forward[i] {
case ']':
inBrackets++
case '[':
inBrackets--
case ':':
if inBrackets != 0 {
continue
}
// This colon is not inside brackets
// Check if this looks like it could be the split point
// The split point would be after a port number (digits after a colon)
// Look ahead to see what comes after this colon
nextChar := byte(' ')
if i+1 < len(forward) {
nextChar = forward[i+1]
}
// If the next character could be start of a host (letter, digit, bracket)
// then this is our split point
if nextChar != ':' {
localPart := forward[:i]
remotePart := forward[i+1:]
return localPart + " " + remotePart
}
}
}
// If no split point found, return as-is
return forward
}
// removeHostByAlias removes a host by its alias from the list of hosts.
func (r *Repository) removeHostByAlias(hosts []*ssh_config.Host, alias string) []*ssh_config.Host {
for i, host := range hosts {
@@ -0,0 +1,147 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ssh_config_file
import (
"testing"
)
func TestConvertCLIForwardToConfigFormat(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "basic local forward",
input: "8080:localhost:80",
expected: "8080 localhost:80",
},
{
name: "local forward with bind address",
input: "127.0.0.1:8080:localhost:80",
expected: "127.0.0.1:8080 localhost:80",
},
{
name: "local forward with wildcard bind",
input: "*:8080:localhost:80",
expected: "*:8080 localhost:80",
},
{
name: "remote forward",
input: "8080:localhost:3000",
expected: "8080 localhost:3000",
},
{
name: "remote forward with bind address",
input: "0.0.0.0:80:localhost:8080",
expected: "0.0.0.0:80 localhost:8080",
},
{
name: "forward with IPv6 address",
input: "8080:[2001:db8::1]:80",
expected: "8080 [2001:db8::1]:80",
},
{
name: "forward with domain",
input: "3306:db.example.com:3306",
expected: "3306 db.example.com:3306",
},
{
name: "invalid format - only one colon",
input: "8080:localhost",
expected: "8080:localhost", // returned as-is
},
{
name: "invalid format - no colons",
input: "8080",
expected: "8080", // returned as-is
},
}
r := &Repository{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := r.convertCLIForwardToConfigFormat(tt.input)
if result != tt.expected {
t.Errorf("convertCLIForwardToConfigFormat(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestConvertConfigForwardToCLIFormat(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "basic local forward",
input: "8080 localhost:80",
expected: "8080:localhost:80",
},
{
name: "local forward with bind address",
input: "127.0.0.1:8080 localhost:80",
expected: "127.0.0.1:8080:localhost:80",
},
{
name: "local forward with wildcard bind",
input: "*:8080 localhost:80",
expected: "*:8080:localhost:80",
},
{
name: "remote forward",
input: "8080 localhost:3000",
expected: "8080:localhost:3000",
},
{
name: "remote forward with bind address",
input: "0.0.0.0:80 localhost:8080",
expected: "0.0.0.0:80:localhost:8080",
},
{
name: "forward with IPv6 address",
input: "8080 [2001:db8::1]:80",
expected: "8080:[2001:db8::1]:80",
},
{
name: "forward with domain",
input: "3306 db.example.com:3306",
expected: "3306:db.example.com:3306",
},
{
name: "already in CLI format",
input: "8080:localhost:80",
expected: "8080:localhost:80", // returned as-is
},
{
name: "no space separator",
input: "8080",
expected: "8080", // returned as-is
},
}
r := &Repository{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := r.convertConfigForwardToCLIFormat(tt.input)
if result != tt.expected {
t.Errorf("convertConfigForwardToCLIFormat(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
@@ -65,19 +65,230 @@ func (r *Repository) toDomainServer(cfg *ssh_config.Config) []domain.Server {
// mapKVToServer maps an ssh_config.KV node to the corresponding fields in domain.Server.
func (r *Repository) mapKVToServer(server *domain.Server, kvNode *ssh_config.KV) {
switch strings.ToLower(kvNode.Key) {
key := strings.ToLower(kvNode.Key)
value := kvNode.Value
// Try mapping in order of categories
if r.mapBasicConfig(server, key, value) {
return
}
if r.mapConnectionConfig(server, key, value) {
return
}
if r.mapForwardingConfig(server, key, value) {
return
}
if r.mapAuthenticationConfig(server, key, value) {
return
}
if r.mapSecurityConfig(server, key, value) {
return
}
if r.mapEnvironmentConfig(server, key, value) {
return
}
r.mapDebugConfig(server, key, value)
}
// mapBasicConfig maps basic SSH configuration fields
func (r *Repository) mapBasicConfig(server *domain.Server, key, value string) bool {
switch key {
case "hostname":
server.Host = kvNode.Value
server.Host = value
case "user":
server.User = kvNode.Value
server.User = value
case "port":
port, err := strconv.Atoi(kvNode.Value)
port, err := strconv.Atoi(value)
if err == nil {
server.Port = port
}
case "identityfile":
server.IdentityFiles = append(server.IdentityFiles, kvNode.Value)
server.IdentityFiles = append(server.IdentityFiles, value)
default:
return false
}
return true
}
// mapConnectionConfig maps connection and proxy configuration fields
func (r *Repository) mapConnectionConfig(server *domain.Server, key, value string) bool {
switch key {
case "proxycommand":
server.ProxyCommand = value
case "proxyjump":
server.ProxyJump = value
case "remotecommand":
server.RemoteCommand = value
case "requesttty":
server.RequestTTY = value
case "sessiontype":
server.SessionType = value
case "connecttimeout":
server.ConnectTimeout = value
case "connectionattempts":
server.ConnectionAttempts = value
case "bindaddress":
server.BindAddress = value
case "bindinterface":
server.BindInterface = value
case "addressfamily":
server.AddressFamily = value
case "exitonforwardfailure":
server.ExitOnForwardFailure = value
case "ipqos":
server.IPQoS = value
case "canonicalizehostname":
server.CanonicalizeHostname = value
case "canonicaldomains":
server.CanonicalDomains = value
case "canonicalizefallbacklocal":
server.CanonicalizeFallbackLocal = value
case "canonicalizemaxdots":
server.CanonicalizeMaxDots = value
case "canonicalizepermittedcnames":
server.CanonicalizePermittedCNAMEs = value
case "serveraliveinterval":
server.ServerAliveInterval = value
case "serveralivecountmax":
server.ServerAliveCountMax = value
case "compression":
server.Compression = value
case "tcpkeepalive":
server.TCPKeepAlive = value
case "batchmode":
server.BatchMode = value
case "controlmaster":
server.ControlMaster = value
case "controlpath":
server.ControlPath = value
case "controlpersist":
server.ControlPersist = value
default:
return false
}
return true
}
// mapForwardingConfig maps port forwarding and agent forwarding fields
func (r *Repository) mapForwardingConfig(server *domain.Server, key, value string) bool {
switch key {
case "localforward":
cliFormat := r.convertConfigForwardToCLIFormat(value)
server.LocalForward = append(server.LocalForward, cliFormat)
case "remoteforward":
cliFormat := r.convertConfigForwardToCLIFormat(value)
server.RemoteForward = append(server.RemoteForward, cliFormat)
case "dynamicforward":
server.DynamicForward = append(server.DynamicForward, value)
case "clearallforwardings":
server.ClearAllForwardings = value
case "gatewayports":
server.GatewayPorts = value
case "forwardagent":
server.ForwardAgent = value
case "forwardx11":
server.ForwardX11 = value
case "forwardx11trusted":
server.ForwardX11Trusted = value
default:
return false
}
return true
}
// mapAuthenticationConfig maps authentication-related fields
func (r *Repository) mapAuthenticationConfig(server *domain.Server, key, value string) bool {
switch key {
case "pubkeyauthentication":
server.PubkeyAuthentication = value
case "pubkeyacceptedalgorithms", "pubkeyacceptedkeytypes":
// PubkeyAcceptedKeyTypes is deprecated alias for PubkeyAcceptedAlgorithms (since OpenSSH 8.5)
server.PubkeyAcceptedAlgorithms = value
case "hostbasedacceptedalgorithms", "hostbasedkeytypes", "hostbasedacceptedkeytypes":
// HostbasedKeyTypes and HostbasedAcceptedKeyTypes are deprecated aliases (since OpenSSH 8.5)
server.HostbasedAcceptedAlgorithms = value
case "passwordauthentication":
server.PasswordAuthentication = value
case "preferredauthentications":
server.PreferredAuthentications = value
case "identitiesonly":
server.IdentitiesOnly = value
case "addkeystoagent":
server.AddKeysToAgent = value
case "identityagent":
server.IdentityAgent = value
case "kbdinteractiveauthentication", "challengeresponseauthentication":
// ChallengeResponseAuthentication is deprecated alias for KbdInteractiveAuthentication
server.KbdInteractiveAuthentication = value
case "numberofpasswordprompts":
server.NumberOfPasswordPrompts = value
default:
return false
}
return true
}
// mapSecurityConfig maps security-related fields
func (r *Repository) mapSecurityConfig(server *domain.Server, key, value string) bool {
switch key {
case "stricthostkeychecking":
server.StrictHostKeyChecking = value
case "checkhostip":
server.CheckHostIP = value
case "fingerprinthash":
server.FingerprintHash = value
case "userknownhostsfile":
server.UserKnownHostsFile = value
case "hostkeyalgorithms":
server.HostKeyAlgorithms = value
case "macs":
server.MACs = value
case "ciphers":
server.Ciphers = value
case "kexalgorithms":
server.KexAlgorithms = value
case "verifyhostkeydns":
server.VerifyHostKeyDNS = value
case "updatehostkeys":
server.UpdateHostKeys = value
case "hashknownhosts":
server.HashKnownHosts = value
case "visualhostkey":
server.VisualHostKey = value
default:
return false
}
return true
}
// mapEnvironmentConfig maps environment and command execution fields
func (r *Repository) mapEnvironmentConfig(server *domain.Server, key, value string) bool {
switch key {
case "localcommand":
server.LocalCommand = value
case "permitlocalcommand":
server.PermitLocalCommand = value
case "escapechar":
server.EscapeChar = value
case "sendenv":
server.SendEnv = append(server.SendEnv, value)
case "setenv":
server.SetEnv = append(server.SetEnv, value)
default:
return false
}
return true
}
// mapDebugConfig maps debugging-related fields
func (r *Repository) mapDebugConfig(server *domain.Server, key, value string) bool {
switch key {
case "loglevel":
server.LogLevel = value
default:
return false
}
return true
}
// mergeMetadata merges additional metadata into the servers.
@@ -104,3 +315,19 @@ func (r *Repository) mergeMetadata(servers []domain.Server, metadata map[string]
}
return servers
}
// convertConfigForwardToCLIFormat converts SSH config format forwarding spec to CLI format.
// Config format: [bind_address:]port host:hostport
// CLI format: [bind_address:]port:host:hostport
func (r *Repository) convertConfigForwardToCLIFormat(forward string) string {
// Find the last space which separates the local part from the remote part
lastSpace := strings.LastIndex(forward, " ")
if lastSpace != -1 {
localPart := forward[:lastSpace]
remotePart := forward[lastSpace+1:]
// Join them with a colon for CLI format
return localPart + ":" + remotePart
}
// If no space found, return as-is (might already be in CLI format)
return forward
}
+223
View File
@@ -0,0 +1,223 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
// SSHFieldDefaults contains the default values for all SSH configuration fields
// This centralizes all default values to ensure consistency across the application
var SSHFieldDefaults = map[string]string{
// Basic fields
"Port": "22",
"User": "", // Empty means current username (OpenSSH default)
// Connection fields
"ConnectTimeout": "", // none (system default)
"ConnectionAttempts": "1",
"IPQoS": "af21 cs1",
"BatchMode": "no",
"Compression": "no",
"AddressFamily": "any",
"RequestTTY": "auto",
"SessionType": "default",
// Proxy fields
"ProxyJump": "", // none
"ProxyCommand": "", // none
"RemoteCommand": "", // none
// Port forwarding fields
"LocalForward": "", // none
"RemoteForward": "", // none
"DynamicForward": "", // none
"ForwardAgent": "no",
"ForwardX11": "no",
"ForwardX11Trusted": "no",
"ClearAllForwardings": "no",
"ExitOnForwardFailure": "no",
"GatewayPorts": "no",
// Authentication fields
"PubkeyAuthentication": "yes",
"PasswordAuthentication": "yes",
"PreferredAuthentications": "gssapi-with-mic,hostbased,publickey,keyboard-interactive,password",
"IdentitiesOnly": "no",
"AddKeysToAgent": "no",
"IdentityAgent": "SSH_AUTH_SOCK",
"KbdInteractiveAuthentication": "yes",
"NumberOfPasswordPrompts": "3",
"PubkeyAcceptedAlgorithms": "", // all supported
"HostbasedAcceptedAlgorithms": "", // all supported
// Multiplexing fields
"ControlMaster": "no",
"ControlPath": "", // none
"ControlPersist": "no",
// Keep-alive fields
"ServerAliveInterval": "0", // disabled
"ServerAliveCountMax": "3",
"TCPKeepAlive": "yes",
// Security fields
"StrictHostKeyChecking": "ask",
"UserKnownHostsFile": "~/.ssh/known_hosts",
"HostKeyAlgorithms": "", // default algorithms
"Ciphers": "", // default ciphers
"MACs": "", // default MACs
"CheckHostIP": "no",
"FingerprintHash": "SHA256", // OpenSSH uses uppercase SHA256
"VerifyHostKeyDNS": "no",
"UpdateHostKeys": "no",
"HashKnownHosts": "no",
"VisualHostKey": "no",
// Cryptography fields
"KexAlgorithms": "", // all supported
// Hostname canonicalization fields
"CanonicalizeHostname": "no",
"CanonicalDomains": "", // none
"CanonicalizeFallbackLocal": "yes",
"CanonicalizeMaxDots": "1",
"CanonicalizePermittedCNAMEs": "", // none
// Command execution fields
"LocalCommand": "", // none
"PermitLocalCommand": "no",
"EscapeChar": "~",
// Environment fields
"SendEnv": "", // none
"SetEnv": "", // none
// Debugging fields
"LogLevel": "INFO",
// Bind options
"BindAddress": "", // none
"BindInterface": "", // none
}
// GetSSHFieldDefault returns the default value for a given SSH field
// Returns empty string if no default is defined
func GetSSHFieldDefault(fieldName string) string {
if value, exists := SSHFieldDefaults[fieldName]; exists {
return value
}
return ""
}
// GetSSHFieldDefaultWithFallback returns the default value for a given SSH field
// with a fallback value if no default is defined
func GetSSHFieldDefaultWithFallback(fieldName, fallback string) string {
if value, exists := SSHFieldDefaults[fieldName]; exists {
return value
}
return fallback
}
// GetFieldPlaceholder returns an appropriate placeholder for a form field
// It returns either the default value, an example, or an empty string
//
//nolint:gocyclo // This is a simple switch statement for field-specific placeholders
func GetFieldPlaceholder(fieldName string) string {
defaultValue := GetSSHFieldDefault(fieldName)
switch fieldName {
// Required fields
case "Alias", "Host":
return "required"
// Fields that show default value in placeholder
case "Port":
return "default: " + defaultValue
case "User":
return "default: current username"
case "ConnectTimeout":
if defaultValue == "" {
return "seconds (default: none)"
}
return "default: " + defaultValue + " seconds"
case "ConnectionAttempts":
return "default: " + defaultValue
case "ServerAliveInterval":
if defaultValue == "0" {
return "seconds (default: 0)"
}
return "default: " + defaultValue + " seconds"
case "ServerAliveCountMax":
return "default: " + defaultValue
case "NumberOfPasswordPrompts":
return "default: " + defaultValue
case "CanonicalizeMaxDots":
return "default: " + defaultValue
case "IPQoS":
return "default: " + defaultValue
case "EscapeChar":
return "default: " + defaultValue
case "IdentityAgent":
if defaultValue != "" {
return "default: " + defaultValue
}
return "default: SSH_AUTH_SOCK"
case "UserKnownHostsFile":
if defaultValue != "" {
return "default: " + defaultValue
}
return "default: ~/.ssh/known_hosts"
// Fields that show examples in placeholder
case "Keys":
return "e.g., ~/.ssh/id_rsa, ~/.ssh/id_ed25519"
case "Tags":
return "comma-separated tags"
case "ProxyJump": //nolint:goconst // Field name used in switch case
return "e.g., bastion.example.com"
case "ProxyCommand":
return "e.g., ssh -W %h:%p jump.example.com"
case "RemoteCommand":
return "e.g., tmux attach"
case "LocalForward":
return "e.g., 8080:localhost:80, 3000:localhost:3000"
case "RemoteForward":
return "e.g., 80:localhost:8080"
case "DynamicForward":
return "e.g., 1080, 1081"
case "ControlPath":
return "e.g., ~/.ssh/master-%r@%h:%p"
case "ControlPersist":
return "e.g., 10m, 4h, yes, no"
case "PreferredAuthentications":
return "e.g., publickey,password"
case "PubkeyAcceptedAlgorithms", "HostbasedAcceptedAlgorithms",
"HostKeyAlgorithms", "Ciphers", "MACs", "KexAlgorithms":
return "algorithms (+/-/^ prefix supported)"
case "BindAddress":
return "IP, hostname, * (all), or localhost"
case "CanonicalDomains":
return "e.g., example.com, internal.net"
case "CanonicalizePermittedCNAMEs":
return "e.g., *.example.com:example.net"
case "LocalCommand":
return "e.g., echo 'Connected to %h'"
case "SendEnv":
return "e.g., LANG, LC_*, TERM"
case "SetEnv":
return "e.g., FOO=bar, DEBUG=1"
// Fields with no placeholder
default:
return ""
}
}
+722
View File
@@ -0,0 +1,722 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
// FieldHelp contains help information for SSH config fields
type FieldHelp struct {
Field string // Field name
Description string // Brief description
Syntax string // Syntax format
Examples []string // Usage examples
Default string // Default value
Since string // OpenSSH version when introduced
Category string // Category for grouping
}
// HelpDisplayMode defines how help is displayed
type HelpDisplayMode int
const (
HelpModeOff HelpDisplayMode = iota // No help shown
HelpModeCompact // Single line help
HelpModeNormal // Standard help panel
HelpModeFull // Detailed help with all info
)
// GetFieldHelp returns help information for a specific field
func GetFieldHelp(fieldName string) *FieldHelp {
if help, exists := fieldHelpData[fieldName]; exists {
// Update default value from centralized source
defaultValue := GetSSHFieldDefault(fieldName)
if defaultValue != "" {
help.Default = formatDefaultValue(fieldName, defaultValue)
}
return &help
}
return nil
}
// formatDefaultValue formats the default value for display in help
func formatDefaultValue(fieldName, value string) string {
// Special formatting for certain fields
switch fieldName {
case "ConnectTimeout":
if value == "" {
return "none (system default)"
}
return value + " seconds"
case "ServerAliveInterval":
if value == "0" {
return "0 (disabled)"
}
return value + " seconds"
case "ControlPath", "ProxyJump", "ProxyCommand", "RemoteCommand",
"LocalForward", "RemoteForward", "DynamicForward",
"LocalCommand", "SendEnv", "SetEnv", "BindAddress", "BindInterface",
"CanonicalDomains", "CanonicalizePermittedCNAMEs",
"PubkeyAcceptedAlgorithms", "HostbasedAcceptedAlgorithms",
"HostKeyAlgorithms", "Ciphers", "MACs", "KexAlgorithms":
if value == "" {
return "none" //nolint:goconst // "none" here means empty/not configured, different from sessionTypeNone
}
return value
case "PreferredAuthentications":
if value == "gssapi-with-mic,hostbased,publickey,keyboard-interactive,password" {
return "gssapi-with-mic,hostbased,publickey,keyboard-interactive,password"
}
return value
case "IdentityAgent":
if value == "SSH_AUTH_SOCK" {
return "SSH_AUTH_SOCK"
}
return value
case "User":
if value == "" {
return "current username"
}
return value
default:
return value
}
}
// fieldHelpData contains help information for all SSH config fields
var fieldHelpData = map[string]FieldHelp{
// Basic fields
"Alias": {
Field: "Alias",
Description: "A nickname or abbreviation for the host. This is what you type after 'ssh' command.",
Syntax: "any_string_without_spaces",
Examples: []string{"myserver", "prod-db", "dev-web-01"},
Default: "(required)",
Category: "Basic",
},
"Host": {
Field: "Host",
Description: "The real hostname or IP address to connect to. Can be a domain name or IP address.",
Syntax: "hostname | ip_address",
Examples: []string{"example.com", "192.168.1.100", "2001:db8::1"},
Default: "(required)",
Category: "Basic",
},
"Port": {
Field: "Port",
Description: "The port number to connect to on the remote host. Standard SSH port is 22.",
Syntax: "port_number (1-65535)",
Examples: []string{"22", "2222", "8022"},
Default: "22",
Category: "Basic",
},
"User": {
Field: "User",
Description: "Username for logging into the remote machine. If not specified, uses current username.",
Syntax: "username",
Examples: []string{"root", "ubuntu", "admin", "deploy"},
Default: "current username",
Category: "Basic",
},
"Keys": {
Field: "Keys",
Description: "Path to SSH private key files for authentication. Multiple keys can be specified.",
Syntax: "path[,path,...]",
Examples: []string{"~/.ssh/id_ed25519", "~/.ssh/id_rsa,~/.ssh/id_ed25519"},
Default: "~/.ssh/id_rsa, ~/.ssh/id_ed25519, etc.",
Category: "Basic",
},
// Connection fields
"ProxyJump": {
Field: "ProxyJump",
Description: "Specifies one or more jump hosts (bastion hosts) to reach the destination. Useful for accessing servers behind firewalls.",
Syntax: "[user@]host[:port][,[user@]host[:port]]",
Examples: []string{"bastion.example.com", "jump1.com,jump2.com", "user@proxy:2222"},
Default: "none",
Since: "OpenSSH 7.3+",
Category: "Connection",
},
"ProxyCommand": {
Field: "ProxyCommand",
Description: "Command to use to connect to the server. Useful for connecting through proxies or using custom connection methods.",
Syntax: "command",
Examples: []string{"ssh -W %h:%p jump.example.com", "nc -X 5 -x proxy:1080 %h %p"},
Default: "none",
Category: "Connection",
},
"RemoteCommand": {
Field: "RemoteCommand",
Description: "Specifies a command to execute on the remote machine after successfully connecting.",
Syntax: "command | none",
Examples: []string{"tmux attach || tmux new", "screen -r", "none"},
Default: "none",
Since: "OpenSSH 7.6+ (for 'none' value)",
Category: "Connection",
},
"ConnectTimeout": {
Field: "ConnectTimeout",
Description: "Timeout in seconds for establishing the connection. Useful for slow or unreliable networks.",
Syntax: "seconds | none",
Examples: []string{"10", "30", "none"},
Default: "none (system default)",
Category: "Connection",
},
"ConnectionAttempts": {
Field: "ConnectionAttempts",
Description: "Number of attempts to make before giving up on connecting.",
Syntax: "number",
Examples: []string{"1", "3", "5"},
Default: "1",
Category: "Connection",
},
"SessionType": {
Field: "SessionType",
Description: "Type of session to request. 'none' (-N flag) is useful for port forwarding without shell.",
Syntax: "none | subsystem | default",
Examples: []string{"none", "subsystem", "default"},
Default: "default",
Since: "OpenSSH 8.7+",
Category: "Connection",
},
"RequestTTY": {
Field: "RequestTTY",
Description: "Request a pseudo-terminal for the session. Required for interactive programs.",
Syntax: "yes | no | force | auto",
Examples: []string{"yes", "force", "auto"},
Default: "auto",
Category: "Connection",
},
// Port forwarding fields
"LocalForward": {
Field: "LocalForward",
Description: "Forward a local port to a remote address. Useful for accessing remote services through SSH tunnel.",
Syntax: "[bind_address:]port:host:hostport (CLI format, auto-converted for config file)",
Examples: []string{"8080:localhost:80", "3306:db.internal:3306", "*:8080:localhost:80"},
Default: "none",
Category: "Forwarding",
},
"RemoteForward": {
Field: "RemoteForward",
Description: "Forward a remote port to a local address. Allows remote users to access local services.",
Syntax: "[bind_address:]port:host:hostport (CLI format, auto-converted for config file)",
Examples: []string{"8080:localhost:3000", "*:80:localhost:8080"},
Default: "none",
Category: "Forwarding",
},
"DynamicForward": {
Field: "DynamicForward",
Description: "Create a SOCKS proxy on the specified port. Useful for routing traffic through SSH.",
Syntax: "[bind_address:]port",
Examples: []string{"1080", "localhost:1080", "*:1080"},
Default: "none",
Category: "Forwarding",
},
"ForwardAgent": {
Field: "ForwardAgent",
Description: "Forward SSH agent connection to remote host. Allows using local SSH keys on remote servers.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "no",
Category: "Forwarding",
},
"ForwardX11": {
Field: "ForwardX11",
Description: "Enable X11 forwarding for GUI applications over SSH.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "no",
Category: "Forwarding",
},
// Authentication fields
"PubkeyAuthentication": {
Field: "PubkeyAuthentication",
Description: "Enable or disable public key authentication.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "yes",
Category: "Authentication",
},
"PasswordAuthentication": {
Field: "PasswordAuthentication",
Description: "Enable or disable password authentication.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "yes",
Category: "Authentication",
},
"PreferredAuthentications": {
Field: "PreferredAuthentications",
Description: "Order of authentication methods to try.",
Syntax: "method[,method,...]",
Examples: []string{"publickey,password", "publickey,keyboard-interactive,password"},
Default: "gssapi-with-mic,hostbased,publickey,keyboard-interactive,password",
Category: "Authentication",
},
"IdentitiesOnly": {
Field: "IdentitiesOnly",
Description: "Only use authentication identity files configured in ssh_config, ignore ssh-agent.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "no",
Category: "Authentication",
},
"AddKeysToAgent": {
Field: "AddKeysToAgent",
Description: "Add keys to ssh-agent automatically when used.",
Syntax: "yes | no | ask | confirm",
Examples: []string{"yes", "ask", "confirm"},
Default: "no",
Since: "OpenSSH 7.2+",
Category: "Authentication",
},
// Multiplexing fields
"ControlMaster": {
Field: "ControlMaster",
Description: "Enable connection multiplexing. Reuse existing connections for speed.",
Syntax: "yes | no | ask | auto | autoask",
Examples: []string{"auto", "yes", "no"},
Default: "no",
Category: "Multiplexing",
},
"ControlPath": {
Field: "ControlPath",
Description: "Path to control socket for connection multiplexing.",
Syntax: "path",
Examples: []string{"~/.ssh/master-%r@%h:%p", "/tmp/ssh-%r@%h:%p"},
Default: "none",
Category: "Multiplexing",
},
"ControlPersist": {
Field: "ControlPersist",
Description: "Keep master connection open in background after initial client exits.",
Syntax: "yes | no | time",
Examples: []string{"yes", "10m", "4h", "no"},
Default: "no",
Category: "Multiplexing",
},
// Keep-alive fields
"ServerAliveInterval": {
Field: "ServerAliveInterval",
Description: "Seconds between keepalive messages. Prevents connection drops on idle connections.",
Syntax: "seconds",
Examples: []string{"60", "120", "300"},
Default: "0 (disabled)",
Category: "Keep-Alive",
},
"ServerAliveCountMax": {
Field: "ServerAliveCountMax",
Description: "Number of keepalive messages before disconnecting.",
Syntax: "count",
Examples: []string{"3", "5", "10"},
Default: "3",
Category: "Keep-Alive",
},
"TCPKeepAlive": {
Field: "TCPKeepAlive",
Description: "Send TCP keepalive messages to detect broken connections.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "yes",
Category: "Keep-Alive",
},
// Security fields
"StrictHostKeyChecking": {
Field: "StrictHostKeyChecking",
Description: "How to handle unknown host keys. 'ask' prompts user, 'no' auto-adds, 'yes' requires pre-existing key.",
Syntax: "yes | no | ask | accept-new",
Examples: []string{"ask", "accept-new", "yes"},
Default: "ask",
Category: "Security",
},
"UserKnownHostsFile": {
Field: "UserKnownHostsFile",
Description: "File to store host keys. Can specify multiple files.",
Syntax: "path [path ...]",
Examples: []string{"~/.ssh/known_hosts", "~/.ssh/known_hosts ~/.ssh/known_hosts2"},
Default: "~/.ssh/known_hosts",
Category: "Security",
},
"HostKeyAlgorithms": {
Field: "HostKeyAlgorithms",
Description: "Host key algorithms in order of preference. Use +/- to add/remove from defaults.",
Syntax: "algorithm[,algorithm,...] | +algo | -algo",
Examples: []string{"ssh-ed25519,ssh-rsa", "+ssh-rsa", "-ssh-dss"},
Default: "ssh-ed25519,ecdsa-sha2-nistp256,ssh-rsa,...",
Category: "Security",
},
"Ciphers": {
Field: "Ciphers",
Description: "Encryption algorithms in order of preference.",
Syntax: "cipher[,cipher,...] | +cipher | -cipher",
Examples: []string{"aes256-gcm@openssh.com,aes256-ctr", "+aes256-cbc", "-3des-cbc"},
Default: "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,...",
Category: "Security",
},
"MACs": {
Field: "MACs",
Description: "Message authentication code algorithms in order of preference.",
Syntax: "mac[,mac,...] | +mac | -mac",
Examples: []string{"hmac-sha2-256,hmac-sha2-512", "+hmac-md5", "-hmac-sha1"},
Default: "umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh.com,...",
Category: "Security",
},
// Other useful fields
"LogLevel": {
Field: "LogLevel",
Description: "Verbosity level for logging. Higher levels show more detail for debugging.",
Syntax: "QUIET | FATAL | ERROR | INFO | VERBOSE | DEBUG | DEBUG1 | DEBUG2 | DEBUG3",
Examples: []string{"INFO", "DEBUG", "ERROR"},
Default: "INFO",
Category: "Debugging",
},
"Compression": {
Field: "Compression",
Description: "Enable compression to reduce bandwidth usage. Useful for slow connections.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "no",
Category: "Connection",
},
"BatchMode": {
Field: "BatchMode",
Description: "Disable all interactive prompts. Useful for scripts and automation.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "no",
Category: "Connection",
},
// Missing fields - Tags
"Tags": {
Field: "Tags",
Description: "Custom tags for organizing and filtering servers. Comma-separated list.",
Syntax: "tag1[,tag2,...] ",
Examples: []string{"production", "development,staging", "web,frontend"},
Default: "none",
Category: "Basic",
},
// Connection - IP and Address fields
"IPQoS": {
Field: "IPQoS",
Description: "Quality of Service (QoS) / DSCP / TOS for SSH connections. Can specify different values for interactive and bulk traffic.",
Syntax: "dscp_value | lowdelay | throughput | reliability | af11-af43 | cs0-cs7 | ef | le",
Examples: []string{"af21 cs1", "lowdelay throughput", "cs2"},
Default: "af21 cs1",
Category: "Connection",
},
"BindAddress": {
Field: "BindAddress",
Description: "Use specific source address for the connection. Useful for multi-homed hosts.",
Syntax: "address | hostname",
Examples: []string{"192.168.1.100", "localhost", "*"},
Default: "none",
Category: "Connection",
},
"BindInterface": {
Field: "BindInterface",
Description: "Use specific network interface for the connection. Useful for routing through specific NICs.",
Syntax: "interface_name",
Examples: []string{"eth0", "en0", "wlan0"},
Default: "none",
Since: "OpenSSH 7.7+",
Category: "Connection",
},
"AddressFamily": {
Field: "AddressFamily",
Description: "Limit connections to IPv4 or IPv6 addresses.",
Syntax: "any | inet | inet6",
Examples: []string{"any", "inet", "inet6"},
Default: "any",
Category: "Connection",
},
// Hostname Canonicalization
"CanonicalizeHostname": {
Field: "CanonicalizeHostname",
Description: "Controls whether to perform hostname canonicalization. Useful for shortening hostnames.",
Syntax: "yes | no | always",
Examples: []string{"yes", "no", "always"},
Default: "no",
Since: "OpenSSH 6.5+",
Category: "Connection",
},
"CanonicalDomains": {
Field: "CanonicalDomains",
Description: "Search domains for hostname canonicalization. SSH will try appending these domains.",
Syntax: "domain1[,domain2,...] ",
Examples: []string{"example.com", "internal.net,example.org"},
Default: "none",
Since: "OpenSSH 6.5+",
Category: "Connection",
},
"CanonicalizeFallbackLocal": {
Field: "CanonicalizeFallbackLocal",
Description: "Whether to fail if canonicalization fails. If yes, uses the original hostname.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "yes",
Since: "OpenSSH 6.5+",
Category: "Connection",
},
"CanonicalizeMaxDots": {
Field: "CanonicalizeMaxDots",
Description: "Maximum dots in hostname before disabling canonicalization.",
Syntax: "number",
Examples: []string{"1", "2", "0"},
Default: "1",
Since: "OpenSSH 6.5+",
Category: "Connection",
},
"CanonicalizePermittedCNAMEs": {
Field: "CanonicalizePermittedCNAMEs",
Description: "Rules for CNAME following during canonicalization.",
Syntax: "source:target[,source:target,...] ",
Examples: []string{"*.example.com:example.net", "*.internal:*.example.com"},
Default: "none",
Since: "OpenSSH 6.5+",
Category: "Connection",
},
"ForwardX11Trusted": {
Field: "ForwardX11Trusted",
Description: "Enable trusted X11 forwarding. Less secure but more compatible.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "no",
Category: "Forwarding",
},
"ClearAllForwardings": {
Field: "ClearAllForwardings",
Description: "Clear all port forwardings set in configuration files.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "no",
Category: "Forwarding",
},
"ExitOnForwardFailure": {
Field: "ExitOnForwardFailure",
Description: "Terminate connection if port forwarding fails.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "no",
Category: "Forwarding",
},
"GatewayPorts": {
Field: "GatewayPorts",
Description: "Allow remote hosts to connect to forwarded ports.",
Syntax: "yes | no | clientspecified",
Examples: []string{"no", "yes", "clientspecified"},
Default: "no",
Category: "Forwarding",
},
// Authentication fields
"KbdInteractiveAuthentication": {
Field: "KbdInteractiveAuthentication",
Description: "Enable keyboard-interactive authentication (e.g., for 2FA).",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "yes",
Category: "Authentication",
},
"NumberOfPasswordPrompts": {
Field: "NumberOfPasswordPrompts",
Description: "Number of password prompts before giving up.",
Syntax: "number",
Examples: []string{"3", "1", "5"},
Default: "3",
Category: "Authentication",
},
"IdentityAgent": {
Field: "IdentityAgent",
Description: "Location of the authentication agent socket.",
Syntax: "path | SSH_AUTH_SOCK | none",
Examples: []string{"SSH_AUTH_SOCK", "~/.ssh/agent.sock", "none"},
Default: "SSH_AUTH_SOCK",
Since: "OpenSSH 7.3+",
Category: "Authentication",
},
"PubkeyAcceptedAlgorithms": {
Field: "PubkeyAcceptedAlgorithms",
Description: "Signature algorithms accepted for public key authentication.",
Syntax: "algorithm[,algorithm,...] ",
Examples: []string{"ssh-ed25519,ssh-rsa", "+ssh-rsa", "-ssh-dss"},
Default: "(all supported)",
Since: "OpenSSH 8.5+ (PubkeyAcceptedKeyTypes before)",
Category: "Authentication",
},
"HostbasedAcceptedAlgorithms": {
Field: "HostbasedAcceptedAlgorithms",
Description: "Signature algorithms accepted for host-based authentication.",
Syntax: "algorithm[,algorithm,...] ",
Examples: []string{"ssh-ed25519,ssh-rsa", "+ssh-rsa", "-ssh-dss"},
Default: "(all supported)",
Since: "OpenSSH 8.5+",
Category: "Authentication",
},
// Security fields
"CheckHostIP": {
Field: "CheckHostIP",
Description: "Check the host IP address in known_hosts file.",
Syntax: "yes | no",
Examples: []string{"yes", "no"},
Default: "no",
Category: "Security",
},
"FingerprintHash": {
Field: "FingerprintHash",
Description: "Hash algorithm for displaying key fingerprints.",
Syntax: "md5 | sha256",
Examples: []string{"sha256", "md5"},
Default: "sha256",
Since: "OpenSSH 6.8+",
Category: "Security",
},
"VerifyHostKeyDNS": {
Field: "VerifyHostKeyDNS",
Description: "Verify host keys using DNS SSHFP records.",
Syntax: "yes | no | ask",
Examples: []string{"no", "ask", "yes"},
Default: "no",
Category: "Security",
},
"UpdateHostKeys": {
Field: "UpdateHostKeys",
Description: "Update known_hosts automatically with new host keys.",
Syntax: "yes | no | ask",
Examples: []string{"no", "ask", "yes"},
Default: "no",
Since: "OpenSSH 6.8+",
Category: "Security",
},
"HashKnownHosts": {
Field: "HashKnownHosts",
Description: "Hash host names and addresses in known_hosts file.",
Syntax: "yes | no",
Examples: []string{"no", "yes"},
Default: "no",
Category: "Security",
},
"VisualHostKey": {
Field: "VisualHostKey",
Description: "Display ASCII art representation of the host key.",
Syntax: "yes | no",
Examples: []string{"no", "yes"},
Default: "no",
Category: "Security",
},
// Cryptography
"KexAlgorithms": {
Field: "KexAlgorithms",
Description: "Key exchange algorithms to use.",
Syntax: "algorithm[,algorithm,...] ",
Examples: []string{"curve25519-sha256", "+diffie-hellman-group14-sha256", "-ecdh-sha2-nistp256"},
Default: "(all supported)",
Category: "Cryptography",
},
// Command execution
"LocalCommand": {
Field: "LocalCommand",
Description: "Command to execute on local machine after connecting.",
Syntax: "command",
Examples: []string{"echo 'Connected to %h'", "notify-send 'SSH Connected'"},
Default: "none",
Category: "Command",
},
"PermitLocalCommand": {
Field: "PermitLocalCommand",
Description: "Allow LocalCommand execution.",
Syntax: "yes | no",
Examples: []string{"no", "yes"},
Default: "no",
Category: "Command",
},
"EscapeChar": {
Field: "EscapeChar",
Description: "Escape character for SSH session (~ by default). Set to 'none' to disable.",
Syntax: "char | none | ^char",
Examples: []string{"~", "^", "none"},
Default: "~",
Category: "Command",
},
// Environment
"SendEnv": {
Field: "SendEnv",
Description: "Environment variables to send to the server.",
Syntax: "variable[,variable,...] ",
Examples: []string{"LANG", "LC_*", "TERM", "LANG LC_* EDITOR"},
Default: "none",
Category: "Environment",
},
"SetEnv": {
Field: "SetEnv",
Description: "Set environment variables for the SSH session.",
Syntax: "VAR=value[,VAR=value,...] ",
Examples: []string{"FOO=bar", "DEBUG=1", "PATH=/custom/path:$PATH"},
Default: "none",
Since: "OpenSSH 7.8+",
Category: "Environment",
},
}
// GetFieldsByCategory returns all fields in a specific category
func GetFieldsByCategory(category string) []string {
// Pre-count to allocate correct capacity
count := 0
for _, help := range fieldHelpData {
if help.Category == category {
count++
}
}
fields := make([]string, 0, count)
for name, help := range fieldHelpData {
if help.Category == category {
fields = append(fields, name)
}
}
return fields
}
// GetAllCategories returns all available help categories
func GetAllCategories() []string {
categories := make(map[string]bool)
for _, help := range fieldHelpData {
categories[help.Category] = true
}
// Convert to slice with defined order
orderedCategories := []string{
"Basic", "Connection", "Forwarding", "Authentication",
"Multiplexing", "Keep-Alive", "Security", "Debugging",
}
var result []string
for _, cat := range orderedCategories {
if categories[cat] {
result = append(result, cat)
}
}
return result
}
+165
View File
@@ -0,0 +1,165 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
import (
"testing"
)
func TestGetFieldHelp(t *testing.T) {
tests := []struct {
name string
fieldName string
wantNil bool
checkFunc func(*FieldHelp) bool
}{
{
name: "Host field should have help",
fieldName: "Host",
wantNil: false,
checkFunc: func(h *FieldHelp) bool {
return h.Field == "Host" &&
h.Description != "" &&
h.Syntax != "" &&
len(h.Examples) > 0
},
},
{
name: "ProxyJump field should have help with version info",
fieldName: "ProxyJump",
wantNil: false,
checkFunc: func(h *FieldHelp) bool {
return h.Field == "ProxyJump" &&
h.Since != "" &&
h.Category == "Connection"
},
},
{
name: "LocalForward field should have correct category",
fieldName: "LocalForward",
wantNil: false,
checkFunc: func(h *FieldHelp) bool {
return h.Category == "Forwarding"
},
},
{
name: "Unknown field should return nil",
fieldName: "NonExistentField",
wantNil: true,
checkFunc: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
help := GetFieldHelp(tt.fieldName)
if tt.wantNil {
if help != nil {
t.Errorf("GetFieldHelp(%s) = %v, want nil", tt.fieldName, help)
}
return
}
if help == nil {
t.Errorf("GetFieldHelp(%s) = nil, want non-nil", tt.fieldName)
return
}
if tt.checkFunc != nil && !tt.checkFunc(help) {
t.Errorf("GetFieldHelp(%s) returned help that doesn't match expected criteria", tt.fieldName)
}
})
}
}
func TestGetFieldsByCategory(t *testing.T) {
tests := []struct {
name string
category string
minCount int // Minimum expected fields in category
}{
{"Basic category", "Basic", 5},
{"Connection category", "Connection", 5},
{"Forwarding category", "Forwarding", 4},
{"Authentication category", "Authentication", 5},
{"Security category", "Security", 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fields := GetFieldsByCategory(tt.category)
if len(fields) < tt.minCount {
t.Errorf("GetFieldsByCategory(%s) returned %d fields, want at least %d",
tt.category, len(fields), tt.minCount)
}
})
}
}
func TestGetAllCategories(t *testing.T) {
categories := GetAllCategories()
// Check we have at least the main categories
expectedCategories := []string{
"Basic", "Connection", "Forwarding", "Authentication", "Security",
}
for _, expected := range expectedCategories {
found := false
for _, cat := range categories {
if cat == expected {
found = true
break
}
}
if !found {
t.Errorf("GetAllCategories() missing expected category: %s", expected)
}
}
if len(categories) < len(expectedCategories) {
t.Errorf("GetAllCategories() returned %d categories, want at least %d",
len(categories), len(expectedCategories))
}
}
func TestHelpContent(t *testing.T) {
// Test that critical fields have comprehensive help
criticalFields := []string{
"Host", "Port", "User", "ProxyJump", "LocalForward",
"ControlMaster", "StrictHostKeyChecking",
}
for _, field := range criticalFields {
help := GetFieldHelp(field)
if help == nil {
t.Errorf("Critical field %s has no help", field)
continue
}
if help.Description == "" {
t.Errorf("Field %s has no description", field)
}
if help.Syntax == "" && len(help.Examples) == 0 {
t.Errorf("Field %s has neither syntax nor examples", field)
}
if help.Default == "" {
t.Logf("Warning: Field %s has no default value specified", field)
}
}
}
+25 -3
View File
@@ -183,6 +183,8 @@ func (t *tui) handleServerSelectionChange(server domain.Server) {
func (t *tui) handleServerAdd() {
form := NewServerForm(ServerFormAdd, nil).
SetApp(t.app).
SetVersionInfo(t.version, t.commit).
OnSave(t.handleServerSave).
OnCancel(t.handleFormCancel)
t.app.SetRoot(form, true)
@@ -191,6 +193,8 @@ func (t *tui) handleServerAdd() {
func (t *tui) handleServerEdit() {
if server, ok := t.serverList.GetSelectedServer(); ok {
form := NewServerForm(ServerFormEdit, &server).
SetApp(t.app).
SetVersionInfo(t.version, t.commit).
OnSave(t.handleServerSave).
OnCancel(t.handleFormCancel)
t.app.SetRoot(form, true)
@@ -308,7 +312,7 @@ func (t *tui) showDeleteConfirmModal(server domain.Server) {
modal := tview.NewModal().
SetText(msg).
AddButtons([]string{"Cancel", "Confirm"}).
AddButtons([]string{"[yellow]C[-]ancel", "[yellow]D[-]elete"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
if buttonIndex == 1 {
_ = t.serverService.DeleteServer(server)
@@ -317,14 +321,32 @@ func (t *tui) showDeleteConfirmModal(server domain.Server) {
t.handleModalClose()
})
// Add keyboard shortcuts for the modal
modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Rune() {
case 'c', 'C':
// Cancel
t.handleModalClose()
return nil
case 'd', 'D':
// Delete
_ = t.serverService.DeleteServer(server)
t.refreshServerList()
t.handleModalClose()
return nil
}
// ESC key already handled by default modal behavior
return event
})
t.app.SetRoot(modal, true)
}
func (t *tui) showEditTagsForm(server domain.Server) {
form := tview.NewForm()
form.SetBorder(true).
SetTitle(fmt.Sprintf("Edit Tags: %s", server.Alias)).
SetTitleAlign(tview.AlignLeft)
SetTitle(fmt.Sprintf(" Edit Tags: %s ", server.Alias)).
SetTitleAlign(tview.AlignCenter)
defaultTags := strings.Join(server.Tags, ", ")
form.AddInputField("Tags (comma):", defaultTags, 40, nil, nil)
@@ -0,0 +1,39 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
import (
"net"
"sort"
)
// GetNetworkInterfaces returns a list of available network interface names
func GetNetworkInterfaces() []string {
interfaces, err := net.Interfaces()
if err != nil {
return []string{}
}
var names []string
for _, iface := range interfaces {
// Skip down interfaces and loopback for cleaner list
if iface.Flags&net.FlagUp != 0 {
names = append(names, iface.Name)
}
}
sort.Strings(names)
return names
}
+2 -1
View File
@@ -39,7 +39,8 @@ func (s *SearchBar) build() {
SetFieldTextColor(tcell.Color252).
SetFieldWidth(30).
SetBorder(true).
SetTitle("Search").
SetTitle(" Search ").
SetTitleAlign(tview.AlignCenter).
SetBorderColor(tcell.Color238).
SetTitleColor(tcell.Color250)
+146 -4
View File
@@ -39,7 +39,8 @@ func (sd *ServerDetails) build() {
sd.TextView.SetDynamicColors(true).
SetWrap(true).
SetBorder(true).
SetTitle("Details").
SetTitle(" Details ").
SetTitleAlign(tview.AlignCenter).
SetBorderColor(tcell.Color238).
SetTitleColor(tcell.Color250)
}
@@ -47,7 +48,7 @@ func (sd *ServerDetails) build() {
// renderTagChips builds colored tag chips for details view.
func renderTagChips(tags []string) string {
if len(tags) == 0 {
return "-"
return ""
}
chips := make([]string, 0, len(tags))
for _, t := range tags {
@@ -68,11 +69,152 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
pinnedStr = "false"
}
tagsText := renderTagChips(server.Tags)
// Basic information
aliasText := strings.Join(server.Aliases, ", ")
userText := server.User
hostText := server.Host
portText := fmt.Sprintf("%d", server.Port)
if server.Port == 0 {
portText = ""
}
text := fmt.Sprintf(
"[::b]%s[-]\n\nHost: [white]%s[-]\nUser: [white]%s[-]\nPort: [white]%d[-]\nKey: [white]%s[-]\nTags: %s\nPinned: [white]%s[-]\nLast SSH: %s\nSSH Count: [white]%d[-]\n\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin",
strings.Join(server.Aliases, ", "), server.Host, server.User, server.Port,
"[::b]%s[-]\n\n[::b]Basic Settings:[-]\n Host: [white]%s[-]\n User: [white]%s[-]\n Port: [white]%s[-]\n Key: [white]%s[-]\n Tags: %s\n Pinned: [white]%s[-]\n Last SSH: %s\n SSH Count: [white]%d[-]\n",
aliasText, hostText, userText, portText,
serverKey, tagsText, pinnedStr,
lastSeen, server.SSHCount)
// Advanced settings section (only show non-empty fields)
// Organized by logical grouping for better readability
type fieldEntry struct {
name string
value string
}
type fieldGroup struct {
name string
fields []fieldEntry
}
// Create field groups for better organization and future extensibility
groups := []fieldGroup{
{
name: "Connection & Proxy",
fields: []fieldEntry{
{"ProxyJump", server.ProxyJump},
{"ProxyCommand", server.ProxyCommand},
{"RemoteCommand", server.RemoteCommand},
{"RequestTTY", server.RequestTTY},
{"SessionType", server.SessionType},
{"ConnectTimeout", server.ConnectTimeout},
{"ConnectionAttempts", server.ConnectionAttempts},
{"BindAddress", server.BindAddress},
{"BindInterface", server.BindInterface},
{"AddressFamily", server.AddressFamily},
{"ExitOnForwardFailure", server.ExitOnForwardFailure},
{"IPQoS", server.IPQoS},
{"CanonicalizeHostname", server.CanonicalizeHostname},
{"CanonicalDomains", server.CanonicalDomains},
{"CanonicalizeFallbackLocal", server.CanonicalizeFallbackLocal},
{"CanonicalizeMaxDots", server.CanonicalizeMaxDots},
{"CanonicalizePermittedCNAMEs", server.CanonicalizePermittedCNAMEs},
{"ServerAliveInterval", server.ServerAliveInterval},
{"ServerAliveCountMax", server.ServerAliveCountMax},
{"Compression", server.Compression},
{"TCPKeepAlive", server.TCPKeepAlive},
{"BatchMode", server.BatchMode},
{"ControlMaster", server.ControlMaster},
{"ControlPath", server.ControlPath},
{"ControlPersist", server.ControlPersist},
},
},
{
name: "Authentication",
fields: []fieldEntry{
{"PubkeyAuthentication", server.PubkeyAuthentication},
{"PubkeyAcceptedAlgorithms", server.PubkeyAcceptedAlgorithms},
{"HostbasedAcceptedAlgorithms", server.HostbasedAcceptedAlgorithms},
{"PasswordAuthentication", server.PasswordAuthentication},
{"PreferredAuthentications", server.PreferredAuthentications},
{"IdentitiesOnly", server.IdentitiesOnly},
{"AddKeysToAgent", server.AddKeysToAgent},
{"IdentityAgent", server.IdentityAgent},
{"KbdInteractiveAuthentication", server.KbdInteractiveAuthentication},
{"NumberOfPasswordPrompts", server.NumberOfPasswordPrompts},
},
},
{
name: "Forwarding",
fields: []fieldEntry{
{"ForwardAgent", server.ForwardAgent},
{"ForwardX11", server.ForwardX11},
{"ForwardX11Trusted", server.ForwardX11Trusted},
{"LocalForward", strings.Join(server.LocalForward, ", ")},
{"RemoteForward", strings.Join(server.RemoteForward, ", ")},
{"DynamicForward", strings.Join(server.DynamicForward, ", ")},
{"ClearAllForwardings", server.ClearAllForwardings},
{"GatewayPorts", server.GatewayPorts},
},
},
{
name: "Security & Cryptography",
fields: []fieldEntry{
{"StrictHostKeyChecking", server.StrictHostKeyChecking},
{"CheckHostIP", server.CheckHostIP},
{"FingerprintHash", server.FingerprintHash},
{"UserKnownHostsFile", server.UserKnownHostsFile},
{"HostKeyAlgorithms", server.HostKeyAlgorithms},
{"Ciphers", server.Ciphers},
{"MACs", server.MACs},
{"KexAlgorithms", server.KexAlgorithms},
{"VerifyHostKeyDNS", server.VerifyHostKeyDNS},
{"UpdateHostKeys", server.UpdateHostKeys},
{"HashKnownHosts", server.HashKnownHosts},
{"VisualHostKey", server.VisualHostKey},
},
},
{
name: "Environment & Execution",
fields: []fieldEntry{
{"LocalCommand", server.LocalCommand},
{"PermitLocalCommand", server.PermitLocalCommand},
{"EscapeChar", server.EscapeChar},
{"SendEnv", strings.Join(server.SendEnv, ", ")},
{"SetEnv", strings.Join(server.SetEnv, ", ")},
},
},
{
name: "Debugging",
fields: []fieldEntry{
{"LogLevel", server.LogLevel},
},
},
}
// Build advanced settings text without group labels for cleaner display
hasAdvanced := false
advancedText := "\n[::b]Advanced Settings:[-]\n"
for _, group := range groups {
for _, field := range group.fields {
if field.value != "" {
hasAdvanced = true
advancedText += fmt.Sprintf(" %s: [white]%s[-]\n", field.name, field.value)
}
}
}
if hasAdvanced {
text += advancedText
}
// Commands list
text += "\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin"
sd.TextView.SetText(text)
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -38,7 +38,8 @@ func NewServerList() *ServerList {
func (sl *ServerList) build() {
sl.List.ShowSecondaryText(false)
sl.List.SetBorder(true).
SetTitle("Servers").
SetTitle(" Servers ").
SetTitleAlign(tview.AlignCenter).
SetBorderColor(tcell.Color238).
SetTitleColor(tcell.Color250)
sl.List.
+1 -1
View File
@@ -141,6 +141,6 @@ func (t *tui) loadInitialData() *tui {
func (t *tui) updateListTitle() {
if t.serverList != nil {
t.serverList.SetTitle("Servers — Sort: " + t.sortMode.String())
t.serverList.SetTitle(" Servers — Sort: " + t.sortMode.String() + " ")
}
}
+501 -6
View File
@@ -16,6 +16,8 @@ package ui
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
@@ -23,6 +25,18 @@ import (
"github.com/mattn/go-runewidth"
)
// SSH config value constants
const (
sshYes = "yes"
sshNo = "no"
sshForce = "force"
sshAuto = "auto"
// SessionType values
sessionTypeNone = "none"
sessionTypeSubsystem = "subsystem"
)
// renderTagBadgesForList renders up to two colored tag chips for the server list.
// If there are more tags, it appends a subtle gray "+N" badge. Returns an empty
// string when there are no tags to avoid cluttering the list.
@@ -107,9 +121,54 @@ func humanizeDuration(t time.Time) string {
}
// BuildSSHCommand constructs a ready-to-run ssh command for the given server.
// Format: ssh [user@]host [-p PORT if not 22] [-i KEY if provided]
// Format: ssh [options] [user@]host [command]
func BuildSSHCommand(s domain.Server) string {
parts := []string{"ssh"}
// Add proxy and connection options
addProxyOptions(&parts, s)
addConnectionTimingOptions(&parts, s)
// Add port forwarding options
addPortForwardingOptions(&parts, s)
// Add authentication options
addAuthOptions(&parts, s)
// Add agent and forwarding options
addForwardingOptions(&parts, s)
// Add connection multiplexing options
addMultiplexingOptions(&parts, s)
// Add connection reliability options
addConnectionOptions(&parts, s)
// Add security options
addSecurityOptions(&parts, s)
// Add command execution options
addCommandExecutionOptions(&parts, s)
// Add environment options
addEnvironmentOptions(&parts, s)
// Add TTY and logging options
addTTYAndLoggingOptions(&parts, s)
// Port option
if s.Port != 0 && s.Port != 22 {
parts = append(parts, "-p", fmt.Sprintf("%d", s.Port))
}
// Identity file option
if len(s.IdentityFiles) > 0 {
for _, keyFile := range s.IdentityFiles {
parts = append(parts, "-i", quoteIfNeeded(keyFile))
}
}
// Host specification
userHost := ""
switch {
case s.User != "" && s.Host != "":
@@ -121,15 +180,285 @@ func BuildSSHCommand(s domain.Server) string {
}
parts = append(parts, userHost)
if s.Port != 0 && s.Port != 22 {
parts = append(parts, "-p", fmt.Sprintf("%d", s.Port))
}
if len(s.IdentityFiles) > 0 {
parts = append(parts, "-i", quoteIfNeeded(s.IdentityFiles[0]))
// RemoteCommand (must come after the host)
if s.RemoteCommand != "" {
// Handle special case: RemoteCommand=none clears the command (OpenSSH 7.6+)
if s.RemoteCommand == sessionTypeNone {
parts = append(parts, "-o", "RemoteCommand=none")
} else {
parts = append(parts, quoteIfNeeded(s.RemoteCommand))
}
}
return strings.Join(parts, " ")
}
// addOption adds an SSH option in the format "-o Key=Value" if value is not empty
func addOption(parts *[]string, key, value string) {
if value != "" {
*parts = append(*parts, "-o", fmt.Sprintf("%s=%s", key, value))
}
}
// addQuotedOption adds an SSH option with quoted value if needed
func addQuotedOption(parts *[]string, key, value string) {
if value != "" {
*parts = append(*parts, "-o", fmt.Sprintf("%s=%s", key, quoteIfNeeded(value)))
}
}
// addProxyOptions adds proxy-related options to the SSH command
func addProxyOptions(parts *[]string, s domain.Server) {
if s.ProxyJump != "" {
*parts = append(*parts, "-J", quoteIfNeeded(s.ProxyJump))
}
addQuotedOption(parts, "ProxyCommand", s.ProxyCommand)
}
// addConnectionTimingOptions adds connection timing options to the SSH command
func addConnectionTimingOptions(parts *[]string, s domain.Server) {
addOption(parts, "ConnectTimeout", s.ConnectTimeout)
addOption(parts, "ConnectionAttempts", s.ConnectionAttempts)
if s.BindAddress != "" {
*parts = append(*parts, "-b", s.BindAddress)
}
if s.BindInterface != "" {
*parts = append(*parts, "-B", s.BindInterface)
}
addOption(parts, "AddressFamily", s.AddressFamily)
addOption(parts, "IPQoS", s.IPQoS)
// Hostname canonicalization options
addOption(parts, "CanonicalizeHostname", s.CanonicalizeHostname)
addOption(parts, "CanonicalDomains", s.CanonicalDomains)
addOption(parts, "CanonicalizeFallbackLocal", s.CanonicalizeFallbackLocal)
addOption(parts, "CanonicalizeMaxDots", s.CanonicalizeMaxDots)
addQuotedOption(parts, "CanonicalizePermittedCNAMEs", s.CanonicalizePermittedCNAMEs)
}
// addPortForwardingOptions adds port forwarding options to the SSH command
func addPortForwardingOptions(parts *[]string, s domain.Server) {
for _, forward := range s.LocalForward {
*parts = append(*parts, "-L", forward)
}
for _, forward := range s.RemoteForward {
*parts = append(*parts, "-R", forward)
}
for _, forward := range s.DynamicForward {
*parts = append(*parts, "-D", forward)
}
if s.ClearAllForwardings == sshYes {
*parts = append(*parts, "-o", "ClearAllForwardings=yes")
}
if s.ExitOnForwardFailure == sshYes {
*parts = append(*parts, "-o", "ExitOnForwardFailure=yes")
}
if s.GatewayPorts != "" {
*parts = append(*parts, "-o", fmt.Sprintf("GatewayPorts=%s", s.GatewayPorts))
}
}
// addAuthOptions adds authentication-related options to the SSH command
func addAuthOptions(parts *[]string, s domain.Server) {
if s.PubkeyAuthentication != "" {
*parts = append(*parts, "-o", fmt.Sprintf("PubkeyAuthentication=%s", s.PubkeyAuthentication))
}
if s.PubkeyAcceptedAlgorithms != "" {
*parts = append(*parts, "-o", fmt.Sprintf("PubkeyAcceptedAlgorithms=%s", s.PubkeyAcceptedAlgorithms))
}
if s.HostbasedAcceptedAlgorithms != "" {
*parts = append(*parts, "-o", fmt.Sprintf("HostbasedAcceptedAlgorithms=%s", s.HostbasedAcceptedAlgorithms))
}
if s.PasswordAuthentication != "" {
*parts = append(*parts, "-o", fmt.Sprintf("PasswordAuthentication=%s", s.PasswordAuthentication))
}
if s.PreferredAuthentications != "" {
*parts = append(*parts, "-o", fmt.Sprintf("PreferredAuthentications=%s", s.PreferredAuthentications))
}
if s.IdentitiesOnly != "" {
*parts = append(*parts, "-o", fmt.Sprintf("IdentitiesOnly=%s", s.IdentitiesOnly))
}
if s.AddKeysToAgent != "" {
*parts = append(*parts, "-o", fmt.Sprintf("AddKeysToAgent=%s", s.AddKeysToAgent))
}
if s.IdentityAgent != "" {
*parts = append(*parts, "-o", fmt.Sprintf("IdentityAgent=%s", quoteIfNeeded(s.IdentityAgent)))
}
if s.KbdInteractiveAuthentication != "" {
*parts = append(*parts, "-o", fmt.Sprintf("KbdInteractiveAuthentication=%s", s.KbdInteractiveAuthentication))
}
if s.NumberOfPasswordPrompts != "" {
*parts = append(*parts, "-o", fmt.Sprintf("NumberOfPasswordPrompts=%s", s.NumberOfPasswordPrompts))
}
}
// addForwardingOptions adds agent and X11 forwarding options to the SSH command
func addForwardingOptions(parts *[]string, s domain.Server) {
if s.ForwardAgent != "" {
if s.ForwardAgent == sshYes {
*parts = append(*parts, "-A")
} else if s.ForwardAgent == sshNo {
*parts = append(*parts, "-a")
}
}
if s.ForwardX11 != "" {
if s.ForwardX11 == sshYes {
*parts = append(*parts, "-X")
} else if s.ForwardX11 == sshNo {
*parts = append(*parts, "-x")
}
}
if s.ForwardX11Trusted == sshYes {
*parts = append(*parts, "-Y")
}
}
// addMultiplexingOptions adds connection multiplexing options to the SSH command
func addMultiplexingOptions(parts *[]string, s domain.Server) {
if s.ControlMaster != "" {
*parts = append(*parts, "-o", fmt.Sprintf("ControlMaster=%s", s.ControlMaster))
}
if s.ControlPath != "" {
*parts = append(*parts, "-o", fmt.Sprintf("ControlPath=%s", quoteIfNeeded(s.ControlPath)))
}
if s.ControlPersist != "" {
*parts = append(*parts, "-o", fmt.Sprintf("ControlPersist=%s", s.ControlPersist))
}
}
// addConnectionOptions adds connection reliability options to the SSH command
func addConnectionOptions(parts *[]string, s domain.Server) {
if s.ServerAliveInterval != "" {
*parts = append(*parts, "-o", fmt.Sprintf("ServerAliveInterval=%s", s.ServerAliveInterval))
}
if s.ServerAliveCountMax != "" {
*parts = append(*parts, "-o", fmt.Sprintf("ServerAliveCountMax=%s", s.ServerAliveCountMax))
}
if s.Compression == sshYes {
*parts = append(*parts, "-C")
}
if s.TCPKeepAlive != "" {
*parts = append(*parts, "-o", fmt.Sprintf("TCPKeepAlive=%s", s.TCPKeepAlive))
}
if s.BatchMode == sshYes {
*parts = append(*parts, "-o", "BatchMode=yes")
}
}
// addCommandExecutionOptions adds command execution options to the SSH command
func addCommandExecutionOptions(parts *[]string, s domain.Server) {
if s.LocalCommand != "" {
*parts = append(*parts, "-o", fmt.Sprintf("LocalCommand=%s", quoteIfNeeded(s.LocalCommand)))
}
if s.PermitLocalCommand != "" {
*parts = append(*parts, "-o", fmt.Sprintf("PermitLocalCommand=%s", s.PermitLocalCommand))
}
if s.EscapeChar != "" {
*parts = append(*parts, "-e", s.EscapeChar)
}
}
// addEnvironmentOptions adds environment variable options to the SSH command
func addEnvironmentOptions(parts *[]string, s domain.Server) {
for _, env := range s.SendEnv {
*parts = append(*parts, "-o", fmt.Sprintf("SendEnv=%s", env))
}
for _, env := range s.SetEnv {
*parts = append(*parts, "-o", fmt.Sprintf("SetEnv=%s", quoteIfNeeded(env)))
}
}
// addSecurityOptions adds security-related options to the SSH command
func addSecurityOptions(parts *[]string, s domain.Server) {
if s.StrictHostKeyChecking != "" {
*parts = append(*parts, "-o", fmt.Sprintf("StrictHostKeyChecking=%s", s.StrictHostKeyChecking))
}
if s.CheckHostIP != "" {
*parts = append(*parts, "-o", fmt.Sprintf("CheckHostIP=%s", s.CheckHostIP))
}
if s.FingerprintHash != "" {
*parts = append(*parts, "-o", fmt.Sprintf("FingerprintHash=%s", s.FingerprintHash))
}
if s.UserKnownHostsFile != "" {
*parts = append(*parts, "-o", fmt.Sprintf("UserKnownHostsFile=%s", quoteIfNeeded(s.UserKnownHostsFile)))
}
if s.HostKeyAlgorithms != "" {
*parts = append(*parts, "-o", fmt.Sprintf("HostKeyAlgorithms=%s", s.HostKeyAlgorithms))
}
if s.MACs != "" {
*parts = append(*parts, "-m", s.MACs)
}
if s.Ciphers != "" {
*parts = append(*parts, "-c", s.Ciphers)
}
if s.KexAlgorithms != "" {
*parts = append(*parts, "-o", fmt.Sprintf("KexAlgorithms=%s", s.KexAlgorithms))
}
if s.VerifyHostKeyDNS != "" {
*parts = append(*parts, "-o", fmt.Sprintf("VerifyHostKeyDNS=%s", s.VerifyHostKeyDNS))
}
if s.UpdateHostKeys != "" {
*parts = append(*parts, "-o", fmt.Sprintf("UpdateHostKeys=%s", s.UpdateHostKeys))
}
if s.HashKnownHosts != "" {
*parts = append(*parts, "-o", fmt.Sprintf("HashKnownHosts=%s", s.HashKnownHosts))
}
if s.VisualHostKey != "" {
*parts = append(*parts, "-o", fmt.Sprintf("VisualHostKey=%s", s.VisualHostKey))
}
}
// addTTYAndLoggingOptions adds TTY and logging options to the SSH command
func addTTYAndLoggingOptions(parts *[]string, s domain.Server) {
// RequestTTY option
if s.RequestTTY != "" {
switch s.RequestTTY {
case sshYes:
*parts = append(*parts, "-t")
case sshNo:
*parts = append(*parts, "-T")
case sshForce:
*parts = append(*parts, "-tt")
case sshAuto:
// auto is the default behavior, no flag needed
default:
// For any other value, pass it as-is via -o
*parts = append(*parts, "-o", fmt.Sprintf("RequestTTY=%s", s.RequestTTY))
}
}
// LogLevel option
if s.LogLevel != "" {
switch strings.ToLower(s.LogLevel) {
case "quiet":
*parts = append(*parts, "-q")
case "verbose":
*parts = append(*parts, "-v")
case "debug", "debug1":
*parts = append(*parts, "-v")
case "debug2":
*parts = append(*parts, "-vv")
case "debug3":
*parts = append(*parts, "-vvv")
}
}
// SessionType option (OpenSSH 8.7+)
// "none" is equivalent to -N flag
if s.SessionType != "" {
switch s.SessionType {
case sessionTypeNone:
// Use -N flag for better compatibility
*parts = append(*parts, "-N")
case sessionTypeSubsystem:
// Use -s flag for subsystem
*parts = append(*parts, "-s")
default:
// For other values, use -o SessionType=
*parts = append(*parts, "-o", fmt.Sprintf("SessionType=%s", s.SessionType))
}
}
}
// quoteIfNeeded returns the value quoted if it contains spaces.
func quoteIfNeeded(val string) string {
if strings.ContainsAny(val, " \t") {
@@ -137,3 +466,169 @@ func quoteIfNeeded(val string) string {
}
return val
}
// GetAvailableSSHKeys returns a list of available SSH private key files in the user's .ssh directory.
// It safely handles file permission issues and only returns readable key files.
func GetAvailableSSHKeys() []string {
homeDir, err := os.UserHomeDir()
if err != nil {
return []string{}
}
sshDir := filepath.Join(homeDir, ".ssh")
keys := []string{}
// Common SSH key filenames to look for
commonKeyFiles := []string{
"id_ed25519",
"id_rsa",
"id_ecdsa",
"id_dsa",
"id_ed25519_sk",
"id_ecdsa_sk",
}
// First, add common key files if they exist and are readable
for _, keyName := range commonKeyFiles {
keyPath := filepath.Join(sshDir, keyName)
if info, err := os.Stat(keyPath); err == nil && !info.IsDir() {
// Check if file is readable
// #nosec G304 - keyPath is constructed from known safe values
if file, err := os.Open(keyPath); err == nil {
_ = file.Close()
// Use tilde notation for user-friendly display
keys = append(keys, "~/.ssh/"+keyName)
}
}
}
// Additionally, scan for any other files that look like private keys
// (files without .pub extension and with appropriate permissions)
entries, err := os.ReadDir(sshDir)
if err != nil {
return keys // Return what we have so far
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Skip public keys, known_hosts, config, and authorized_keys
if strings.HasSuffix(name, ".pub") ||
name == "known_hosts" ||
name == "config" ||
name == "authorized_keys" ||
strings.HasPrefix(name, ".") {
continue
}
// Skip if already added as common key
isCommon := false
for _, commonKey := range commonKeyFiles {
if name == commonKey {
isCommon = true
break
}
}
if isCommon {
continue
}
// Check if file is readable and looks like a private key
keyPath := filepath.Join(sshDir, name)
// #nosec G304 - keyPath is constructed from safe directory enumeration
if file, err := os.Open(keyPath); err == nil {
// Read first few bytes to check if it might be a private key
buffer := make([]byte, 100)
n, readErr := file.Read(buffer)
_ = file.Close()
if readErr == nil && n > 0 {
content := string(buffer[:n])
if strings.Contains(content, "PRIVATE KEY") ||
strings.Contains(content, "SSH2 ENCRYPTED PRIVATE KEY") {
keys = append(keys, "~/.ssh/"+name)
}
}
}
}
return keys
}
// GetAvailableKnownHostsFiles returns a list of available known_hosts files in common locations.
// It safely handles file permission issues and only returns readable files.
func GetAvailableKnownHostsFiles() []string {
homeDir, err := os.UserHomeDir()
if err != nil {
return []string{}
}
files := []string{}
// Common known_hosts file locations to check
commonPaths := []string{
filepath.Join(homeDir, ".ssh", "known_hosts"),
filepath.Join(homeDir, ".ssh", "known_hosts2"),
filepath.Join(homeDir, ".ssh", "known_hosts.old"),
"/etc/ssh/ssh_known_hosts",
"/etc/ssh/ssh_known_hosts2",
}
// Check each common location
for _, path := range commonPaths {
if info, err := os.Stat(path); err == nil && !info.IsDir() {
// Check if file is readable
// #nosec G304 - path is constructed from known safe values
if file, err := os.Open(path); err == nil {
_ = file.Close()
// Convert to user-friendly path with tilde notation
if strings.HasPrefix(path, homeDir) {
relPath := strings.TrimPrefix(path, homeDir)
files = append(files, "~"+relPath)
} else {
files = append(files, path)
}
}
}
}
// Also check for any other files in .ssh directory that might be known_hosts files
sshDir := filepath.Join(homeDir, ".ssh")
entries, err := os.ReadDir(sshDir)
if err == nil {
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
// Look for files that might be known_hosts variants
if strings.Contains(name, "known_hosts") && !strings.HasSuffix(name, ".pub") {
// Skip if already added from common paths
fullPath := filepath.Join(sshDir, name)
tildeNotation := "~/.ssh/" + name
alreadyAdded := false
for _, existing := range files {
if existing == tildeNotation {
alreadyAdded = true
break
}
}
if !alreadyAdded {
// Check if readable
// #nosec G304 - fullPath is constructed from safe directory enumeration
if file, err := os.Open(fullPath); err == nil {
_ = file.Close()
files = append(files, tildeNotation)
}
}
}
}
}
return files
}
+173
View File
@@ -0,0 +1,173 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
import (
"strings"
"testing"
"github.com/Adembc/lazyssh/internal/core/domain"
)
func TestBuildSSHCommand_PortForwarding(t *testing.T) {
tests := []struct {
name string
server domain.Server
expected []string // expected parts in the command
}{
{
name: "local forward",
server: domain.Server{
Alias: "test",
Host: "example.com",
User: "user",
LocalForward: []string{"8080:localhost:80", "3306:db.internal:3306"},
},
expected: []string{"ssh", "-L", "8080:localhost:80", "-L", "3306:db.internal:3306", "user@example.com"},
},
{
name: "remote forward",
server: domain.Server{
Alias: "test",
Host: "example.com",
User: "user",
RemoteForward: []string{"8080:localhost:3000", "*:80:localhost:8080"},
},
expected: []string{"ssh", "-R", "8080:localhost:3000", "-R", "*:80:localhost:8080", "user@example.com"},
},
{
name: "dynamic forward",
server: domain.Server{
Alias: "test",
Host: "example.com",
User: "user",
DynamicForward: []string{"1080", "localhost:1081"},
},
expected: []string{"ssh", "-D", "1080", "-D", "localhost:1081", "user@example.com"},
},
{
name: "all forward types",
server: domain.Server{
Alias: "test",
Host: "example.com",
User: "user",
LocalForward: []string{"8080:localhost:80"},
RemoteForward: []string{"9090:localhost:9090"},
DynamicForward: []string{"1080"},
},
expected: []string{"ssh", "-L", "8080:localhost:80", "-R", "9090:localhost:9090", "-D", "1080", "user@example.com"},
},
{
name: "forward with bind address",
server: domain.Server{
Alias: "test",
Host: "example.com",
User: "user",
LocalForward: []string{"127.0.0.1:8080:localhost:80", "*:3000:localhost:3000"},
},
expected: []string{"ssh", "-L", "127.0.0.1:8080:localhost:80", "-L", "*:3000:localhost:3000", "user@example.com"},
},
{
name: "forward with additional options",
server: domain.Server{
Alias: "test",
Host: "example.com",
User: "user",
LocalForward: []string{"8080:localhost:80"},
ExitOnForwardFailure: "yes",
GatewayPorts: "clientspecified",
},
expected: []string{"ssh", "-L", "8080:localhost:80", "-o", "ExitOnForwardFailure=yes", "-o", "GatewayPorts=clientspecified", "user@example.com"},
},
{
name: "clear all forwardings",
server: domain.Server{
Alias: "test",
Host: "example.com",
User: "user",
LocalForward: []string{"8080:localhost:80"},
ClearAllForwardings: "yes",
},
expected: []string{"ssh", "-L", "8080:localhost:80", "-o", "ClearAllForwardings=yes", "user@example.com"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BuildSSHCommand(tt.server)
// Check that all expected parts are in the result
for _, part := range tt.expected {
if !strings.Contains(result, part) {
t.Errorf("BuildSSHCommand() missing expected part %q in result: %q", part, result)
}
}
// Additional check: ensure the command starts with "ssh"
if !strings.HasPrefix(result, "ssh ") {
t.Errorf("BuildSSHCommand() should start with 'ssh ', got: %q", result)
}
})
}
}
func TestBuildSSHCommand_CompleteCommand(t *testing.T) {
server := domain.Server{
Alias: "myserver",
Host: "example.com",
User: "admin",
Port: 2222,
LocalForward: []string{"8080:localhost:80", "3306:db.internal:3306"},
RemoteForward: []string{"9090:localhost:9090"},
DynamicForward: []string{"1080"},
IdentityFiles: []string{"~/.ssh/id_rsa"},
}
result := BuildSSHCommand(server)
// Check command structure
if !strings.HasPrefix(result, "ssh ") {
t.Errorf("Command should start with 'ssh ', got: %q", result)
}
// Check port
if !strings.Contains(result, "-p 2222") {
t.Errorf("Command should contain port flag '-p 2222', got: %q", result)
}
// Check identity file
if !strings.Contains(result, "-i ~/.ssh/id_rsa") {
t.Errorf("Command should contain identity file flag, got: %q", result)
}
// Check all forwards
expectedForwards := []string{
"-L 8080:localhost:80",
"-L 3306:db.internal:3306",
"-R 9090:localhost:9090",
"-D 1080",
}
for _, forward := range expectedForwards {
if !strings.Contains(result, forward) {
t.Errorf("Command should contain forward %q, got: %q", forward, result)
}
}
// Check user@host
if !strings.Contains(result, "admin@example.com") {
t.Errorf("Command should contain 'admin@example.com', got: %q", result)
}
}
+713
View File
@@ -0,0 +1,713 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
import (
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
)
// fieldValidator contains validation rules for SSH configuration fields
type fieldValidator struct {
Required bool
Pattern *regexp.Regexp
Validate func(string) error
Message string
}
// ValidationState tracks validation errors for each field
type ValidationState struct {
errors map[string]string
mu sync.RWMutex
}
// NewValidationState creates a new validation state
func NewValidationState() *ValidationState {
return &ValidationState{
errors: make(map[string]string),
}
}
// SetError sets or clears an error for a field
func (v *ValidationState) SetError(field, errMsg string) {
v.mu.Lock()
defer v.mu.Unlock()
if errMsg == "" {
delete(v.errors, field)
} else {
v.errors[field] = errMsg
}
}
// GetError gets the error for a specific field
func (v *ValidationState) GetError(field string) string {
v.mu.RLock()
defer v.mu.RUnlock()
return v.errors[field]
}
// HasErrors checks if there are any validation errors
func (v *ValidationState) HasErrors() bool {
v.mu.RLock()
defer v.mu.RUnlock()
return len(v.errors) > 0
}
// GetErrorCount returns the number of validation errors
func (v *ValidationState) GetErrorCount() int {
v.mu.RLock()
defer v.mu.RUnlock()
return len(v.errors)
}
// GetAllErrors returns all validation errors in field order
func (v *ValidationState) GetAllErrors() []string {
v.mu.RLock()
defer v.mu.RUnlock()
// Define field order for consistent error display
fieldOrder := []string{
"Alias", "Host", "Port", "User", "Keys", "Tags",
"ConnectTimeout", "ConnectionAttempts", "ServerAliveInterval", "ServerAliveCountMax",
"IPQoS", "BindAddress", "LocalForward", "RemoteForward", "DynamicForward",
"NumberOfPasswordPrompts", "CanonicalizeMaxDots", "EscapeChar",
}
// Create a set for O(1) lookups
fieldOrderSet := make(map[string]bool, len(fieldOrder))
for _, field := range fieldOrder {
fieldOrderSet[field] = true
}
errors := make([]string, 0, len(v.errors))
// Add errors in defined order
for _, field := range fieldOrder {
if err, exists := v.errors[field]; exists {
errors = append(errors, fmt.Sprintf("%s: %s", field, err))
}
}
// Add any other errors not in the defined order
for field, err := range v.errors {
if !fieldOrderSet[field] {
errors = append(errors, fmt.Sprintf("%s: %s", field, err))
}
}
return errors
}
// Clear removes all validation errors
func (v *ValidationState) Clear() {
v.mu.Lock()
defer v.mu.Unlock()
v.errors = make(map[string]string)
}
// invalidHostChars contains characters that are not allowed in hostnames
const invalidHostChars = "@#$%^&*()=+[]{}|\\;:'\"<>,?/"
// invalidAddressChars contains characters that are not allowed in bind addresses
const invalidAddressChars = "@#$%^&()=+{}|\\;:'\"<>,?/"
// GetFieldValidators returns validation rules for SSH configuration fields
func GetFieldValidators() map[string]fieldValidator {
validators := make(map[string]fieldValidator)
// Basic fields
validators["Alias"] = fieldValidator{
Required: true,
Pattern: regexp.MustCompile(`^[a-zA-Z0-9._-]+$`),
Message: "Alias is required and can only contain letters, numbers, dots, hyphens, and underscores",
}
validators["Host"] = fieldValidator{
Required: true,
Validate: validateHost,
Message: "Host is required and must be a valid hostname or IP address",
}
validators["Port"] = fieldValidator{
Pattern: regexp.MustCompile(`^([1-9]\d{0,4})$`),
Validate: validatePort,
Message: "Port must be between 1 and 65535",
}
validators["User"] = fieldValidator{
Pattern: regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9._-]*$`),
Message: "User must start with a letter and contain only letters, numbers, dots, hyphens, and underscores",
}
validators["Keys"] = fieldValidator{
Validate: validateKeyPaths,
Message: "Key file not found or not accessible",
}
// Connection fields
validators["ConnectTimeout"] = fieldValidator{
Validate: validateConnectTimeout,
Message: "ConnectTimeout must be a positive number or 'none'",
}
validators["ConnectionAttempts"] = fieldValidator{
Pattern: regexp.MustCompile(`^[1-9]\d*$`),
Message: "ConnectionAttempts must be a positive number",
}
validators["ServerAliveInterval"] = fieldValidator{
Pattern: regexp.MustCompile(`^\d+$`),
Validate: validateNonNegativeNumber,
Message: "ServerAliveInterval must be a non-negative number",
}
validators["ServerAliveCountMax"] = fieldValidator{
Pattern: regexp.MustCompile(`^\d+$`),
Validate: validateNonNegativeNumber,
Message: "ServerAliveCountMax must be a non-negative number",
}
validators["IPQoS"] = fieldValidator{
Validate: validateIPQoS,
Message: "IPQoS must be valid QoS values (e.g., 'af21 cs1', 'lowdelay', 'ef')",
}
// Address and forwarding fields
validators["BindAddress"] = fieldValidator{
Validate: validateBindAddress,
Message: "BindAddress must be a valid IP address, hostname, or '*'",
}
validators["LocalForward"] = fieldValidator{
Validate: validatePortForward,
Message: "LocalForward must be in format '[bind_address:]port:host:hostport'",
}
validators["RemoteForward"] = fieldValidator{
Validate: validatePortForward,
Message: "RemoteForward must be in format '[bind_address:]port:host:hostport'",
}
validators["DynamicForward"] = fieldValidator{
Validate: validateDynamicForward,
Message: "DynamicForward must be in format '[bind_address:]port'",
}
// Authentication fields
validators["NumberOfPasswordPrompts"] = fieldValidator{
Pattern: regexp.MustCompile(`^\d+$`),
Validate: validatePasswordPrompts,
Message: "NumberOfPasswordPrompts must be between 0 and 10",
}
// Advanced fields
validators["CanonicalizeMaxDots"] = fieldValidator{
Pattern: regexp.MustCompile(`^\d+$`),
Validate: validateNonNegativeNumber,
Message: "CanonicalizeMaxDots must be a non-negative number",
}
validators["EscapeChar"] = fieldValidator{
Validate: validateEscapeChar,
Message: "EscapeChar must be a single character, 'none', or ^X format (e.g., ^A)",
}
// Security fields
validators["UserKnownHostsFile"] = fieldValidator{
Validate: validateKnownHostsFiles,
Message: "Known hosts file not found or not accessible",
}
return validators
}
// validatePort validates port number
func validatePort(value string) error {
if value == "" {
return nil // Port is optional
}
port, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid port number")
}
if port < 1 || port > 65535 {
return fmt.Errorf("port must be between 1 and 65535")
}
return nil
}
// validateConnectTimeout validates connection timeout
func validateConnectTimeout(value string) error {
if value == "" || value == "none" {
return nil
}
timeout, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid timeout value")
}
if timeout <= 0 {
return fmt.Errorf("timeout must be positive or 'none'")
}
return nil
}
// validateNonNegativeNumber validates that a value is a non-negative number
func validateNonNegativeNumber(value string) error {
if value == "" {
return nil
}
num, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid number")
}
if num < 0 {
return fmt.Errorf("must be non-negative")
}
return nil
}
// validatePasswordPrompts validates NumberOfPasswordPrompts
func validatePasswordPrompts(value string) error {
if value == "" {
return nil
}
num, err := strconv.Atoi(value)
if err != nil {
return fmt.Errorf("invalid number")
}
if num < 0 || num > 10 {
return fmt.Errorf("must be between 0 and 10")
}
return nil
}
// validateEscapeChar validates escape character format
func validateEscapeChar(value string) error {
if value == "" || value == "none" || value == "~" {
return nil
}
// Support ^X format (Ctrl+X)
if len(value) == 2 && value[0] == '^' {
char := value[1]
if (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') {
return nil
}
}
// Single printable character
if len(value) == 1 && value[0] >= 32 && value[0] <= 126 {
return nil
}
return fmt.Errorf("invalid escape character format")
}
// validateIPQoS validates IPQoS values
func validateIPQoS(value string) error {
if value == "" {
return nil
}
validValues := map[string]bool{
"af11": true, "af12": true, "af13": true,
"af21": true, "af22": true, "af23": true,
"af31": true, "af32": true, "af33": true,
"af41": true, "af42": true, "af43": true,
"cs0": true, "cs1": true, "cs2": true, "cs3": true,
"cs4": true, "cs5": true, "cs6": true, "cs7": true,
"ef": true, "le": true,
"lowdelay": true, "throughput": true, "reliability": true, "none": true,
}
// Can be single value or two space-separated values
parts := strings.Fields(value)
if len(parts) > 2 {
return fmt.Errorf("IPQoS accepts at most 2 values")
}
for _, part := range parts {
if !validValues[strings.ToLower(part)] {
return fmt.Errorf("invalid IPQoS value: %s", part)
}
}
return nil
}
// validateFilePath validates a single file path for existence and readability
func validateFilePath(path string) (exists bool, accessible bool, isDir bool) {
// Get home directory for tilde expansion
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = ""
}
// Expand tilde notation
expandedPath := path
if strings.HasPrefix(path, "~/") && homeDir != "" {
expandedPath = filepath.Join(homeDir, path[2:])
} else if strings.HasPrefix(path, "~") && homeDir != "" {
// Handle ~ alone
expandedPath = homeDir
}
// Check if file exists
info, err := os.Stat(expandedPath)
if err != nil {
if os.IsNotExist(err) {
return false, false, false // File doesn't exist
}
// Permission denied or other error
return true, false, false // File exists but not accessible
}
// Check if it's a directory
if info.IsDir() {
return true, true, true
}
// Check if file is readable
// #nosec G304 - expandedPath is validated user input
file, err := os.Open(expandedPath)
if err != nil {
return true, false, false // File exists but not readable
}
_ = file.Close()
return true, true, false // File exists and is readable
}
// buildFileValidationError builds an error message from invalid and inaccessible file paths
func buildFileValidationError(invalidPaths, inaccessiblePaths []string) error {
var errors []string
if len(invalidPaths) > 0 {
errors = append(errors, fmt.Sprintf("file(s) not found: %s", strings.Join(invalidPaths, ", ")))
}
if len(inaccessiblePaths) > 0 {
errors = append(errors, fmt.Sprintf("file(s) not accessible: %s", strings.Join(inaccessiblePaths, ", ")))
}
if len(errors) > 0 {
return fmt.Errorf("%s", strings.Join(errors, "; "))
}
return nil
}
// validateFilePaths validates multiple file paths with a custom separator
func validateFilePaths(files string, separator string) error {
if files == "" {
return nil
}
// Check for invalid characters first, before trimming
if strings.ContainsAny(files, "\n\r\t") {
return fmt.Errorf("file path contains invalid characters")
}
var paths []string
if separator == " " {
// For space separator, use Fields to handle multiple spaces
paths = strings.Fields(files)
} else {
// For other separators like comma
paths = strings.Split(files, separator)
}
var invalidPaths []string
var inaccessiblePaths []string
for _, path := range paths {
path = strings.TrimSpace(path)
if path == "" {
continue
}
exists, accessible, isDir := validateFilePath(path)
switch {
case !exists:
invalidPaths = append(invalidPaths, path)
case isDir:
invalidPaths = append(invalidPaths, fmt.Sprintf("%s (is a directory)", path))
case !accessible:
inaccessiblePaths = append(inaccessiblePaths, path)
}
}
return buildFileValidationError(invalidPaths, inaccessiblePaths)
}
// validateKeyPaths validates SSH key file paths (comma-separated)
func validateKeyPaths(keys string) error {
return validateFilePaths(keys, ",")
}
// validateKnownHostsFiles validates known_hosts file paths (space-separated)
func validateKnownHostsFiles(files string) error {
// Empty is valid - SSH will use default
return validateFilePaths(files, " ")
}
// validateHost validates a hostname or IP address
func validateHost(host string) error {
if host == "" {
return fmt.Errorf("host is required")
}
// Check for spaces
if strings.Contains(host, " ") {
return fmt.Errorf("host cannot contain spaces")
}
// Try to parse as IP address first
if net.ParseIP(host) != nil {
return nil
}
// Validate as hostname
return validateHostname(host)
}
// validateHostname validates a hostname (not IP)
func validateHostname(host string) error {
if len(host) > 253 {
return fmt.Errorf("hostname too long")
}
// Check for invalid characters using a single check
if strings.ContainsAny(host, invalidHostChars) {
return fmt.Errorf("host contains invalid characters")
}
// Check hostname format
if strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") {
return fmt.Errorf("hostname cannot start or end with a dot")
}
if strings.Contains(host, "..") {
return fmt.Errorf("hostname cannot contain consecutive dots")
}
// Validate each label
return validateHostLabels(host)
}
// validateHostLabels validates each label in a hostname
func validateHostLabels(host string) error {
labels := strings.Split(host, ".")
for _, label := range labels {
if err := validateHostLabel(label); err != nil {
return err
}
}
return nil
}
// validateHostLabel validates a single hostname label
func validateHostLabel(label string) error {
if label == "" {
return fmt.Errorf("hostname has empty label")
}
if len(label) > 63 {
return fmt.Errorf("hostname label too long")
}
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return fmt.Errorf("hostname label cannot start or end with hyphen")
}
return nil
}
// validatePortForward validates port forwarding specification
func validatePortForward(forward string) error {
if forward == "" {
return nil // Port forwarding is optional
}
// Support multiple forwards separated by comma
forwards := strings.Split(forward, ",")
for _, fwd := range forwards {
fwd = strings.TrimSpace(fwd)
if fwd == "" {
continue
}
// Format: [bind_address:]port:host:hostport
parts := strings.Split(fwd, ":")
if len(parts) < 3 || len(parts) > 4 {
return fmt.Errorf("invalid format, expected [bind_address:]port:host:hostport")
}
// Validate ports
var portIdx, hostPortIdx int
if len(parts) == 3 {
// port:host:hostport
portIdx = 0
hostPortIdx = 2
} else {
// bind_address:port:host:hostport
portIdx = 1
hostPortIdx = 3
// Validate bind address
if parts[0] != "" && parts[0] != "*" {
if err := validateBindAddress(parts[0]); err != nil {
return fmt.Errorf("invalid bind address: %w", err)
}
}
}
// Validate port numbers
port, err := strconv.Atoi(parts[portIdx])
if err != nil || port < 1 || port > 65535 {
return fmt.Errorf("invalid port number: %s", parts[portIdx])
}
hostPort, err := strconv.Atoi(parts[hostPortIdx])
if err != nil || hostPort < 1 || hostPort > 65535 {
return fmt.Errorf("invalid host port number: %s", parts[hostPortIdx])
}
}
return nil
}
// validateDynamicForward validates dynamic port forwarding specification
func validateDynamicForward(forward string) error {
if forward == "" {
return nil // Dynamic forwarding is optional
}
// Support multiple forwards separated by comma
forwards := strings.Split(forward, ",")
for _, fwd := range forwards {
fwd = strings.TrimSpace(fwd)
if fwd == "" {
continue
}
// Format: [bind_address:]port
parts := strings.Split(fwd, ":")
if len(parts) > 2 {
return fmt.Errorf("invalid format, expected [bind_address:]port")
}
var portStr string
if len(parts) == 1 {
// Just port
portStr = parts[0]
} else {
// bind_address:port
if parts[0] != "" && parts[0] != "*" {
if err := validateBindAddress(parts[0]); err != nil {
return fmt.Errorf("invalid bind address: %w", err)
}
}
portStr = parts[1]
}
// Validate port number
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 || port > 65535 {
return fmt.Errorf("invalid port number: %s", portStr)
}
}
return nil
}
// validateBindAddress validates a bind address (IP, hostname, or *)
func validateBindAddress(address string) error {
if address == "" || address == "*" {
return nil // Empty or wildcard is valid
}
// Check for spaces
if strings.Contains(address, " ") {
return fmt.Errorf("address cannot contain spaces")
}
// Try to parse as IP address first (including IPv6)
if net.ParseIP(address) != nil {
return nil
}
// Validate as hostname with relaxed rules
return validateBindHostname(address)
}
// isNumericDottedFormat checks if the address looks like an IP address (contains only dots and digits)
func isNumericDottedFormat(address string) bool {
for _, ch := range address {
if ch != '.' && (ch < '0' || ch > '9') {
return false
}
}
return strings.Contains(address, ".")
}
// validateBindHostname validates a hostname for bind address (more permissive than regular hostname)
func validateBindHostname(address string) error {
// Check for invalid characters using a single check
if strings.ContainsAny(address, invalidAddressChars) {
return fmt.Errorf("address contains invalid characters")
}
// Check hostname format
if strings.HasPrefix(address, ".") || strings.HasSuffix(address, ".") {
return fmt.Errorf("address cannot start or end with a dot")
}
if strings.HasPrefix(address, "-") || strings.HasSuffix(address, "-") {
return fmt.Errorf("address cannot start or end with hyphen")
}
// Check for consecutive dots
if strings.Contains(address, "..") {
return fmt.Errorf("address cannot contain consecutive dots")
}
// If it looks like an IP address (contains only dots and digits), validate it more strictly
if isNumericDottedFormat(address) {
// Check if all segments are valid numbers
segments := strings.Split(address, ".")
// IPv4 should have exactly 4 segments
if len(segments) == 4 {
for _, seg := range segments {
if seg == "" {
return fmt.Errorf("invalid IP address format")
}
num, err := strconv.Atoi(seg)
if err != nil || num < 0 || num > 255 {
return fmt.Errorf("invalid IP address format")
}
}
return nil // Valid IPv4
}
// If it's not 4 segments but looks numeric, it's invalid
return fmt.Errorf("invalid address format")
}
// Check each label for hyphens at start/end
if strings.Contains(address, ".") {
return validateAddressLabels(address)
}
return nil
}
// validateAddressLabels validates labels in a bind address
func validateAddressLabels(address string) error {
labels := strings.Split(address, ".")
for _, label := range labels {
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
return fmt.Errorf("address label cannot start or end with hyphen")
}
}
return nil
}
// stripColorTags removes tview color tags from a string
func stripColorTags(s string) string {
// Remove all tview color tags like [red], [-], [yellow], etc.
colorTagRegex := regexp.MustCompile(`\[[^\]]*\]`)
return colorTagRegex.ReplaceAllString(s, "")
}
+304
View File
@@ -0,0 +1,304 @@
// Copyright 2025.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package ui
import (
"testing"
)
func TestValidateHost(t *testing.T) {
tests := []struct {
name string
host string
wantErr bool
}{
{"Valid IP", "192.168.1.1", false},
{"Valid hostname", "example.com", false},
{"Valid subdomain", "api.example.com", false},
{"Empty host", "", true},
{"Host with spaces", "example .com", true},
{"Host with invalid chars", "example@com", true},
{"Host starting with dot", ".example.com", true},
{"Host ending with dot", "example.com.", true},
{"Host with empty label", "example..com", true},
{"Label starting with hyphen", "-example.com", true},
{"Label ending with hyphen", "example-.com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateHost(tt.host)
if (err != nil) != tt.wantErr {
t.Errorf("validateHost(%s) error = %v, wantErr %v", tt.host, err, tt.wantErr)
}
})
}
}
func TestValidatePortForward(t *testing.T) {
tests := []struct {
name string
forward string
wantErr bool
}{
{"Valid simple forward", "8080:localhost:80", false},
{"Valid with bind address", "127.0.0.1:8080:localhost:80", false},
{"Multiple forwards", "8080:localhost:80, 3000:localhost:3000", false},
{"Empty forward", "", false},
{"Invalid format - too few parts", "8080:localhost", true},
{"Invalid format - too many parts", "127.0.0.1:8080:localhost:80:extra", true},
{"Invalid port number", "abc:localhost:80", true},
{"Port out of range", "70000:localhost:80", true},
{"Invalid bind address - malformed IP", "127.0.0.0.0.0.1:8080:localhost:80", true},
{"Invalid bind address - IP out of range", "192.168.1.256:8080:localhost:80", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validatePortForward(tt.forward)
if (err != nil) != tt.wantErr {
t.Errorf("validatePortForward(%s) error = %v, wantErr %v", tt.forward, err, tt.wantErr)
}
})
}
}
func TestValidateDynamicForward(t *testing.T) {
tests := []struct {
name string
forward string
wantErr bool
}{
{"Valid port only", "1080", false},
{"Valid with bind address", "127.0.0.1:1080", false},
{"Multiple forwards", "1080, 1081", false},
{"Empty forward", "", false},
{"Invalid format - too many parts", "127.0.0.1:1080:extra", true},
{"Invalid port number", "abc", true},
{"Port out of range", "70000", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateDynamicForward(tt.forward)
if (err != nil) != tt.wantErr {
t.Errorf("validateDynamicForward(%s) error = %v, wantErr %v", tt.forward, err, tt.wantErr)
}
})
}
}
func TestValidateBindAddress(t *testing.T) {
tests := []struct {
name string
address string
wantErr bool
}{
{"Valid IP", "192.168.1.1", false},
{"Valid IPv6", "::1", false},
{"Valid hostname", "example.com", false},
{"Wildcard", "*", false},
{"Localhost", "localhost", false},
{"Empty address", "", false},
{"Address with spaces", "example .com", true},
{"Address with invalid chars", "example@com", true},
{"Address starting with dot", ".example.com", true},
{"Address ending with dot", "example.com.", true},
{"Address starting with hyphen", "-example.com", true},
{"Address ending with hyphen", "example-.com", true},
{"Invalid IP-like address", "127.0.0.0.0.0.1", true},
{"Invalid numeric hostname", "192.168.1.256", true},
{"Multiple dots", "example..com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateBindAddress(tt.address)
if (err != nil) != tt.wantErr {
t.Errorf("validateBindAddress(%s) error = %v, wantErr %v", tt.address, err, tt.wantErr)
}
})
}
}
func TestValidateKeyPaths(t *testing.T) {
tests := []struct {
name string
keys string
wantErr bool
}{
{"Valid single path", "~/.ssh/id_rsa", false},
{"Valid multiple paths", "~/.ssh/id_rsa, ~/.ssh/id_ed25519", false},
{"Empty keys", "", false},
{"Path with newline", "~/.ssh/id_rsa\n", true},
{"Path with tab", "~/.ssh/id_rsa\t", true},
{"Path with carriage return", "~/.ssh/id_rsa\r", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateKeyPaths(tt.keys)
if (err != nil) != tt.wantErr {
t.Errorf("validateKeyPaths(%s) error = %v, wantErr %v", tt.keys, err, tt.wantErr)
}
})
}
}
func TestFieldValidatorPatterns(t *testing.T) {
fieldValidators := GetFieldValidators()
tests := []struct {
field string
value string
wantErr bool
}{
// Alias field
{"Alias", "server-01", false},
{"Alias", "server_01", false},
{"Alias", "server.01", false},
{"Alias", "server@01", true},
{"Alias", "", true}, // Required field
// Port field
{"Port", "22", false},
{"Port", "65535", false},
{"Port", "0", true},
{"Port", "65536", true},
{"Port", "abc", true},
// User field
{"User", "root", false},
{"User", "user_name", false},
{"User", "user-name", false},
{"User", "1user", true}, // Can't start with number
// ConnectTimeout field
{"ConnectTimeout", "none", false},
{"ConnectTimeout", "30", false},
{"ConnectTimeout", "0", true},
{"ConnectTimeout", "-10", true},
// IPQoS field
{"IPQoS", "af21 cs1", false},
{"IPQoS", "ef", false},
{"IPQoS", "lowdelay", false},
{"IPQoS", "invalid", true},
// EscapeChar field
{"EscapeChar", "~", false},
{"EscapeChar", "none", false},
{"EscapeChar", "^A", false},
{"EscapeChar", "^z", false},
{"EscapeChar", "invalid", true},
}
for _, tt := range tests {
t.Run(tt.field+"_"+tt.value, func(t *testing.T) {
validator, exists := fieldValidators[tt.field]
if !exists {
if tt.wantErr {
t.Errorf("Expected validator for field %s but none found", tt.field)
}
return
}
var err error
// Check required fields
switch {
case validator.Required && tt.value == "":
err = &testError{msg: "required field is empty"}
case validator.Pattern != nil && !validator.Pattern.MatchString(tt.value):
err = &testError{msg: "pattern mismatch"}
case validator.Validate != nil:
err = validator.Validate(tt.value)
}
if (err != nil) != tt.wantErr {
t.Errorf("validateField(%s, %s) error = %v, wantErr %v", tt.field, tt.value, err, tt.wantErr)
}
})
}
}
// testError is a helper type for testing
type testError struct {
msg string
}
func (e *testError) Error() string {
return e.msg
}
func TestValidationState_MultipleErrors(t *testing.T) {
state := NewValidationState()
// Set multiple errors
state.SetError("Alias", "Alias is required")
state.SetError("Host", "Host is required")
state.SetError("Port", "Port must be between 1 and 65535")
state.SetError("User", "Invalid username")
// Check that we have errors
if !state.HasErrors() {
t.Error("Expected HasErrors to return true")
}
// Get all errors
errors := state.GetAllErrors()
// Should have 4 errors
if len(errors) != 4 {
t.Errorf("Expected 4 errors, got %d", len(errors))
}
// Print errors for debugging
t.Logf("Found %d errors:", len(errors))
for i, err := range errors {
t.Logf(" %d. %s", i+1, err)
}
// Check that errors are in the expected order
expectedOrder := []string{"Alias", "Host", "Port", "User"}
for i, expectedField := range expectedOrder {
if i >= len(errors) {
break
}
// Check if the error message starts with the expected field name
if len(errors[i]) < len(expectedField) || errors[i][:len(expectedField)] != expectedField {
t.Errorf("Expected error %d to be for field %s, but got: %s", i, expectedField, errors[i])
}
}
}
func TestValidationState_Clear(t *testing.T) {
state := NewValidationState()
// Add some errors
state.SetError("Alias", "Error 1")
state.SetError("Host", "Error 2")
// Clear all errors
state.Clear()
// Should have no errors
if state.HasErrors() {
t.Error("Expected no errors after Clear()")
}
if state.GetErrorCount() != 0 {
t.Errorf("Expected error count to be 0, got %d", state.GetErrorCount())
}
}
+87
View File
@@ -27,4 +27,91 @@ type Server struct {
LastSeen time.Time
PinnedAt time.Time
SSHCount int
// Additional SSH config fields
// Connection and proxy settings
ProxyJump string
ProxyCommand string
RemoteCommand string
RequestTTY string
SessionType string // none, subsystem, default (OpenSSH 8.7+)
ConnectTimeout string
ConnectionAttempts string
BindAddress string
BindInterface string
AddressFamily string // any, inet, inet6
ExitOnForwardFailure string // yes, no
IPQoS string // af11, af12, af13, af21, af22, af23, af31, af32, af33, af41, af42, af43, cs0-cs7, ef, lowdelay, throughput, reliability, or numeric value
// Hostname canonicalization
CanonicalizeHostname string // yes, no, always
CanonicalDomains string
CanonicalizeFallbackLocal string // yes, no
CanonicalizeMaxDots string
CanonicalizePermittedCNAMEs string
// Port forwarding settings
LocalForward []string
RemoteForward []string
DynamicForward []string
ClearAllForwardings string // yes, no
GatewayPorts string // yes, no, clientspecified
// Authentication and key management
// Public key
PubkeyAuthentication string
PubkeyAcceptedAlgorithms string
HostbasedAcceptedAlgorithms string
IdentitiesOnly string
// SSH Agent
AddKeysToAgent string
IdentityAgent string
// Password & Interactive
PasswordAuthentication string
KbdInteractiveAuthentication string // yes, no
NumberOfPasswordPrompts string
// Advanced
PreferredAuthentications string
// Agent and X11 forwarding
ForwardAgent string
ForwardX11 string
ForwardX11Trusted string
// Connection multiplexing
ControlMaster string
ControlPath string
ControlPersist string
// Connection reliability settings
ServerAliveInterval string
ServerAliveCountMax string
Compression string
TCPKeepAlive string
BatchMode string // yes, no - disable all interactive prompts
// Security and cryptography settings
StrictHostKeyChecking string
CheckHostIP string // yes, no
FingerprintHash string // md5, sha256
UserKnownHostsFile string
HostKeyAlgorithms string
MACs string
Ciphers string
KexAlgorithms string
VerifyHostKeyDNS string // yes, no, ask
UpdateHostKeys string // yes, no, ask
HashKnownHosts string // yes, no
VisualHostKey string // yes, no
// Command execution
LocalCommand string
PermitLocalCommand string
EscapeChar string // single character or "none"
// Environment settings
SendEnv []string
SetEnv []string
// Debugging settings
LogLevel string
}