mirror of
https://github.com/Adembc/lazyssh.git
synced 2026-07-14 12:13:34 +02:00
feat: Add 60+ SSH config field support with enhanced UI (#50)
This commit is contained in:
@@ -36,3 +36,6 @@ bin
|
||||
.DS_Store
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
|
||||
# Binary output
|
||||
lazyssh
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+2153
-104
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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() + " ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user