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
|
.DS_Store
|
||||||
# Added by goreleaser init:
|
# Added by goreleaser init:
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Binary output
|
||||||
|
lazyssh
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw
|
|||||||
|
|
||||||
### Server Management
|
### Server Management
|
||||||
- 📜 Read & display servers from your `~/.ssh/config` in a scrollable list.
|
- 📜 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.
|
- ➕ Add a new server from the UI with comprehensive SSH configuration options.
|
||||||
- ✏ Edit existing server entries directly from the UI.
|
- ✏ Edit existing server entries directly from the UI with a tabbed interface.
|
||||||
- 🗑 Delete server entries safely.
|
- 🗑 Delete server entries safely.
|
||||||
- 📌 Pin / unpin servers to keep favorites at the top.
|
- 📌 Pin / unpin servers to keep favorites at the top.
|
||||||
- 🏓 Ping server to check status.
|
- 🏓 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.
|
- 🏷 Tag servers (e.g., prod, dev, test) for quick filtering.
|
||||||
- ↕️ Sort by alias or last SSH (toggle + reverse).
|
- ↕️ 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
|
### Upcoming
|
||||||
- 📁 Copy files between local and servers with an easy picker UI.
|
- 📁 Copy files between local and servers with an easy picker UI.
|
||||||
- 📡 Port forwarding (local↔remote) from the UI.
|
- 🔑 SSH Key Deployment Features:
|
||||||
- 🔑 Enhanced Key Management:
|
|
||||||
- Use default local public key (`~/.ssh/id_ed25519.pub` or `~/.ssh/id_rsa.pub`)
|
- Use default local public key (`~/.ssh/id_ed25519.pub` or `~/.ssh/id_rsa.pub`)
|
||||||
- Paste custom public keys manually
|
- Paste custom public keys manually
|
||||||
- Generate new keypairs and deploy them
|
- 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" />
|
<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 |
|
| S | Reverse sort order |
|
||||||
| q | Quit |
|
| 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.
|
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),
|
SpaceBeforeComment: strings.Repeat(" ", 4),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Basic config - always present
|
||||||
r.addKVNodeIfNotEmpty(host, "HostName", server.Host)
|
r.addKVNodeIfNotEmpty(host, "HostName", server.Host)
|
||||||
r.addKVNodeIfNotEmpty(host, "User", server.User)
|
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 {
|
for _, identityFile := range server.IdentityFiles {
|
||||||
r.addKVNodeIfNotEmpty(host, "IdentityFile", identityFile)
|
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
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,38 +203,136 @@ func (r *Repository) addKVNodeIfNotEmpty(host *ssh_config.Host, key, value strin
|
|||||||
host.Nodes = append(host.Nodes, kvNode)
|
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.
|
// updateHostNodes updates the nodes of an existing host with new server details.
|
||||||
func (r *Repository) updateHostNodes(host *ssh_config.Host, newServer domain.Server) {
|
func (r *Repository) updateHostNodes(host *ssh_config.Host, newServer domain.Server) {
|
||||||
updates := map[string]string{
|
// Handle Port - include if explicitly set (even if it's 22)
|
||||||
"hostname": newServer.Host,
|
portValue := ""
|
||||||
"user": newServer.User,
|
if newServer.Port != 0 {
|
||||||
"port": fmt.Sprintf("%d", newServer.Port),
|
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 {
|
for key, value := range updates {
|
||||||
if value != "" {
|
if value != "" {
|
||||||
r.updateOrAddKVNode(host, key, 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 {
|
for _, identityFile := range newServer.IdentityFiles {
|
||||||
r.addKVNodeIfNotEmpty(host, "IdentityFile", identityFile)
|
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.
|
// 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)
|
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.
|
// getProperKeyCase returns the proper case for known SSH config keys.
|
||||||
// Reference: https://www.ssh.com/academy/ssh/config
|
// Reference: https://www.ssh.com/academy/ssh/config
|
||||||
func (r *Repository) getProperKeyCase(key string) string {
|
func (r *Repository) getProperKeyCase(key string) string {
|
||||||
keyMap := map[string]string{
|
keyMap := map[string]string{
|
||||||
"hostname": "HostName",
|
"hostname": "HostName",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"identityfile": "IdentityFile",
|
"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 {
|
if properCase, exists := keyMap[strings.ToLower(key)]; exists {
|
||||||
@@ -199,6 +452,79 @@ func (r *Repository) getProperKeyCase(key string) string {
|
|||||||
return key
|
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.
|
// 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 {
|
func (r *Repository) removeHostByAlias(hosts []*ssh_config.Host, alias string) []*ssh_config.Host {
|
||||||
for i, host := range hosts {
|
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.
|
// 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) {
|
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":
|
case "hostname":
|
||||||
server.Host = kvNode.Value
|
server.Host = value
|
||||||
case "user":
|
case "user":
|
||||||
server.User = kvNode.Value
|
server.User = value
|
||||||
case "port":
|
case "port":
|
||||||
port, err := strconv.Atoi(kvNode.Value)
|
port, err := strconv.Atoi(value)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
server.Port = port
|
server.Port = port
|
||||||
}
|
}
|
||||||
case "identityfile":
|
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.
|
// mergeMetadata merges additional metadata into the servers.
|
||||||
@@ -104,3 +315,19 @@ func (r *Repository) mergeMetadata(servers []domain.Server, metadata map[string]
|
|||||||
}
|
}
|
||||||
return servers
|
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() {
|
func (t *tui) handleServerAdd() {
|
||||||
form := NewServerForm(ServerFormAdd, nil).
|
form := NewServerForm(ServerFormAdd, nil).
|
||||||
|
SetApp(t.app).
|
||||||
|
SetVersionInfo(t.version, t.commit).
|
||||||
OnSave(t.handleServerSave).
|
OnSave(t.handleServerSave).
|
||||||
OnCancel(t.handleFormCancel)
|
OnCancel(t.handleFormCancel)
|
||||||
t.app.SetRoot(form, true)
|
t.app.SetRoot(form, true)
|
||||||
@@ -191,6 +193,8 @@ func (t *tui) handleServerAdd() {
|
|||||||
func (t *tui) handleServerEdit() {
|
func (t *tui) handleServerEdit() {
|
||||||
if server, ok := t.serverList.GetSelectedServer(); ok {
|
if server, ok := t.serverList.GetSelectedServer(); ok {
|
||||||
form := NewServerForm(ServerFormEdit, &server).
|
form := NewServerForm(ServerFormEdit, &server).
|
||||||
|
SetApp(t.app).
|
||||||
|
SetVersionInfo(t.version, t.commit).
|
||||||
OnSave(t.handleServerSave).
|
OnSave(t.handleServerSave).
|
||||||
OnCancel(t.handleFormCancel)
|
OnCancel(t.handleFormCancel)
|
||||||
t.app.SetRoot(form, true)
|
t.app.SetRoot(form, true)
|
||||||
@@ -308,7 +312,7 @@ func (t *tui) showDeleteConfirmModal(server domain.Server) {
|
|||||||
|
|
||||||
modal := tview.NewModal().
|
modal := tview.NewModal().
|
||||||
SetText(msg).
|
SetText(msg).
|
||||||
AddButtons([]string{"Cancel", "Confirm"}).
|
AddButtons([]string{"[yellow]C[-]ancel", "[yellow]D[-]elete"}).
|
||||||
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
if buttonIndex == 1 {
|
if buttonIndex == 1 {
|
||||||
_ = t.serverService.DeleteServer(server)
|
_ = t.serverService.DeleteServer(server)
|
||||||
@@ -317,14 +321,32 @@ func (t *tui) showDeleteConfirmModal(server domain.Server) {
|
|||||||
t.handleModalClose()
|
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)
|
t.app.SetRoot(modal, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *tui) showEditTagsForm(server domain.Server) {
|
func (t *tui) showEditTagsForm(server domain.Server) {
|
||||||
form := tview.NewForm()
|
form := tview.NewForm()
|
||||||
form.SetBorder(true).
|
form.SetBorder(true).
|
||||||
SetTitle(fmt.Sprintf("Edit Tags: %s", server.Alias)).
|
SetTitle(fmt.Sprintf(" Edit Tags: %s ", server.Alias)).
|
||||||
SetTitleAlign(tview.AlignLeft)
|
SetTitleAlign(tview.AlignCenter)
|
||||||
|
|
||||||
defaultTags := strings.Join(server.Tags, ", ")
|
defaultTags := strings.Join(server.Tags, ", ")
|
||||||
form.AddInputField("Tags (comma):", defaultTags, 40, nil, nil)
|
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).
|
SetFieldTextColor(tcell.Color252).
|
||||||
SetFieldWidth(30).
|
SetFieldWidth(30).
|
||||||
SetBorder(true).
|
SetBorder(true).
|
||||||
SetTitle("Search").
|
SetTitle(" Search ").
|
||||||
|
SetTitleAlign(tview.AlignCenter).
|
||||||
SetBorderColor(tcell.Color238).
|
SetBorderColor(tcell.Color238).
|
||||||
SetTitleColor(tcell.Color250)
|
SetTitleColor(tcell.Color250)
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ func (sd *ServerDetails) build() {
|
|||||||
sd.TextView.SetDynamicColors(true).
|
sd.TextView.SetDynamicColors(true).
|
||||||
SetWrap(true).
|
SetWrap(true).
|
||||||
SetBorder(true).
|
SetBorder(true).
|
||||||
SetTitle("Details").
|
SetTitle(" Details ").
|
||||||
|
SetTitleAlign(tview.AlignCenter).
|
||||||
SetBorderColor(tcell.Color238).
|
SetBorderColor(tcell.Color238).
|
||||||
SetTitleColor(tcell.Color250)
|
SetTitleColor(tcell.Color250)
|
||||||
}
|
}
|
||||||
@@ -47,7 +48,7 @@ func (sd *ServerDetails) build() {
|
|||||||
// renderTagChips builds colored tag chips for details view.
|
// renderTagChips builds colored tag chips for details view.
|
||||||
func renderTagChips(tags []string) string {
|
func renderTagChips(tags []string) string {
|
||||||
if len(tags) == 0 {
|
if len(tags) == 0 {
|
||||||
return "-"
|
return ""
|
||||||
}
|
}
|
||||||
chips := make([]string, 0, len(tags))
|
chips := make([]string, 0, len(tags))
|
||||||
for _, t := range tags {
|
for _, t := range tags {
|
||||||
@@ -68,11 +69,152 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) {
|
|||||||
pinnedStr = "false"
|
pinnedStr = "false"
|
||||||
}
|
}
|
||||||
tagsText := renderTagChips(server.Tags)
|
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(
|
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",
|
"[::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",
|
||||||
strings.Join(server.Aliases, ", "), server.Host, server.User, server.Port,
|
aliasText, hostText, userText, portText,
|
||||||
serverKey, tagsText, pinnedStr,
|
serverKey, tagsText, pinnedStr,
|
||||||
lastSeen, server.SSHCount)
|
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)
|
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() {
|
func (sl *ServerList) build() {
|
||||||
sl.List.ShowSecondaryText(false)
|
sl.List.ShowSecondaryText(false)
|
||||||
sl.List.SetBorder(true).
|
sl.List.SetBorder(true).
|
||||||
SetTitle("Servers").
|
SetTitle(" Servers ").
|
||||||
|
SetTitleAlign(tview.AlignCenter).
|
||||||
SetBorderColor(tcell.Color238).
|
SetBorderColor(tcell.Color238).
|
||||||
SetTitleColor(tcell.Color250)
|
SetTitleColor(tcell.Color250)
|
||||||
sl.List.
|
sl.List.
|
||||||
|
|||||||
@@ -141,6 +141,6 @@ func (t *tui) loadInitialData() *tui {
|
|||||||
|
|
||||||
func (t *tui) updateListTitle() {
|
func (t *tui) updateListTitle() {
|
||||||
if t.serverList != nil {
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -23,6 +25,18 @@ import (
|
|||||||
"github.com/mattn/go-runewidth"
|
"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.
|
// 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
|
// 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.
|
// 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.
|
// 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 {
|
func BuildSSHCommand(s domain.Server) string {
|
||||||
parts := []string{"ssh"}
|
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 := ""
|
userHost := ""
|
||||||
switch {
|
switch {
|
||||||
case s.User != "" && s.Host != "":
|
case s.User != "" && s.Host != "":
|
||||||
@@ -121,15 +180,285 @@ func BuildSSHCommand(s domain.Server) string {
|
|||||||
}
|
}
|
||||||
parts = append(parts, userHost)
|
parts = append(parts, userHost)
|
||||||
|
|
||||||
if s.Port != 0 && s.Port != 22 {
|
// RemoteCommand (must come after the host)
|
||||||
parts = append(parts, "-p", fmt.Sprintf("%d", s.Port))
|
if s.RemoteCommand != "" {
|
||||||
}
|
// Handle special case: RemoteCommand=none clears the command (OpenSSH 7.6+)
|
||||||
if len(s.IdentityFiles) > 0 {
|
if s.RemoteCommand == sessionTypeNone {
|
||||||
parts = append(parts, "-i", quoteIfNeeded(s.IdentityFiles[0]))
|
parts = append(parts, "-o", "RemoteCommand=none")
|
||||||
|
} else {
|
||||||
|
parts = append(parts, quoteIfNeeded(s.RemoteCommand))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(parts, " ")
|
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.
|
// quoteIfNeeded returns the value quoted if it contains spaces.
|
||||||
func quoteIfNeeded(val string) string {
|
func quoteIfNeeded(val string) string {
|
||||||
if strings.ContainsAny(val, " \t") {
|
if strings.ContainsAny(val, " \t") {
|
||||||
@@ -137,3 +466,169 @@ func quoteIfNeeded(val string) string {
|
|||||||
}
|
}
|
||||||
return val
|
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
|
LastSeen time.Time
|
||||||
PinnedAt time.Time
|
PinnedAt time.Time
|
||||||
SSHCount int
|
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