diff --git a/.gitignore b/.gitignore index 70f9531..1403ef5 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ bin .DS_Store # Added by goreleaser init: dist/ + +# Binary output +lazyssh diff --git a/README.md b/README.md index 3394aab..8b05377 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw ### Server Management - πŸ“œ Read & display servers from your `~/.ssh/config` in a scrollable list. -- βž• Add a new server from the UI by specifying alias, host/IP, username, port, identity file. -- ✏ Edit existing server entries directly from the UI. +- βž• Add a new server from the UI with comprehensive SSH configuration options. +- ✏ Edit existing server entries directly from the UI with a tabbed interface. - πŸ—‘ Delete server entries safely. - πŸ“Œ Pin / unpin servers to keep favorites at the top. - πŸ“ Ping server to check status. @@ -26,11 +26,22 @@ With lazyssh, you can quickly navigate, connect, manage, and transfer files betw - 🏷 Tag servers (e.g., prod, dev, test) for quick filtering. - ↕️ Sort by alias or last SSH (toggle + reverse). +### Advanced SSH Configuration +- πŸ”— Port forwarding (LocalForward, RemoteForward, DynamicForward). +- πŸš€ Connection multiplexing for faster subsequent connections. +- πŸ” Advanced authentication options (public key, password, agent forwarding). +- πŸ”’ Security settings (ciphers, MACs, key exchange algorithms). +- 🌐 Proxy settings (ProxyJump, ProxyCommand). +- βš™οΈ Extensive SSH config options organized in tabbed interface. + +### Key Management +- πŸ”‘ SSH key autocomplete with automatic detection of available keys. +- πŸ“ Smart key selection with support for multiple keys. + ### Upcoming - πŸ“ Copy files between local and servers with an easy picker UI. -- πŸ“‘ Port forwarding (local↔remote) from the UI. -- πŸ”‘ Enhanced Key Management: +- πŸ”‘ SSH Key Deployment Features: - Use default local public key (`~/.ssh/id_ed25519.pub` or `~/.ssh/id_rsa.pub`) - Paste custom public keys manually - Generate new keypairs and deploy them @@ -86,10 +97,15 @@ Fuzzy search functionality to quickly find servers by name, IP address, or tags --- -### βž• Add Server +### βž• Add/Edit Server Add a new server -User-friendly form interface for adding new SSH connections. +Tabbed interface for managing SSH connections with extensive configuration options organized into: +- **Basic** - Host, user, port, keys, tags +- **Connection** - Proxy, timeouts, multiplexing, canonicalization +- **Forwarding** - Port forwarding, X11, agent +- **Authentication** - Keys, passwords, methods, algorithm settings +- **Advanced** - Security, cryptography, environment, debugging --- @@ -163,6 +179,14 @@ make run | S | Reverse sort order | | q | Quit | +**In Server Form:** +| Key | Action | +| ------ | -------------------- | +| Ctrl+H | Previous tab | +| Ctrl+L | Next tab | +| Ctrl+S | Save | +| Esc | Cancel | + Tip: The hint bar at the top of the list shows the most useful shortcuts. --- diff --git a/internal/adapters/data/ssh_config_file/crud.go b/internal/adapters/data/ssh_config_file/crud.go index f22c560..6c064d4 100644 --- a/internal/adapters/data/ssh_config_file/crud.go +++ b/internal/adapters/data/ssh_config_file/crud.go @@ -103,13 +103,89 @@ func (r *Repository) createHostFromServer(server domain.Server) *ssh_config.Host SpaceBeforeComment: strings.Repeat(" ", 4), } + // Basic config - always present r.addKVNodeIfNotEmpty(host, "HostName", server.Host) r.addKVNodeIfNotEmpty(host, "User", server.User) - r.addKVNodeIfNotEmpty(host, "Port", fmt.Sprintf("%d", server.Port)) + if server.Port != 0 { + r.addKVNodeIfNotEmpty(host, "Port", fmt.Sprintf("%d", server.Port)) + } for _, identityFile := range server.IdentityFiles { r.addKVNodeIfNotEmpty(host, "IdentityFile", identityFile) } + // Connection and proxy settings + r.addKVNodeIfNotEmpty(host, "ProxyJump", server.ProxyJump) + r.addKVNodeIfNotEmpty(host, "ProxyCommand", server.ProxyCommand) + r.addKVNodeIfNotEmpty(host, "RemoteCommand", server.RemoteCommand) + r.addKVNodeIfNotEmpty(host, "RequestTTY", server.RequestTTY) + r.addKVNodeIfNotEmpty(host, "ConnectTimeout", server.ConnectTimeout) + r.addKVNodeIfNotEmpty(host, "ConnectionAttempts", server.ConnectionAttempts) + + // Port forwarding + for _, forward := range server.LocalForward { + configFormat := r.convertCLIForwardToConfigFormat(forward) + r.addKVNodeIfNotEmpty(host, "LocalForward", configFormat) + } + for _, forward := range server.RemoteForward { + configFormat := r.convertCLIForwardToConfigFormat(forward) + r.addKVNodeIfNotEmpty(host, "RemoteForward", configFormat) + } + for _, forward := range server.DynamicForward { + r.addKVNodeIfNotEmpty(host, "DynamicForward", forward) + } + + // Authentication and key management + r.addKVNodeIfNotEmpty(host, "PubkeyAuthentication", server.PubkeyAuthentication) + r.addKVNodeIfNotEmpty(host, "PubkeyAcceptedAlgorithms", server.PubkeyAcceptedAlgorithms) + r.addKVNodeIfNotEmpty(host, "HostbasedAcceptedAlgorithms", server.HostbasedAcceptedAlgorithms) + r.addKVNodeIfNotEmpty(host, "PasswordAuthentication", server.PasswordAuthentication) + r.addKVNodeIfNotEmpty(host, "PreferredAuthentications", server.PreferredAuthentications) + r.addKVNodeIfNotEmpty(host, "IdentitiesOnly", server.IdentitiesOnly) + r.addKVNodeIfNotEmpty(host, "AddKeysToAgent", server.AddKeysToAgent) + r.addKVNodeIfNotEmpty(host, "IdentityAgent", server.IdentityAgent) + + // Agent and X11 forwarding + r.addKVNodeIfNotEmpty(host, "ForwardAgent", server.ForwardAgent) + r.addKVNodeIfNotEmpty(host, "ForwardX11", server.ForwardX11) + r.addKVNodeIfNotEmpty(host, "ForwardX11Trusted", server.ForwardX11Trusted) + + // Connection multiplexing + r.addKVNodeIfNotEmpty(host, "ControlMaster", server.ControlMaster) + r.addKVNodeIfNotEmpty(host, "ControlPath", server.ControlPath) + r.addKVNodeIfNotEmpty(host, "ControlPersist", server.ControlPersist) + + // Connection reliability + r.addKVNodeIfNotEmpty(host, "ServerAliveInterval", server.ServerAliveInterval) + r.addKVNodeIfNotEmpty(host, "ServerAliveCountMax", server.ServerAliveCountMax) + r.addKVNodeIfNotEmpty(host, "Compression", server.Compression) + r.addKVNodeIfNotEmpty(host, "TCPKeepAlive", server.TCPKeepAlive) + r.addKVNodeIfNotEmpty(host, "BatchMode", server.BatchMode) + + // Security + r.addKVNodeIfNotEmpty(host, "StrictHostKeyChecking", server.StrictHostKeyChecking) + r.addKVNodeIfNotEmpty(host, "UserKnownHostsFile", server.UserKnownHostsFile) + r.addKVNodeIfNotEmpty(host, "HostKeyAlgorithms", server.HostKeyAlgorithms) + r.addKVNodeIfNotEmpty(host, "VerifyHostKeyDNS", server.VerifyHostKeyDNS) + r.addKVNodeIfNotEmpty(host, "UpdateHostKeys", server.UpdateHostKeys) + r.addKVNodeIfNotEmpty(host, "HashKnownHosts", server.HashKnownHosts) + r.addKVNodeIfNotEmpty(host, "VisualHostKey", server.VisualHostKey) + + // Command execution + r.addKVNodeIfNotEmpty(host, "LocalCommand", server.LocalCommand) + r.addKVNodeIfNotEmpty(host, "PermitLocalCommand", server.PermitLocalCommand) + r.addKVNodeIfNotEmpty(host, "EscapeChar", server.EscapeChar) + + // Environment settings + for _, env := range server.SendEnv { + r.addKVNodeIfNotEmpty(host, "SendEnv", env) + } + for _, env := range server.SetEnv { + r.addKVNodeIfNotEmpty(host, "SetEnv", env) + } + + // Debugging + r.addKVNodeIfNotEmpty(host, "LogLevel", server.LogLevel) + return host } @@ -127,38 +203,136 @@ func (r *Repository) addKVNodeIfNotEmpty(host *ssh_config.Host, key, value strin host.Nodes = append(host.Nodes, kvNode) } +// removeNodesByKey removes all nodes with the specified key from the nodes slice +func removeNodesByKey(nodes []ssh_config.Node, key string) []ssh_config.Node { + filtered := make([]ssh_config.Node, 0, len(nodes)) + for _, node := range nodes { + if kv, ok := node.(*ssh_config.KV); ok { + if strings.EqualFold(kv.Key, key) { + continue // skip nodes with matching key + } + } + filtered = append(filtered, node) + } + return filtered +} + // updateHostNodes updates the nodes of an existing host with new server details. func (r *Repository) updateHostNodes(host *ssh_config.Host, newServer domain.Server) { - updates := map[string]string{ - "hostname": newServer.Host, - "user": newServer.User, - "port": fmt.Sprintf("%d", newServer.Port), + // Handle Port - include if explicitly set (even if it's 22) + portValue := "" + if newServer.Port != 0 { + portValue = fmt.Sprintf("%d", newServer.Port) } + + updates := map[string]string{ + "hostname": newServer.Host, + "user": newServer.User, + "port": portValue, + "proxycommand": newServer.ProxyCommand, + "proxyjump": newServer.ProxyJump, + "remotecommand": newServer.RemoteCommand, + "requesttty": newServer.RequestTTY, + "sessiontype": newServer.SessionType, + "connecttimeout": newServer.ConnectTimeout, + "connectionattempts": newServer.ConnectionAttempts, + "bindaddress": newServer.BindAddress, + "bindinterface": newServer.BindInterface, + "addressfamily": newServer.AddressFamily, + "exitonforwardfailure": newServer.ExitOnForwardFailure, + "ipqos": newServer.IPQoS, + "canonicalizehostname": newServer.CanonicalizeHostname, + "canonicaldomains": newServer.CanonicalDomains, + "canonicalizefallbacklocal": newServer.CanonicalizeFallbackLocal, + "canonicalizemaxdots": newServer.CanonicalizeMaxDots, + "canonicalizepermittedcnames": newServer.CanonicalizePermittedCNAMEs, + "clearallforwardings": newServer.ClearAllForwardings, + "gatewayports": newServer.GatewayPorts, + "pubkeyauthentication": newServer.PubkeyAuthentication, + "passwordauthentication": newServer.PasswordAuthentication, + "preferredauthentications": newServer.PreferredAuthentications, + "pubkeyacceptedalgorithms": newServer.PubkeyAcceptedAlgorithms, + "pubkeyacceptedkeytypes": newServer.PubkeyAcceptedAlgorithms, // Deprecated alias (since OpenSSH 8.5) + "hostbasedacceptedalgorithms": newServer.HostbasedAcceptedAlgorithms, + "hostbasedkeytypes": newServer.HostbasedAcceptedAlgorithms, // Deprecated alias (since OpenSSH 8.5) + "hostbasedacceptedkeytypes": newServer.HostbasedAcceptedAlgorithms, // Deprecated alias (since OpenSSH 8.5) + "identitiesonly": newServer.IdentitiesOnly, + "addkeystoagent": newServer.AddKeysToAgent, + "identityagent": newServer.IdentityAgent, + "kbdinteractiveauthentication": newServer.KbdInteractiveAuthentication, + "challengeresponseauthentication": newServer.KbdInteractiveAuthentication, // Deprecated alias + "numberofpasswordprompts": newServer.NumberOfPasswordPrompts, + "forwardagent": newServer.ForwardAgent, + "forwardx11": newServer.ForwardX11, + "forwardx11trusted": newServer.ForwardX11Trusted, + "controlmaster": newServer.ControlMaster, + "controlpath": newServer.ControlPath, + "controlpersist": newServer.ControlPersist, + "serveraliveinterval": newServer.ServerAliveInterval, + "serveralivecountmax": newServer.ServerAliveCountMax, + "compression": newServer.Compression, + "tcpkeepalive": newServer.TCPKeepAlive, + "batchmode": newServer.BatchMode, + "stricthostkeychecking": newServer.StrictHostKeyChecking, + "checkhostip": newServer.CheckHostIP, + "fingerprinthash": newServer.FingerprintHash, + "userknownhostsfile": newServer.UserKnownHostsFile, + "hostkeyalgorithms": newServer.HostKeyAlgorithms, + "macs": newServer.MACs, + "ciphers": newServer.Ciphers, + "kexalgorithms": newServer.KexAlgorithms, + "verifyhostkeydns": newServer.VerifyHostKeyDNS, + "updatehostkeys": newServer.UpdateHostKeys, + "hashknownhosts": newServer.HashKnownHosts, + "visualhostkey": newServer.VisualHostKey, + "localcommand": newServer.LocalCommand, + "permitlocalcommand": newServer.PermitLocalCommand, + "escapechar": newServer.EscapeChar, + "loglevel": newServer.LogLevel, + } + + // Update or remove nodes based on value for key, value := range updates { if value != "" { r.updateOrAddKVNode(host, key, value) + } else { + // Remove the key if value is empty (user selected default) + r.removeKVNode(host, key) } } - // Replace IdentityFile entries entirely to reflect the new state. - // This ensures removing/clearing identity files works as expected. - - removeKey := func(nodes []ssh_config.Node, key string) []ssh_config.Node { - filtered := make([]ssh_config.Node, 0, len(nodes)) - for _, node := range nodes { - if kv, ok := node.(*ssh_config.KV); ok { - if strings.EqualFold(kv.Key, key) { - continue // skip existing IdentityFile - } - } - filtered = append(filtered, node) - } - return filtered - } - host.Nodes = removeKey(host.Nodes, "IdentityFile") + // Replace multi-value entries entirely to reflect the new state + host.Nodes = removeNodesByKey(host.Nodes, "IdentityFile") for _, identityFile := range newServer.IdentityFiles { r.addKVNodeIfNotEmpty(host, "IdentityFile", identityFile) } + + host.Nodes = removeNodesByKey(host.Nodes, "LocalForward") + for _, forward := range newServer.LocalForward { + configFormat := r.convertCLIForwardToConfigFormat(forward) + r.addKVNodeIfNotEmpty(host, "LocalForward", configFormat) + } + + host.Nodes = removeNodesByKey(host.Nodes, "RemoteForward") + for _, forward := range newServer.RemoteForward { + configFormat := r.convertCLIForwardToConfigFormat(forward) + r.addKVNodeIfNotEmpty(host, "RemoteForward", configFormat) + } + + host.Nodes = removeNodesByKey(host.Nodes, "DynamicForward") + for _, forward := range newServer.DynamicForward { + r.addKVNodeIfNotEmpty(host, "DynamicForward", forward) + } + + host.Nodes = removeNodesByKey(host.Nodes, "SendEnv") + for _, env := range newServer.SendEnv { + r.addKVNodeIfNotEmpty(host, "SendEnv", env) + } + + host.Nodes = removeNodesByKey(host.Nodes, "SetEnv") + for _, env := range newServer.SetEnv { + r.addKVNodeIfNotEmpty(host, "SetEnv", env) + } } // updateOrAddKVNode updates an existing key-value node or adds a new one if it doesn't exist. @@ -183,14 +357,93 @@ func (r *Repository) updateOrAddKVNode(host *ssh_config.Host, key, newValue stri host.Nodes = append(host.Nodes, kvNode) } +// removeKVNode removes a key-value node from the host if it exists. +func (r *Repository) removeKVNode(host *ssh_config.Host, key string) { + filtered := make([]ssh_config.Node, 0, len(host.Nodes)) + for _, node := range host.Nodes { + if kvNode, ok := node.(*ssh_config.KV); ok { + if strings.EqualFold(kvNode.Key, key) { + continue // Skip this node (remove it) + } + } + filtered = append(filtered, node) + } + host.Nodes = filtered +} + // getProperKeyCase returns the proper case for known SSH config keys. // Reference: https://www.ssh.com/academy/ssh/config func (r *Repository) getProperKeyCase(key string) string { keyMap := map[string]string{ - "hostname": "HostName", - "user": "User", - "port": "Port", - "identityfile": "IdentityFile", + "hostname": "HostName", + "user": "User", + "port": "Port", + "identityfile": "IdentityFile", + "proxycommand": "ProxyCommand", + "proxyjump": "ProxyJump", + "remotecommand": "RemoteCommand", + "requesttty": "RequestTTY", + "sessiontype": "SessionType", + "connecttimeout": "ConnectTimeout", + "connectionattempts": "ConnectionAttempts", + "bindaddress": "BindAddress", + "bindinterface": "BindInterface", + "addressfamily": "AddressFamily", + "exitonforwardfailure": "ExitOnForwardFailure", + "ipqos": "IPQoS", + "canonicalizehostname": "CanonicalizeHostname", + "canonicaldomains": "CanonicalDomains", + "canonicalizefallbacklocal": "CanonicalizeFallbackLocal", + "canonicalizemaxdots": "CanonicalizeMaxDots", + "canonicalizepermittedcnames": "CanonicalizePermittedCNAMEs", + "localforward": "LocalForward", + "remoteforward": "RemoteForward", + "dynamicforward": "DynamicForward", + "clearallforwardings": "ClearAllForwardings", + "gatewayports": "GatewayPorts", + "pubkeyauthentication": "PubkeyAuthentication", + "passwordauthentication": "PasswordAuthentication", + "preferredauthentications": "PreferredAuthentications", + "pubkeyacceptedalgorithms": "PubkeyAcceptedAlgorithms", + "pubkeyacceptedkeytypes": "PubkeyAcceptedAlgorithms", // Deprecated alias (since OpenSSH 8.5) + "hostbasedacceptedalgorithms": "HostbasedAcceptedAlgorithms", + "hostbasedkeytypes": "HostbasedAcceptedAlgorithms", // Deprecated alias (since OpenSSH 8.5) + "hostbasedacceptedkeytypes": "HostbasedAcceptedAlgorithms", // Deprecated alias (since OpenSSH 8.5) + "identitiesonly": "IdentitiesOnly", + "addkeystoagent": "AddKeysToAgent", + "identityagent": "IdentityAgent", + "kbdinteractiveauthentication": "KbdInteractiveAuthentication", + "challengeresponseauthentication": "KbdInteractiveAuthentication", // Deprecated alias + "numberofpasswordprompts": "NumberOfPasswordPrompts", + "forwardagent": "ForwardAgent", + "forwardx11": "ForwardX11", + "forwardx11trusted": "ForwardX11Trusted", + "controlmaster": "ControlMaster", + "controlpath": "ControlPath", + "controlpersist": "ControlPersist", + "serveraliveinterval": "ServerAliveInterval", + "serveralivecountmax": "ServerAliveCountMax", + "compression": "Compression", + "tcpkeepalive": "TCPKeepAlive", + "stricthostkeychecking": "StrictHostKeyChecking", + "checkhostip": "CheckHostIP", + "fingerprinthash": "FingerprintHash", + "verifyhostkeydns": "VerifyHostKeyDNS", + "updatehostkeys": "UpdateHostKeys", + "hashknownhosts": "HashKnownHosts", + "visualhostkey": "VisualHostKey", + "userknownhostsfile": "UserKnownHostsFile", + "hostkeyalgorithms": "HostKeyAlgorithms", + "macs": "MACs", + "ciphers": "Ciphers", + "kexalgorithms": "KexAlgorithms", + "localcommand": "LocalCommand", + "permitlocalcommand": "PermitLocalCommand", + "escapechar": "EscapeChar", + "sendenv": "SendEnv", + "setenv": "SetEnv", + "loglevel": "LogLevel", + "batchmode": "BatchMode", } if properCase, exists := keyMap[strings.ToLower(key)]; exists { @@ -199,6 +452,79 @@ func (r *Repository) getProperKeyCase(key string) string { return key } +// convertCLIForwardToConfigFormat converts CLI format forwarding spec to SSH config format. +// CLI format: [bind_address:]port:host:hostport +// Config format: [bind_address:]port host:hostport +func (r *Repository) convertCLIForwardToConfigFormat(forward string) string { + // Handle IPv6 addresses in brackets like [2001:db8::1] + // These should be treated as a single unit + + // Find the last `:digits` that represents the final port + lastPortStart := -1 + for i := len(forward) - 1; i >= 0; i-- { + if forward[i] == ':' { + // Check if everything after this colon is digits + if i+1 < len(forward) { + allDigits := true + hasDigits := false + for j := i + 1; j < len(forward); j++ { + if forward[j] >= '0' && forward[j] <= '9' { + hasDigits = true + } else { + allDigits = false + break + } + } + if allDigits && hasDigits { + lastPortStart = i + break + } + } + } + } + + if lastPortStart == -1 { + // No port at the end, return as-is + return forward + } + + // Now find the split point between local and remote parts + // We need to handle bracket-enclosed addresses specially + inBrackets := 0 + for i := lastPortStart - 1; i >= 0; i-- { + switch forward[i] { + case ']': + inBrackets++ + case '[': + inBrackets-- + case ':': + if inBrackets != 0 { + continue + } + // This colon is not inside brackets + // Check if this looks like it could be the split point + // The split point would be after a port number (digits after a colon) + + // Look ahead to see what comes after this colon + nextChar := byte(' ') + if i+1 < len(forward) { + nextChar = forward[i+1] + } + + // If the next character could be start of a host (letter, digit, bracket) + // then this is our split point + if nextChar != ':' { + localPart := forward[:i] + remotePart := forward[i+1:] + return localPart + " " + remotePart + } + } + } + + // If no split point found, return as-is + return forward +} + // removeHostByAlias removes a host by its alias from the list of hosts. func (r *Repository) removeHostByAlias(hosts []*ssh_config.Host, alias string) []*ssh_config.Host { for i, host := range hosts { diff --git a/internal/adapters/data/ssh_config_file/crud_test.go b/internal/adapters/data/ssh_config_file/crud_test.go new file mode 100644 index 0000000..fc0ecf9 --- /dev/null +++ b/internal/adapters/data/ssh_config_file/crud_test.go @@ -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) + } + }) + } +} diff --git a/internal/adapters/data/ssh_config_file/mapper.go b/internal/adapters/data/ssh_config_file/mapper.go index 57f9d90..f8a31f4 100644 --- a/internal/adapters/data/ssh_config_file/mapper.go +++ b/internal/adapters/data/ssh_config_file/mapper.go @@ -65,19 +65,230 @@ func (r *Repository) toDomainServer(cfg *ssh_config.Config) []domain.Server { // mapKVToServer maps an ssh_config.KV node to the corresponding fields in domain.Server. func (r *Repository) mapKVToServer(server *domain.Server, kvNode *ssh_config.KV) { - switch strings.ToLower(kvNode.Key) { + key := strings.ToLower(kvNode.Key) + value := kvNode.Value + + // Try mapping in order of categories + if r.mapBasicConfig(server, key, value) { + return + } + if r.mapConnectionConfig(server, key, value) { + return + } + if r.mapForwardingConfig(server, key, value) { + return + } + if r.mapAuthenticationConfig(server, key, value) { + return + } + if r.mapSecurityConfig(server, key, value) { + return + } + if r.mapEnvironmentConfig(server, key, value) { + return + } + r.mapDebugConfig(server, key, value) +} + +// mapBasicConfig maps basic SSH configuration fields +func (r *Repository) mapBasicConfig(server *domain.Server, key, value string) bool { + switch key { case "hostname": - server.Host = kvNode.Value + server.Host = value case "user": - server.User = kvNode.Value + server.User = value case "port": - port, err := strconv.Atoi(kvNode.Value) + port, err := strconv.Atoi(value) if err == nil { server.Port = port } case "identityfile": - server.IdentityFiles = append(server.IdentityFiles, kvNode.Value) + server.IdentityFiles = append(server.IdentityFiles, value) + default: + return false } + return true +} + +// mapConnectionConfig maps connection and proxy configuration fields +func (r *Repository) mapConnectionConfig(server *domain.Server, key, value string) bool { + switch key { + case "proxycommand": + server.ProxyCommand = value + case "proxyjump": + server.ProxyJump = value + case "remotecommand": + server.RemoteCommand = value + case "requesttty": + server.RequestTTY = value + case "sessiontype": + server.SessionType = value + case "connecttimeout": + server.ConnectTimeout = value + case "connectionattempts": + server.ConnectionAttempts = value + case "bindaddress": + server.BindAddress = value + case "bindinterface": + server.BindInterface = value + case "addressfamily": + server.AddressFamily = value + case "exitonforwardfailure": + server.ExitOnForwardFailure = value + case "ipqos": + server.IPQoS = value + case "canonicalizehostname": + server.CanonicalizeHostname = value + case "canonicaldomains": + server.CanonicalDomains = value + case "canonicalizefallbacklocal": + server.CanonicalizeFallbackLocal = value + case "canonicalizemaxdots": + server.CanonicalizeMaxDots = value + case "canonicalizepermittedcnames": + server.CanonicalizePermittedCNAMEs = value + case "serveraliveinterval": + server.ServerAliveInterval = value + case "serveralivecountmax": + server.ServerAliveCountMax = value + case "compression": + server.Compression = value + case "tcpkeepalive": + server.TCPKeepAlive = value + case "batchmode": + server.BatchMode = value + case "controlmaster": + server.ControlMaster = value + case "controlpath": + server.ControlPath = value + case "controlpersist": + server.ControlPersist = value + default: + return false + } + return true +} + +// mapForwardingConfig maps port forwarding and agent forwarding fields +func (r *Repository) mapForwardingConfig(server *domain.Server, key, value string) bool { + switch key { + case "localforward": + cliFormat := r.convertConfigForwardToCLIFormat(value) + server.LocalForward = append(server.LocalForward, cliFormat) + case "remoteforward": + cliFormat := r.convertConfigForwardToCLIFormat(value) + server.RemoteForward = append(server.RemoteForward, cliFormat) + case "dynamicforward": + server.DynamicForward = append(server.DynamicForward, value) + case "clearallforwardings": + server.ClearAllForwardings = value + case "gatewayports": + server.GatewayPorts = value + case "forwardagent": + server.ForwardAgent = value + case "forwardx11": + server.ForwardX11 = value + case "forwardx11trusted": + server.ForwardX11Trusted = value + default: + return false + } + return true +} + +// mapAuthenticationConfig maps authentication-related fields +func (r *Repository) mapAuthenticationConfig(server *domain.Server, key, value string) bool { + switch key { + case "pubkeyauthentication": + server.PubkeyAuthentication = value + case "pubkeyacceptedalgorithms", "pubkeyacceptedkeytypes": + // PubkeyAcceptedKeyTypes is deprecated alias for PubkeyAcceptedAlgorithms (since OpenSSH 8.5) + server.PubkeyAcceptedAlgorithms = value + case "hostbasedacceptedalgorithms", "hostbasedkeytypes", "hostbasedacceptedkeytypes": + // HostbasedKeyTypes and HostbasedAcceptedKeyTypes are deprecated aliases (since OpenSSH 8.5) + server.HostbasedAcceptedAlgorithms = value + case "passwordauthentication": + server.PasswordAuthentication = value + case "preferredauthentications": + server.PreferredAuthentications = value + case "identitiesonly": + server.IdentitiesOnly = value + case "addkeystoagent": + server.AddKeysToAgent = value + case "identityagent": + server.IdentityAgent = value + case "kbdinteractiveauthentication", "challengeresponseauthentication": + // ChallengeResponseAuthentication is deprecated alias for KbdInteractiveAuthentication + server.KbdInteractiveAuthentication = value + case "numberofpasswordprompts": + server.NumberOfPasswordPrompts = value + default: + return false + } + return true +} + +// mapSecurityConfig maps security-related fields +func (r *Repository) mapSecurityConfig(server *domain.Server, key, value string) bool { + switch key { + case "stricthostkeychecking": + server.StrictHostKeyChecking = value + case "checkhostip": + server.CheckHostIP = value + case "fingerprinthash": + server.FingerprintHash = value + case "userknownhostsfile": + server.UserKnownHostsFile = value + case "hostkeyalgorithms": + server.HostKeyAlgorithms = value + case "macs": + server.MACs = value + case "ciphers": + server.Ciphers = value + case "kexalgorithms": + server.KexAlgorithms = value + case "verifyhostkeydns": + server.VerifyHostKeyDNS = value + case "updatehostkeys": + server.UpdateHostKeys = value + case "hashknownhosts": + server.HashKnownHosts = value + case "visualhostkey": + server.VisualHostKey = value + default: + return false + } + return true +} + +// mapEnvironmentConfig maps environment and command execution fields +func (r *Repository) mapEnvironmentConfig(server *domain.Server, key, value string) bool { + switch key { + case "localcommand": + server.LocalCommand = value + case "permitlocalcommand": + server.PermitLocalCommand = value + case "escapechar": + server.EscapeChar = value + case "sendenv": + server.SendEnv = append(server.SendEnv, value) + case "setenv": + server.SetEnv = append(server.SetEnv, value) + default: + return false + } + return true +} + +// mapDebugConfig maps debugging-related fields +func (r *Repository) mapDebugConfig(server *domain.Server, key, value string) bool { + switch key { + case "loglevel": + server.LogLevel = value + default: + return false + } + return true } // mergeMetadata merges additional metadata into the servers. @@ -104,3 +315,19 @@ func (r *Repository) mergeMetadata(servers []domain.Server, metadata map[string] } return servers } + +// convertConfigForwardToCLIFormat converts SSH config format forwarding spec to CLI format. +// Config format: [bind_address:]port host:hostport +// CLI format: [bind_address:]port:host:hostport +func (r *Repository) convertConfigForwardToCLIFormat(forward string) string { + // Find the last space which separates the local part from the remote part + lastSpace := strings.LastIndex(forward, " ") + if lastSpace != -1 { + localPart := forward[:lastSpace] + remotePart := forward[lastSpace+1:] + // Join them with a colon for CLI format + return localPart + ":" + remotePart + } + // If no space found, return as-is (might already be in CLI format) + return forward +} diff --git a/internal/adapters/ui/defaults.go b/internal/adapters/ui/defaults.go new file mode 100644 index 0000000..94af3cb --- /dev/null +++ b/internal/adapters/ui/defaults.go @@ -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 "" + } +} diff --git a/internal/adapters/ui/field_help.go b/internal/adapters/ui/field_help.go new file mode 100644 index 0000000..b55210a --- /dev/null +++ b/internal/adapters/ui/field_help.go @@ -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 +} diff --git a/internal/adapters/ui/field_help_test.go b/internal/adapters/ui/field_help_test.go new file mode 100644 index 0000000..b554d83 --- /dev/null +++ b/internal/adapters/ui/field_help_test.go @@ -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) + } + } +} diff --git a/internal/adapters/ui/handlers.go b/internal/adapters/ui/handlers.go index 0f2cc9e..7b1becd 100644 --- a/internal/adapters/ui/handlers.go +++ b/internal/adapters/ui/handlers.go @@ -183,6 +183,8 @@ func (t *tui) handleServerSelectionChange(server domain.Server) { func (t *tui) handleServerAdd() { form := NewServerForm(ServerFormAdd, nil). + SetApp(t.app). + SetVersionInfo(t.version, t.commit). OnSave(t.handleServerSave). OnCancel(t.handleFormCancel) t.app.SetRoot(form, true) @@ -191,6 +193,8 @@ func (t *tui) handleServerAdd() { func (t *tui) handleServerEdit() { if server, ok := t.serverList.GetSelectedServer(); ok { form := NewServerForm(ServerFormEdit, &server). + SetApp(t.app). + SetVersionInfo(t.version, t.commit). OnSave(t.handleServerSave). OnCancel(t.handleFormCancel) t.app.SetRoot(form, true) @@ -308,7 +312,7 @@ func (t *tui) showDeleteConfirmModal(server domain.Server) { modal := tview.NewModal(). SetText(msg). - AddButtons([]string{"Cancel", "Confirm"}). + AddButtons([]string{"[yellow]C[-]ancel", "[yellow]D[-]elete"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { if buttonIndex == 1 { _ = t.serverService.DeleteServer(server) @@ -317,14 +321,32 @@ func (t *tui) showDeleteConfirmModal(server domain.Server) { t.handleModalClose() }) + // Add keyboard shortcuts for the modal + modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 'c', 'C': + // Cancel + t.handleModalClose() + return nil + case 'd', 'D': + // Delete + _ = t.serverService.DeleteServer(server) + t.refreshServerList() + t.handleModalClose() + return nil + } + // ESC key already handled by default modal behavior + return event + }) + t.app.SetRoot(modal, true) } func (t *tui) showEditTagsForm(server domain.Server) { form := tview.NewForm() form.SetBorder(true). - SetTitle(fmt.Sprintf("Edit Tags: %s", server.Alias)). - SetTitleAlign(tview.AlignLeft) + SetTitle(fmt.Sprintf(" Edit Tags: %s ", server.Alias)). + SetTitleAlign(tview.AlignCenter) defaultTags := strings.Join(server.Tags, ", ") form.AddInputField("Tags (comma):", defaultTags, 40, nil, nil) diff --git a/internal/adapters/ui/network_interfaces.go b/internal/adapters/ui/network_interfaces.go new file mode 100644 index 0000000..d9c228c --- /dev/null +++ b/internal/adapters/ui/network_interfaces.go @@ -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 +} diff --git a/internal/adapters/ui/search_bar.go b/internal/adapters/ui/search_bar.go index b42ba96..7b03c90 100644 --- a/internal/adapters/ui/search_bar.go +++ b/internal/adapters/ui/search_bar.go @@ -39,7 +39,8 @@ func (s *SearchBar) build() { SetFieldTextColor(tcell.Color252). SetFieldWidth(30). SetBorder(true). - SetTitle("Search"). + SetTitle(" Search "). + SetTitleAlign(tview.AlignCenter). SetBorderColor(tcell.Color238). SetTitleColor(tcell.Color250) diff --git a/internal/adapters/ui/server_details.go b/internal/adapters/ui/server_details.go index 1799143..8e0a634 100644 --- a/internal/adapters/ui/server_details.go +++ b/internal/adapters/ui/server_details.go @@ -39,7 +39,8 @@ func (sd *ServerDetails) build() { sd.TextView.SetDynamicColors(true). SetWrap(true). SetBorder(true). - SetTitle("Details"). + SetTitle(" Details "). + SetTitleAlign(tview.AlignCenter). SetBorderColor(tcell.Color238). SetTitleColor(tcell.Color250) } @@ -47,7 +48,7 @@ func (sd *ServerDetails) build() { // renderTagChips builds colored tag chips for details view. func renderTagChips(tags []string) string { if len(tags) == 0 { - return "-" + return "" } chips := make([]string, 0, len(tags)) for _, t := range tags { @@ -68,11 +69,152 @@ func (sd *ServerDetails) UpdateServer(server domain.Server) { pinnedStr = "false" } tagsText := renderTagChips(server.Tags) + + // Basic information + aliasText := strings.Join(server.Aliases, ", ") + + userText := server.User + + hostText := server.Host + + portText := fmt.Sprintf("%d", server.Port) + if server.Port == 0 { + portText = "" + } + text := fmt.Sprintf( - "[::b]%s[-]\n\nHost: [white]%s[-]\nUser: [white]%s[-]\nPort: [white]%d[-]\nKey: [white]%s[-]\nTags: %s\nPinned: [white]%s[-]\nLast SSH: %s\nSSH Count: [white]%d[-]\n\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin", - strings.Join(server.Aliases, ", "), server.Host, server.User, server.Port, + "[::b]%s[-]\n\n[::b]Basic Settings:[-]\n Host: [white]%s[-]\n User: [white]%s[-]\n Port: [white]%s[-]\n Key: [white]%s[-]\n Tags: %s\n Pinned: [white]%s[-]\n Last SSH: %s\n SSH Count: [white]%d[-]\n", + aliasText, hostText, userText, portText, serverKey, tagsText, pinnedStr, lastSeen, server.SSHCount) + + // Advanced settings section (only show non-empty fields) + // Organized by logical grouping for better readability + type fieldEntry struct { + name string + value string + } + + type fieldGroup struct { + name string + fields []fieldEntry + } + + // Create field groups for better organization and future extensibility + groups := []fieldGroup{ + { + name: "Connection & Proxy", + fields: []fieldEntry{ + {"ProxyJump", server.ProxyJump}, + {"ProxyCommand", server.ProxyCommand}, + {"RemoteCommand", server.RemoteCommand}, + {"RequestTTY", server.RequestTTY}, + {"SessionType", server.SessionType}, + {"ConnectTimeout", server.ConnectTimeout}, + {"ConnectionAttempts", server.ConnectionAttempts}, + {"BindAddress", server.BindAddress}, + {"BindInterface", server.BindInterface}, + {"AddressFamily", server.AddressFamily}, + {"ExitOnForwardFailure", server.ExitOnForwardFailure}, + {"IPQoS", server.IPQoS}, + {"CanonicalizeHostname", server.CanonicalizeHostname}, + {"CanonicalDomains", server.CanonicalDomains}, + {"CanonicalizeFallbackLocal", server.CanonicalizeFallbackLocal}, + {"CanonicalizeMaxDots", server.CanonicalizeMaxDots}, + {"CanonicalizePermittedCNAMEs", server.CanonicalizePermittedCNAMEs}, + {"ServerAliveInterval", server.ServerAliveInterval}, + {"ServerAliveCountMax", server.ServerAliveCountMax}, + {"Compression", server.Compression}, + {"TCPKeepAlive", server.TCPKeepAlive}, + {"BatchMode", server.BatchMode}, + {"ControlMaster", server.ControlMaster}, + {"ControlPath", server.ControlPath}, + {"ControlPersist", server.ControlPersist}, + }, + }, + { + name: "Authentication", + fields: []fieldEntry{ + {"PubkeyAuthentication", server.PubkeyAuthentication}, + {"PubkeyAcceptedAlgorithms", server.PubkeyAcceptedAlgorithms}, + {"HostbasedAcceptedAlgorithms", server.HostbasedAcceptedAlgorithms}, + {"PasswordAuthentication", server.PasswordAuthentication}, + {"PreferredAuthentications", server.PreferredAuthentications}, + {"IdentitiesOnly", server.IdentitiesOnly}, + {"AddKeysToAgent", server.AddKeysToAgent}, + {"IdentityAgent", server.IdentityAgent}, + {"KbdInteractiveAuthentication", server.KbdInteractiveAuthentication}, + {"NumberOfPasswordPrompts", server.NumberOfPasswordPrompts}, + }, + }, + { + name: "Forwarding", + fields: []fieldEntry{ + {"ForwardAgent", server.ForwardAgent}, + {"ForwardX11", server.ForwardX11}, + {"ForwardX11Trusted", server.ForwardX11Trusted}, + {"LocalForward", strings.Join(server.LocalForward, ", ")}, + {"RemoteForward", strings.Join(server.RemoteForward, ", ")}, + {"DynamicForward", strings.Join(server.DynamicForward, ", ")}, + {"ClearAllForwardings", server.ClearAllForwardings}, + {"GatewayPorts", server.GatewayPorts}, + }, + }, + { + name: "Security & Cryptography", + fields: []fieldEntry{ + {"StrictHostKeyChecking", server.StrictHostKeyChecking}, + {"CheckHostIP", server.CheckHostIP}, + {"FingerprintHash", server.FingerprintHash}, + {"UserKnownHostsFile", server.UserKnownHostsFile}, + {"HostKeyAlgorithms", server.HostKeyAlgorithms}, + {"Ciphers", server.Ciphers}, + {"MACs", server.MACs}, + {"KexAlgorithms", server.KexAlgorithms}, + {"VerifyHostKeyDNS", server.VerifyHostKeyDNS}, + {"UpdateHostKeys", server.UpdateHostKeys}, + {"HashKnownHosts", server.HashKnownHosts}, + {"VisualHostKey", server.VisualHostKey}, + }, + }, + { + name: "Environment & Execution", + fields: []fieldEntry{ + {"LocalCommand", server.LocalCommand}, + {"PermitLocalCommand", server.PermitLocalCommand}, + {"EscapeChar", server.EscapeChar}, + {"SendEnv", strings.Join(server.SendEnv, ", ")}, + {"SetEnv", strings.Join(server.SetEnv, ", ")}, + }, + }, + { + name: "Debugging", + fields: []fieldEntry{ + {"LogLevel", server.LogLevel}, + }, + }, + } + + // Build advanced settings text without group labels for cleaner display + hasAdvanced := false + advancedText := "\n[::b]Advanced Settings:[-]\n" + + for _, group := range groups { + for _, field := range group.fields { + if field.value != "" { + hasAdvanced = true + advancedText += fmt.Sprintf(" %s: [white]%s[-]\n", field.name, field.value) + } + } + } + + if hasAdvanced { + text += advancedText + } + + // Commands list + text += "\n[::b]Commands:[-]\n Enter: SSH connect\n c: Copy SSH command\n g: Ping server\n r: Refresh list\n a: Add new server\n e: Edit entry\n t: Edit tags\n d: Delete entry\n p: Pin/Unpin" + sd.TextView.SetText(text) } diff --git a/internal/adapters/ui/server_form.go b/internal/adapters/ui/server_form.go index 3cf1a30..286b47f 100644 --- a/internal/adapters/ui/server_form.go +++ b/internal/adapters/ui/server_form.go @@ -16,8 +16,7 @@ package ui import ( "fmt" - "net" - "regexp" + "reflect" "strconv" "strings" @@ -26,6 +25,10 @@ import ( "github.com/rivo/tview" ) +// sshDefaults is now replaced by SSHFieldDefaults in defaults.go +// This variable references the centralized defaults for consistency +var sshDefaults = SSHFieldDefaults + type ServerFormMode int const ( @@ -33,38 +36,163 @@ const ( ServerFormEdit ) +const ( + tabSeparator = "[gray]|[-] " // Tab separator with gray color + + // SessionType values (sessionTypeNone and sessionTypeSubsystem are in utils.go) + sessionTypeDefault = "default" +) + type ServerForm struct { - *tview.Form - mode ServerFormMode - original *domain.Server - onSave func(domain.Server, *domain.Server) - onCancel func() + *tview.Flex // The root container (includes header, form panel and hint bar) + header *AppHeader // The app header + formPanel *tview.Flex // The actual form panel + pages *tview.Pages + tabBar *tview.TextView + forms map[string]*tview.Form + currentTab string + tabs []string + tabAbbrev map[string]string // Abbreviated tab names for narrow views + mode ServerFormMode + original *domain.Server + onSave func(domain.Server, *domain.Server) + onCancel func() + app *tview.Application // Reference to app for showing modals + version string // Version for header + commit string // Commit for header + validation *ValidationState // Validation state for all fields + helpPanel *tview.TextView // Help panel for field descriptions + helpMode HelpDisplayMode // Current help display mode + currentField string // Currently focused field + mainContainer *tview.Flex // Container for form and help panel } func NewServerForm(mode ServerFormMode, original *domain.Server) *ServerForm { + // Create help panel + helpPanel := tview.NewTextView(). + SetDynamicColors(true). + SetWordWrap(true). + SetScrollable(true) + helpPanel.SetBorder(true). + SetBorderPadding(0, 0, 1, 1). + SetTitle(" Help "). + SetTitleAlign(tview.AlignCenter) + + // Create main container for form and help + mainContainer := tview.NewFlex().SetDirection(tview.FlexColumn) + form := &ServerForm{ - Form: tview.NewForm(), - mode: mode, - original: original, + Flex: tview.NewFlex().SetDirection(tview.FlexRow), + formPanel: tview.NewFlex().SetDirection(tview.FlexRow), + pages: tview.NewPages(), + tabBar: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignCenter).SetRegions(true), + forms: make(map[string]*tview.Form), + mode: mode, + original: original, + validation: NewValidationState(), + helpPanel: helpPanel, + helpMode: HelpModeNormal, // Show help panel by default + mainContainer: mainContainer, + tabs: []string{ + "Basic", + "Connection", + "Forwarding", + "Authentication", + "Advanced", + }, + tabAbbrev: map[string]string{ + "Basic": "Basic", + "Connection": "Conn", + "Forwarding": "Fwd", + "Authentication": "Auth", + "Advanced": "Adv", + }, } - form.build() + form.currentTab = "Basic" + // Don't build here, wait for version info to be set return form } func (sf *ServerForm) build() { - title := sf.titleForMode() + // Create header + sf.header = NewAppHeader(sf.version, sf.commit, RepoURL) - sf.Form.SetBorder(true). - SetTitle(title). - SetTitleAlign(tview.AlignLeft). + // Create forms for each tab + sf.createBasicForm() + sf.createConnectionForm() + sf.createForwardingForm() + sf.createAuthenticationForm() + sf.createAdvancedForm() + + // Setup tab bar + sf.updateTabBar() + + // Setup form panel + sf.formPanel.SetBorder(true). + SetTitle(" " + sf.titleForMode() + " "). + SetTitleAlign(tview.AlignCenter). SetBorderColor(tcell.Color238). SetTitleColor(tcell.Color250) - sf.addFormFields() + sf.formPanel.AddItem(sf.tabBar, 1, 0, false). + AddItem(sf.pages, 0, 1, true) - sf.Form.AddButton("Save", sf.handleSave) - sf.Form.AddButton("Cancel", sf.handleCancel) - sf.Form.SetCancelFunc(sf.handleCancel) + // Setup main container with form and help panel + sf.mainContainer.Clear() + + // Responsive layout: hide help panel if window is too narrow + sf.mainContainer.SetDrawFunc(func(screen tcell.Screen, x, y, width, height int) (int, int, int, int) { + // Minimum width for showing help panel + minWidthForHelp := 120 + + // Clear and rebuild based on width + sf.mainContainer.Clear() + + if width < minWidthForHelp || sf.helpMode == HelpModeOff { + // Window too narrow or help is off - only show form + sf.mainContainer.AddItem(sf.formPanel, 0, 1, true) + } else { + // Window wide enough - show both form and help + sf.mainContainer.AddItem(sf.formPanel, 0, 3, true) + sf.mainContainer.AddItem(sf.helpPanel, 0, 2, false) + // Refresh help content to recalculate separator width on resize + sf.updateHelp(sf.currentField) + } + + return x, y, width, height + }) + + // Initial setup + sf.mainContainer.AddItem(sf.formPanel, 0, 3, true) + if sf.helpMode != HelpModeOff { + sf.mainContainer.AddItem(sf.helpPanel, 0, 2, false) + } + + // Create hint bar with same background as main screen's status bar + hintBar := tview.NewTextView().SetDynamicColors(true) + hintBar.SetBackgroundColor(tcell.Color235) + hintBar.SetTextAlign(tview.AlignCenter) + hintBar.SetText("[white]^H/^L[-] Navigate β€’ [white]^S[-] Save β€’ [white]Esc[-] Cancel") + + // Setup main container - header at top, hint bar at bottom + sf.Flex.AddItem(sf.header, 2, 0, false). + AddItem(sf.mainContainer, 0, 1, true). + AddItem(hintBar, 1, 0, false) + + // Initialize help with first field + sf.updateHelp("Alias") + + // Setup keyboard shortcuts + sf.setupKeyboardShortcuts() + + // Set a draw function for the tab bar to update on each draw + // This ensures the tab bar updates when the window is resized + sf.tabBar.SetDrawFunc(func(screen tcell.Screen, x int, y int, width int, height int) (int, int, int, int) { + // Update tab bar if size changed + sf.updateTabBar() + // Return the original dimensions + return x, y, width, height + }) } func (sf *ServerForm) titleForMode() string { @@ -74,31 +202,1440 @@ func (sf *ServerForm) titleForMode() string { return "Add Server" } -func (sf *ServerForm) addFormFields() { - var defaultValues ServerFormData - if sf.mode == ServerFormEdit && sf.original != nil { - defaultValues = ServerFormData{ - Alias: sf.original.Alias, - Host: sf.original.Host, - User: sf.original.User, - Port: fmt.Sprint(sf.original.Port), - Key: strings.Join(sf.original.IdentityFiles, ", "), - Tags: strings.Join(sf.original.Tags, ", "), +func (sf *ServerForm) getCurrentTabIndex() int { + for i, tab := range sf.tabs { + if tab == sf.currentTab { + return i } - } else { - defaultValues = ServerFormData{ - User: "root", - Port: "22", - Key: "~/.ssh/id_ed25519", + } + return 0 +} + +func (sf *ServerForm) calculateTabsWidth(useAbbrev bool) int { + width := 0 + for i, tab := range sf.tabs { + tabName := tab + if useAbbrev { + tabName = sf.tabAbbrev[tab] + } + width += len(tabName) + 2 // space + name + space + if i < len(sf.tabs)-1 { + width += 3 // " | " separator + } + } + return width +} + +func (sf *ServerForm) determineDisplayMode(width int) string { + if width <= 20 { // Width unknown or too small + return "full" + } + + fullWidth := sf.calculateTabsWidth(false) + if fullWidth <= width-10 { + return "full" + } + + abbrevWidth := sf.calculateTabsWidth(true) + if abbrevWidth <= width-10 { + return "abbrev" + } + + return "scroll" +} + +func (sf *ServerForm) renderTab(tab string, isCurrent bool, useAbbrev bool, index int) string { + tabName := tab + if useAbbrev { + tabName = sf.tabAbbrev[tab] + } + regionID := fmt.Sprintf("tab_%d", index) + if isCurrent { + return fmt.Sprintf("[%q][black:white:b] %s [-:-:-][%q] ", regionID, tabName, "") + } + return fmt.Sprintf("[%q][gray::u] %s [-:-:-][%q] ", regionID, tabName, "") +} + +func (sf *ServerForm) renderScrollableTabs(currentIdx, width int) string { + var tabText string + availableWidth := width - 8 // Reserve space for scroll indicators + + // Calculate visible count + visibleCount := sf.calculateVisibleTabCount(availableWidth) + if visibleCount < 2 { + visibleCount = 2 + } + + // Add left scroll indicator + if currentIdx > 0 { + tabText = "[gray]β—€ [-]" + } + + // Calculate range + start, end := sf.calculateVisibleRange(currentIdx, visibleCount, len(sf.tabs)) + + // Render visible tabs + for i := start; i < end && i < len(sf.tabs); i++ { + tabText += sf.renderTab(sf.tabs[i], sf.tabs[i] == sf.currentTab, true, i) + if i < end-1 && i < len(sf.tabs)-1 { + tabText += tabSeparator } } - sf.Form.AddInputField("Alias:", defaultValues.Alias, 20, nil, nil) - sf.Form.AddInputField("Host/IP:", defaultValues.Host, 20, nil, nil) - sf.Form.AddInputField("User:", defaultValues.User, 20, nil, nil) - sf.Form.AddInputField("Port:", defaultValues.Port, 20, nil, nil) - sf.Form.AddInputField("Key (Comma):", defaultValues.Key, 40, nil, nil) - sf.Form.AddInputField("Tags (comma):", defaultValues.Tags, 30, nil, nil) + // Add right scroll indicator + if currentIdx < len(sf.tabs)-1 { + tabText += " [gray]β–Ά[-]" + } + + return tabText +} + +func (sf *ServerForm) calculateVisibleTabCount(availableWidth int) int { + visibleCount := 0 + currentWidth := 0 + + for i := 0; i < len(sf.tabs) && currentWidth < availableWidth; i++ { + abbrev := sf.tabAbbrev[sf.tabs[i]] + tabWidth := len(abbrev) + 2 + if i > 0 { + tabWidth += 3 // separator + } + if currentWidth+tabWidth <= availableWidth { + visibleCount++ + currentWidth += tabWidth + } else { + break + } + } + + return visibleCount +} + +func (sf *ServerForm) calculateVisibleRange(currentIdx, visibleCount, totalTabs int) (int, int) { + halfVisible := visibleCount / 2 + start := currentIdx - halfVisible + 1 + end := start + visibleCount + + // Adjust boundaries + if start < 0 { + start = 0 + end = visibleCount + } + if end > totalTabs { + end = totalTabs + start = end - visibleCount + if start < 0 { + start = 0 + } + } + + return start, end +} + +func (sf *ServerForm) updateTabBar() { + currentIdx := sf.getCurrentTabIndex() + + // Build tab text with scroll indicator if needed + var tabText string + + // Check if we need to show scroll indicators + x, y, width, height := sf.tabBar.GetInnerRect() + _ = x + _ = y + _ = height + + displayMode := sf.determineDisplayMode(width) + + switch displayMode { + case "scroll": + tabText = sf.renderScrollableTabs(currentIdx, width) + case "abbrev": + // Show all tabs with abbreviated names + for i, tab := range sf.tabs { + tabText += sf.renderTab(tab, tab == sf.currentTab, true, i) + if i < len(sf.tabs)-1 { + tabText += tabSeparator + } + } + default: // "full" + // Show all tabs with full names + for i, tab := range sf.tabs { + tabText += sf.renderTab(tab, tab == sf.currentTab, false, i) + if i < len(sf.tabs)-1 { + tabText += tabSeparator + } + } + } + + sf.tabBar.SetText(tabText) + + // Set up mouse click handler using highlight regions + sf.tabBar.SetHighlightedFunc(func(added, removed, remaining []string) { + if len(added) > 0 { + // Extract tab index from region ID (format: "tab_0", "tab_1", etc) + for _, regionID := range added { + if len(regionID) > 4 && regionID[:4] == "tab_" { + idx := int(regionID[4] - '0') + if idx < len(sf.tabs) { + sf.switchToTab(sf.tabs[idx]) + } + } + } + } + }) +} + +func (sf *ServerForm) switchToTab(tabName string) { + for _, tab := range sf.tabs { + if tab != tabName { + continue + } + + sf.currentTab = tabName + sf.pages.SwitchToPage(tabName) + sf.updateTabBar() + + // Set focus to the form in the newly selected tab + if form, exists := sf.forms[tabName]; exists && sf.app != nil { + sf.app.SetFocus(form) + } + break + } +} + +func (sf *ServerForm) nextTab() { + for i, tab := range sf.tabs { + if tab == sf.currentTab { + // Loop to first tab if at the last tab + if i == len(sf.tabs)-1 { + sf.switchToTab(sf.tabs[0]) + } else { + sf.switchToTab(sf.tabs[i+1]) + } + break + } + } +} + +func (sf *ServerForm) prevTab() { + for i, tab := range sf.tabs { + if tab == sf.currentTab { + // Loop to last tab if at the first tab + if i == 0 { + sf.switchToTab(sf.tabs[len(sf.tabs)-1]) + } else { + sf.switchToTab(sf.tabs[i-1]) + } + break + } + } +} + +// updateHelp updates the help panel with information for the given field +func (sf *ServerForm) updateHelp(fieldName string) { + if sf.helpPanel == nil || sf.helpMode == HelpModeOff { + return + } + + sf.currentField = fieldName + help := GetFieldHelp(fieldName) + if help == nil { + sf.helpPanel.SetText("[dim]No help available for this field[-]") + return + } + + var content string + if sf.helpMode == HelpModeCompact { + // Compact mode: single line + example := "" + if len(help.Examples) > 0 { + example = help.Examples[0] + } + content = fmt.Sprintf("[yellow]%s:[-] %s", help.Field, escapeForTview(help.Description)) + if example != "" { + content += fmt.Sprintf(" [dim](e.g., %s)[-]", escapeForTview(example)) + } + } else { + // Normal/Full mode: detailed help + content = sf.formatDetailedHelp(help) + } + + sf.helpPanel.SetText(content) +} + +// escapeForTview escapes special characters for tview display +func escapeForTview(text string) string { + // Use tview's own Escape function to properly escape text + return tview.Escape(text) +} + +// formatDetailedHelp formats detailed help content for a field +func (sf *ServerForm) formatDetailedHelp(help *FieldHelp) string { + var b strings.Builder + + // Calculate separator width dynamically + // Get the actual width of the help panel if possible + separatorWidth := 40 // Default width + if sf.helpPanel != nil { + _, _, width, _ := sf.helpPanel.GetInnerRect() + if width > 0 { + separatorWidth = width // Fill entire width + } + } + + // Title with field name and separator below + b.WriteString(fmt.Sprintf("[yellow::b]πŸ“– %s[-::-]\n", help.Field)) + b.WriteString("[#444444]" + strings.Repeat("─", separatorWidth) + "[-]\n\n") + + // Description - needs escaping as it might contain brackets + b.WriteString(fmt.Sprintf("%s\n\n", escapeForTview(help.Description))) + + // Syntax - needs escaping as it often contains brackets like [user@] + if help.Syntax != "" { + b.WriteString("[cyan]Syntax:[-] ") + b.WriteString(fmt.Sprintf("%s\n\n", escapeForTview(help.Syntax))) + } + + // Examples - needs escaping as they might contain special characters + if len(help.Examples) > 0 { + b.WriteString("[cyan]Examples:[-]\n") + for _, ex := range help.Examples { + b.WriteString(fmt.Sprintf(" β€’ %s\n", escapeForTview(ex))) + } + b.WriteString("\n") + } + + // Default value - already processed by formatDefaultValue, no additional escaping needed + if help.Default != "" { + b.WriteString(fmt.Sprintf("[dim]Default: %s[-]\n", help.Default)) + } + + // Version info - unlikely to contain brackets, but escape for safety + if help.Since != "" { + b.WriteString(fmt.Sprintf("[dim]Available since: %s[-]\n", escapeForTview(help.Since))) + } + + return b.String() +} + +// Note: toggleHelp and rebuildLayout were removed due to hang issues +// The help panel is now always visible on the right side + +func (sf *ServerForm) setupKeyboardShortcuts() { + // Set input capture for the main flex container + sf.Flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + // Help panel is always visible - no toggle needed + + // Check for Ctrl key combinations with regular keys + if event.Key() == tcell.KeyRune && event.Modifiers()&tcell.ModCtrl != 0 { + switch event.Rune() { + case 'h', 'H', 8: // 8 is ASCII for Ctrl+H (backspace) + // Ctrl+H: Previous tab + sf.prevTab() + return nil + case 'l', 'L', 12: // 12 is ASCII for Ctrl+L (form feed) + // Ctrl+L: Next tab + sf.nextTab() + return nil + case 's', 'S', 19: // 19 is ASCII for Ctrl+S + // Ctrl+S: Save + sf.handleSave() + return nil + } + } + + // Handle special keys + //nolint:exhaustive // We only handle specific keys and pass through others + switch event.Key() { + case tcell.KeyCtrlS: + // Ctrl+S: Save (backup handler) + sf.handleSave() + return nil + case tcell.KeyEscape: + // ESC: Cancel + sf.handleCancel() + return nil + case tcell.KeyCtrlH: + // Ctrl+H: Previous tab (backup handler) + sf.prevTab() + return nil + case tcell.KeyCtrlL: + // Ctrl+L: Next tab (backup handler) + sf.nextTab() + return nil + default: + // Pass through all other keys + } + + return event + }) +} + +// setupFormShortcuts sets up keyboard shortcuts for a form +func (sf *ServerForm) setupFormShortcuts(form *tview.Form) { + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + // Check for Ctrl key combinations + if event.Key() == tcell.KeyRune && event.Modifiers()&tcell.ModCtrl != 0 { + switch event.Rune() { + case 'h', 'H', 8: // Ctrl+H: Previous tab + sf.prevTab() + return nil + case 'l', 'L', 12: // Ctrl+L: Next tab + sf.nextTab() + return nil + case 's', 'S', 19: // Ctrl+S: Save + sf.handleSave() + return nil + } + } + + // Handle special keys + //nolint:exhaustive // We only handle specific keys and pass through others + switch event.Key() { + case tcell.KeyEscape: + sf.handleCancel() + return nil + case tcell.KeyCtrlH: + sf.prevTab() + return nil + case tcell.KeyCtrlL: + sf.nextTab() + return nil + case tcell.KeyCtrlS: + sf.handleSave() + return nil + default: + // Pass through all other keys + } + + return event + }) +} + +// createOptionsWithDefault creates dropdown options with default value indicated +func createOptionsWithDefault(fieldName string, baseOptions []string) []string { + defaultValue, hasDefault := sshDefaults[fieldName] + if !hasDefault { + return baseOptions + } + + options := make([]string, len(baseOptions)) + for i, opt := range baseOptions { + if opt == "" { + options[i] = fmt.Sprintf("default [gray](%s)[-]", defaultValue) + } else { + options[i] = opt + } + } + return options +} + +// parseOptionValue extracts the actual value from an option (handles "default [gray](value)[-]" format) +func parseOptionValue(option string) string { + // Check for colored default format + if strings.HasPrefix(option, "default [gray](") && strings.HasSuffix(option, ")[-]") { + return "" // Return empty string for default values + } + // Check for plain default format (backward compatibility) + if strings.HasPrefix(option, "default (") && strings.HasSuffix(option, ")") { + return "" // Return empty string for default values + } + return option +} + +// findOptionIndex finds the index of a value in options slice +func (sf *ServerForm) findOptionIndex(options []string, value string) int { + // Empty value should match "default [gray](...)[-]" or "default (...)" option + if value == "" { + for i, opt := range options { + if strings.HasPrefix(opt, "default [gray](") || strings.HasPrefix(opt, "default (") { + return i + } + } + } + + // Look for exact match first + for i, opt := range options { + if strings.EqualFold(opt, value) { + return i + } + } + + // Then look for options with descriptions (e.g., "none (-N)" matches "none") + for i, opt := range options { + // Extract the base value from options like "none (-N)" + if spaceIdx := strings.Index(opt, " "); spaceIdx > 0 { + baseOpt := opt[:spaceIdx] + if strings.EqualFold(baseOpt, value) { + return i + } + } + } + + return 0 // Default to first option +} + +// matchesSequence checks if all characters in pattern appear in sequence within text +func matchesSequence(text, pattern string) bool { + if pattern == "" { + return true + } + + textIdx := 0 + for _, ch := range pattern { + found := false + for textIdx < len(text) { + if rune(text[textIdx]) == ch { + found = true + textIdx++ + break + } + textIdx++ + } + if !found { + return false + } + } + return true +} + +// createSSHKeyAutocomplete creates an autocomplete function for SSH key file paths +func (sf *ServerForm) createSSHKeyAutocomplete() func(string) []string { + return func(currentText string) []string { + if currentText == "" { + // Show available keys when field is empty + availableKeys := GetAvailableSSHKeys() + if len(availableKeys) == 0 { + return nil + } + return availableKeys + } + + // Split by comma to handle multiple keys + keys := strings.Split(currentText, ",") + lastKey := strings.TrimSpace(keys[len(keys)-1]) + + // If the last key is empty (after a comma and space), show all available keys + if lastKey == "" { + availableKeys := GetAvailableSSHKeys() + if len(availableKeys) == 0 { + return nil + } + // Build suggestions with existing keys + var suggestions []string + prefix := "" + if len(keys) > 1 { + // Join all keys except the last empty one + existingKeys := keys[:len(keys)-1] + for i := range existingKeys { + existingKeys[i] = strings.TrimSpace(existingKeys[i]) + } + prefix = strings.Join(existingKeys, ", ") + ", " + } + for _, key := range availableKeys { + suggestions = append(suggestions, prefix+key) + } + return suggestions + } + + // Get available keys and filter based on what's being typed + availableKeys := GetAvailableSSHKeys() + if len(availableKeys) == 0 { + return nil + } + + // Convert to lowercase for case-insensitive matching + searchTerm := strings.ToLower(lastKey) + + // Filter available keys + var filtered []string + prefix := "" + if len(keys) > 1 { + // Join all keys except the last one being typed + existingKeys := keys[:len(keys)-1] + for i := range existingKeys { + existingKeys[i] = strings.TrimSpace(existingKeys[i]) + } + prefix = strings.Join(existingKeys, ", ") + ", " + } + + for _, key := range availableKeys { + lowerKey := strings.ToLower(key) + // Check if the key matches the search term + if strings.Contains(lowerKey, searchTerm) || matchesSequence(lowerKey, searchTerm) { + filtered = append(filtered, prefix+key) + } + } + + // If no matches found, return nil to allow Tab navigation + if len(filtered) == 0 { + return nil + } + + return filtered + } +} + +// createKnownHostsAutocomplete creates an autocomplete function for known_hosts file paths +func (sf *ServerForm) createKnownHostsAutocomplete() func(string) []string { + return func(currentText string) []string { + if currentText == "" { + // Show available known_hosts files when field is empty + availableFiles := GetAvailableKnownHostsFiles() + if len(availableFiles) == 0 { + return nil + } + return availableFiles + } + + // Split by space to handle multiple files + files := strings.Split(currentText, " ") + lastFile := strings.TrimSpace(files[len(files)-1]) + + // If the last file is empty (after a space), show all available files + if lastFile == "" { + availableFiles := GetAvailableKnownHostsFiles() + if len(availableFiles) == 0 { + return nil + } + // Build suggestions with existing files + var suggestions []string + prefix := "" + if len(files) > 1 { + // Join all files except the last empty one + existingFiles := files[:len(files)-1] + for i := range existingFiles { + existingFiles[i] = strings.TrimSpace(existingFiles[i]) + } + prefix = strings.Join(existingFiles, " ") + " " + } + for _, file := range availableFiles { + suggestions = append(suggestions, prefix+file) + } + return suggestions + } + + // Get available files and filter based on what's being typed + availableFiles := GetAvailableKnownHostsFiles() + if len(availableFiles) == 0 { + return nil + } + + // Convert to lowercase for case-insensitive matching + searchTerm := strings.ToLower(lastFile) + + // Filter available files + var filtered []string + prefix := "" + if len(files) > 1 { + // Join all files except the last one being typed + existingFiles := files[:len(files)-1] + for i := range existingFiles { + existingFiles[i] = strings.TrimSpace(existingFiles[i]) + } + prefix = strings.Join(existingFiles, " ") + " " + } + + for _, file := range availableFiles { + lowerFile := strings.ToLower(file) + // Check if the file matches the search term + if strings.Contains(lowerFile, searchTerm) || matchesSequence(lowerFile, searchTerm) { + filtered = append(filtered, prefix+file) + } + } + + // If no matches found, return nil to allow Tab navigation + if len(filtered) == 0 { + return nil + } + + return filtered + } +} + +// createAlgorithmAutocomplete creates an autocomplete function for algorithm input fields +func (sf *ServerForm) createAlgorithmAutocomplete(suggestions []string) func(string) []string { + return func(currentText string) []string { + if currentText == "" { + // Return nil when empty to disable autocomplete, allowing Tab to navigate + return nil + } + + // Find the current word being typed + words := strings.Split(currentText, ",") + lastWord := strings.TrimSpace(words[len(words)-1]) + + // If the last word is empty (after a comma), return nil to allow Tab navigation + if lastWord == "" { + return nil + } + + // Handle prefix characters + prefix := "" + searchTerm := lastWord + if lastWord[0] == '+' || lastWord[0] == '-' || lastWord[0] == '^' { + prefix = string(lastWord[0]) + if len(lastWord) > 1 { + searchTerm = lastWord[1:] + } else { + // Just a prefix character, show all suggestions + searchTerm = "" + } + } + + // Filter suggestions - check if all characters appear in sequence + var filtered []string + for _, s := range suggestions { + if searchTerm == "" || matchesSequence(strings.ToLower(s), strings.ToLower(searchTerm)) { + // Build the complete text with the suggestion + newWords := make([]string, len(words)-1) + copy(newWords, words[:len(words)-1]) + newWords = append(newWords, prefix+s) + filtered = append(filtered, strings.Join(newWords, ",")) + } + } + + // If no matches found, return nil to allow Tab navigation + if len(filtered) == 0 { + return nil + } + + return filtered + } +} + +// validateField validates a single field and updates the validation state +func (sf *ServerForm) validateField(fieldName, value string) string { + fieldValidators := GetFieldValidators() + validator, exists := fieldValidators[fieldName] + if !exists { + // No validator for this field, it's valid + sf.validation.SetError(fieldName, "") + return "" + } + + // Check required + if validator.Required && strings.TrimSpace(value) == "" { + err := fmt.Sprintf("%s is required", fieldName) + sf.validation.SetError(fieldName, err) + return err + } + + // If field is empty and not required, it's valid + if value == "" { + sf.validation.SetError(fieldName, "") + return "" + } + + // Check custom validation function + if validator.Validate != nil { + if err := validator.Validate(value); err != nil { + sf.validation.SetError(fieldName, err.Error()) + return err.Error() + } + } + + // Check regex pattern + if validator.Pattern != nil && !validator.Pattern.MatchString(value) { + sf.validation.SetError(fieldName, validator.Message) + return validator.Message + } + + // Field is valid + sf.validation.SetError(fieldName, "") + return "" +} + +// addDropDownWithHelp adds a dropdown field with help support +func (sf *ServerForm) addDropDownWithHelp(form *tview.Form, label, fieldName string, options []string, initialOption int) { + dropdown := tview.NewDropDown(). + SetLabel(label). + SetOptions(options, nil). + SetCurrentOption(initialOption) + + // Add focus handler to show help + dropdown.SetFocusFunc(func() { + sf.updateHelp(fieldName) + }) + + form.AddFormItem(dropdown) +} + +// addInputFieldWithHelp adds a regular input field with help support +func (sf *ServerForm) addInputFieldWithHelp(form *tview.Form, label, fieldName, defaultValue string, width int, placeholder string) *tview.InputField { + field := tview.NewInputField(). + SetLabel(label). + SetText(defaultValue). + SetFieldWidth(width) + + if placeholder != "" { + field.SetPlaceholder(placeholder) + } + + // Add focus handler to show help + field.SetFocusFunc(func() { + sf.updateHelp(fieldName) + }) + + form.AddFormItem(field) + return field +} + +// addValidatedInputField adds an input field with real-time validation +func (sf *ServerForm) addValidatedInputField(form *tview.Form, label, fieldName, defaultValue string, width int, placeholder string) *tview.InputField { + // Store the original label without color tags + originalLabel := label + + field := tview.NewInputField(). + SetLabel(label). + SetText(defaultValue). + SetFieldWidth(width) + + if placeholder != "" { + field.SetPlaceholder(placeholder) + } + + // Add change handler for real-time validation + field.SetChangedFunc(func(text string) { + if err := sf.validateField(fieldName, text); err != "" { + // Show error in the label with red color + field.SetLabel(fmt.Sprintf("[red]%s[-]", originalLabel)) + } else { + // Clear error indication, restore original label + field.SetLabel(originalLabel) + } + }) + + // Add focus handler to show help + field.SetFocusFunc(func() { + sf.updateHelp(fieldName) + }) + + // Validate on blur (when field loses focus) + field.SetFinishedFunc(func(key tcell.Key) { + sf.validateField(fieldName, field.GetText()) + }) + + form.AddFormItem(field) + return field +} + +// validateAllFields validates all fields in the current form +func (sf *ServerForm) validateAllFields() bool { + // Clear all previous errors first + sf.validation = NewValidationState() + + data := sf.getFormData() + + // Validate each field based on form data + // Don't return early - validate all fields + sf.validateField("Alias", data.Alias) + sf.validateField("Host", data.Host) + sf.validateField("Port", data.Port) + sf.validateField("User", data.User) + sf.validateField("Keys", data.Key) + sf.validateField("Tags", data.Tags) + + // Connection fields + sf.validateField("ConnectTimeout", data.ConnectTimeout) + sf.validateField("ConnectionAttempts", data.ConnectionAttempts) + sf.validateField("ServerAliveInterval", data.ServerAliveInterval) + sf.validateField("ServerAliveCountMax", data.ServerAliveCountMax) + sf.validateField("IPQoS", data.IPQoS) + sf.validateField("BindAddress", data.BindAddress) + + // Port forwarding fields + sf.validateField("LocalForward", data.LocalForward) + sf.validateField("RemoteForward", data.RemoteForward) + sf.validateField("DynamicForward", data.DynamicForward) + + // Authentication fields + sf.validateField("NumberOfPasswordPrompts", data.NumberOfPasswordPrompts) + + // Advanced fields + sf.validateField("CanonicalizeMaxDots", data.CanonicalizeMaxDots) + sf.validateField("EscapeChar", data.EscapeChar) + + // Security fields + sf.validateField("UserKnownHostsFile", data.UserKnownHostsFile) + + return !sf.validation.HasErrors() +} + +// getDefaultValues returns default form values based on mode +func (sf *ServerForm) getDefaultValues() ServerFormData { + if sf.mode == ServerFormEdit && sf.original != nil { + return ServerFormData{ + Alias: sf.original.Alias, + Host: sf.original.Host, + User: sf.original.User, + Port: fmt.Sprint(sf.original.Port), + Key: strings.Join(sf.original.IdentityFiles, ", "), + Tags: strings.Join(sf.original.Tags, ", "), + ProxyJump: sf.original.ProxyJump, + ProxyCommand: sf.original.ProxyCommand, + RemoteCommand: sf.original.RemoteCommand, + RequestTTY: sf.original.RequestTTY, + SessionType: sf.original.SessionType, + ConnectTimeout: sf.original.ConnectTimeout, + ConnectionAttempts: sf.original.ConnectionAttempts, + BindAddress: sf.original.BindAddress, + BindInterface: sf.original.BindInterface, + AddressFamily: sf.original.AddressFamily, + ExitOnForwardFailure: sf.original.ExitOnForwardFailure, + IPQoS: sf.original.IPQoS, + // Hostname canonicalization + CanonicalizeHostname: sf.original.CanonicalizeHostname, + CanonicalDomains: sf.original.CanonicalDomains, + CanonicalizeFallbackLocal: sf.original.CanonicalizeFallbackLocal, + CanonicalizeMaxDots: sf.original.CanonicalizeMaxDots, + CanonicalizePermittedCNAMEs: sf.original.CanonicalizePermittedCNAMEs, + GatewayPorts: sf.original.GatewayPorts, + LocalForward: strings.Join(sf.original.LocalForward, ", "), + RemoteForward: strings.Join(sf.original.RemoteForward, ", "), + DynamicForward: strings.Join(sf.original.DynamicForward, ", "), + ClearAllForwardings: sf.original.ClearAllForwardings, + // Public key + PubkeyAuthentication: sf.original.PubkeyAuthentication, + IdentitiesOnly: sf.original.IdentitiesOnly, + // SSH Agent + AddKeysToAgent: sf.original.AddKeysToAgent, + IdentityAgent: sf.original.IdentityAgent, + // Password & Interactive + PasswordAuthentication: sf.original.PasswordAuthentication, + KbdInteractiveAuthentication: sf.original.KbdInteractiveAuthentication, + NumberOfPasswordPrompts: sf.original.NumberOfPasswordPrompts, + // Advanced + PreferredAuthentications: sf.original.PreferredAuthentications, + ForwardAgent: sf.original.ForwardAgent, + ForwardX11: sf.original.ForwardX11, + ForwardX11Trusted: sf.original.ForwardX11Trusted, + ControlMaster: sf.original.ControlMaster, + ControlPath: sf.original.ControlPath, + ControlPersist: sf.original.ControlPersist, + ServerAliveInterval: sf.original.ServerAliveInterval, + ServerAliveCountMax: sf.original.ServerAliveCountMax, + Compression: sf.original.Compression, + TCPKeepAlive: sf.original.TCPKeepAlive, + BatchMode: sf.original.BatchMode, + StrictHostKeyChecking: sf.original.StrictHostKeyChecking, + UserKnownHostsFile: sf.original.UserKnownHostsFile, + HostKeyAlgorithms: sf.original.HostKeyAlgorithms, + PubkeyAcceptedAlgorithms: sf.original.PubkeyAcceptedAlgorithms, + HostbasedAcceptedAlgorithms: sf.original.HostbasedAcceptedAlgorithms, + MACs: sf.original.MACs, + Ciphers: sf.original.Ciphers, + KexAlgorithms: sf.original.KexAlgorithms, + VerifyHostKeyDNS: sf.original.VerifyHostKeyDNS, + UpdateHostKeys: sf.original.UpdateHostKeys, + HashKnownHosts: sf.original.HashKnownHosts, + VisualHostKey: sf.original.VisualHostKey, + LocalCommand: sf.original.LocalCommand, + PermitLocalCommand: sf.original.PermitLocalCommand, + EscapeChar: sf.original.EscapeChar, + SendEnv: strings.Join(sf.original.SendEnv, ", "), + SetEnv: strings.Join(sf.original.SetEnv, ", "), + LogLevel: sf.original.LogLevel, + } + } + // For new servers, use empty values instead of SSH defaults + // SSH defaults will be applied by the SSH client if values are not specified + return ServerFormData{ + Alias: "", // Explicitly empty for new servers + Host: "", // Explicitly empty for new servers + User: "", // Empty for new servers (SSH will use current username) + Port: "22", // Keep port 22 as it's the standard SSH port + Key: "", // Empty for new servers (SSH will try default keys) + Tags: "", + + // All other fields should be empty for new servers + // The SSH client will use its defaults when these are not specified + ProxyJump: "", + ProxyCommand: "", + RemoteCommand: "", + RequestTTY: "", + SessionType: "", + ConnectTimeout: "", + ConnectionAttempts: "", + BindAddress: "", + BindInterface: "", + AddressFamily: "", + ExitOnForwardFailure: "", + IPQoS: "", + + // Hostname canonicalization + CanonicalizeHostname: "", + CanonicalDomains: "", + CanonicalizeFallbackLocal: "", + CanonicalizeMaxDots: "", + CanonicalizePermittedCNAMEs: "", + + // Port forwarding + LocalForward: "", + RemoteForward: "", + DynamicForward: "", + ClearAllForwardings: "", + GatewayPorts: "", + + // Authentication + PubkeyAuthentication: "", + IdentitiesOnly: "", + AddKeysToAgent: "", + IdentityAgent: "", + PasswordAuthentication: "", + KbdInteractiveAuthentication: "", + NumberOfPasswordPrompts: "", + PreferredAuthentications: "", + PubkeyAcceptedAlgorithms: "", + HostbasedAcceptedAlgorithms: "", + + // Forwarding + ForwardAgent: "", + ForwardX11: "", + ForwardX11Trusted: "", + + // Multiplexing + ControlMaster: "", + ControlPath: "", + ControlPersist: "", + + // Keep-alive + ServerAliveInterval: "", + ServerAliveCountMax: "", + TCPKeepAlive: "", + + // Connection + Compression: "", + BatchMode: "", + + // Security + StrictHostKeyChecking: "", + CheckHostIP: "", + FingerprintHash: "", + UserKnownHostsFile: "", + HostKeyAlgorithms: "", + MACs: "", + Ciphers: "", + KexAlgorithms: "", + VerifyHostKeyDNS: "", + UpdateHostKeys: "", + HashKnownHosts: "", + VisualHostKey: "", + + // Command execution + LocalCommand: "", + PermitLocalCommand: "", + EscapeChar: "", + + // Environment + SendEnv: "", + SetEnv: "", + + // Debugging + LogLevel: "", + } +} + +// createBasicForm creates the Basic configuration tab +func (sf *ServerForm) createBasicForm() { + form := tview.NewForm() + defaultValues := sf.getDefaultValues() + + // Add validated input fields + sf.addValidatedInputField(form, "Alias:", "Alias", defaultValues.Alias, 20, GetFieldPlaceholder("Alias")) + sf.addValidatedInputField(form, "Host/IP:", "Host", defaultValues.Host, 20, GetFieldPlaceholder("Host")) + sf.addValidatedInputField(form, "User:", "User", defaultValues.User, 20, GetFieldPlaceholder("User")) + sf.addValidatedInputField(form, "Port:", "Port", defaultValues.Port, 20, GetFieldPlaceholder("Port")) + + // Keys field with autocomplete + keysField := sf.addValidatedInputField(form, "Keys:", "Keys", defaultValues.Key, 40, GetFieldPlaceholder("Keys")) + keysField.SetAutocompleteFunc(sf.createSSHKeyAutocomplete()) + + // Tags field + sf.addValidatedInputField(form, "Tags:", "Tags", defaultValues.Tags, 30, GetFieldPlaceholder("Tags")) + + // Add save and cancel buttons + form.AddButton("Save", sf.handleSaveButton) + form.AddButton("Cancel", sf.handleCancel) + + // Set up form-level input capture for shortcuts + sf.setupFormShortcuts(form) + + sf.forms["Basic"] = form + sf.pages.AddPage("Basic", form, true, true) +} + +// createConnectionForm creates the Connection & Proxy tab +func (sf *ServerForm) createConnectionForm() { + form := tview.NewForm() + defaultValues := sf.getDefaultValues() + + form.AddTextView("\n[yellow]β–Ά Proxy & Command[-]", "", 0, 1, true, false) + sf.addInputFieldWithHelp(form, "ProxyJump:", "ProxyJump", defaultValues.ProxyJump, 40, GetFieldPlaceholder("ProxyJump")) + sf.addInputFieldWithHelp(form, "ProxyCommand:", "ProxyCommand", defaultValues.ProxyCommand, 40, GetFieldPlaceholder("ProxyCommand")) + sf.addInputFieldWithHelp(form, "RemoteCommand:", "RemoteCommand", defaultValues.RemoteCommand, 40, GetFieldPlaceholder("RemoteCommand")) + + // RequestTTY dropdown + requestTTYOptions := createOptionsWithDefault("RequestTTY", []string{"", "yes", "no", "force", "auto"}) + requestTTYIndex := sf.findOptionIndex(requestTTYOptions, defaultValues.RequestTTY) + sf.addDropDownWithHelp(form, "RequestTTY:", "RequestTTY", requestTTYOptions, requestTTYIndex) + + // SessionType dropdown (OpenSSH 8.7+) + sessionTypeOptions := createOptionsWithDefault("SessionType", []string{"", "none (-N)", "subsystem (-s)", "default"}) + sessionTypeIndex := sf.findOptionIndex(sessionTypeOptions, defaultValues.SessionType) + sf.addDropDownWithHelp(form, "SessionType:", "SessionType", sessionTypeOptions, sessionTypeIndex) + + form.AddTextView("\n[yellow]β–Ά Connection Settings[-]", "", 0, 1, true, false) + sf.addValidatedInputField(form, "ConnectTimeout:", "ConnectTimeout", defaultValues.ConnectTimeout, 10, GetFieldPlaceholder("ConnectTimeout")) + sf.addValidatedInputField(form, "ConnectionAttempts:", "ConnectionAttempts", defaultValues.ConnectionAttempts, 10, GetFieldPlaceholder("ConnectionAttempts")) + sf.addValidatedInputField(form, "IPQoS:", "IPQoS", defaultValues.IPQoS, 20, GetFieldPlaceholder("IPQoS")) + + // BatchMode dropdown (moved from Keep-Alive) + batchModeOptions := createOptionsWithDefault("BatchMode", []string{"", "yes", "no"}) + batchModeIndex := sf.findOptionIndex(batchModeOptions, defaultValues.BatchMode) + sf.addDropDownWithHelp(form, "BatchMode:", "BatchMode", batchModeOptions, batchModeIndex) + + form.AddTextView("\n[yellow]β–Ά Bind Options[-]", "", 0, 1, true, false) + sf.addValidatedInputField(form, "BindAddress:", "BindAddress", defaultValues.BindAddress, 40, GetFieldPlaceholder("BindAddress")) + + // BindInterface dropdown with available network interfaces + interfaceOptions := append([]string{""}, GetNetworkInterfaces()...) + bindInterfaceIndex := sf.findOptionIndex(interfaceOptions, defaultValues.BindInterface) + sf.addDropDownWithHelp(form, "BindInterface:", "BindInterface", interfaceOptions, bindInterfaceIndex) + + // AddressFamily dropdown + addressFamilyOptions := createOptionsWithDefault("AddressFamily", []string{"", "any", "inet", "inet6"}) + addressFamilyIndex := sf.findOptionIndex(addressFamilyOptions, defaultValues.AddressFamily) + sf.addDropDownWithHelp(form, "AddressFamily:", "AddressFamily", addressFamilyOptions, addressFamilyIndex) + + form.AddTextView("\n[yellow]β–Ά Hostname Canonicalization[-]", "", 0, 1, true, false) + + // CanonicalizeHostname dropdown + canonicalizeOptions := createOptionsWithDefault("CanonicalizeHostname", []string{"", "yes", "no", "always"}) + canonicalizeIndex := sf.findOptionIndex(canonicalizeOptions, defaultValues.CanonicalizeHostname) + sf.addDropDownWithHelp(form, "CanonicalizeHostname:", "CanonicalizeHostname", canonicalizeOptions, canonicalizeIndex) + + sf.addInputFieldWithHelp(form, "CanonicalDomains:", "CanonicalDomains", defaultValues.CanonicalDomains, 40, GetFieldPlaceholder("CanonicalDomains")) + + // CanonicalizeFallbackLocal dropdown + fallbackOptions := createOptionsWithDefault("CanonicalizeFallbackLocal", []string{"", "yes", "no"}) + fallbackIndex := sf.findOptionIndex(fallbackOptions, defaultValues.CanonicalizeFallbackLocal) + sf.addDropDownWithHelp(form, "CanonicalizeFallbackLocal:", "CanonicalizeFallbackLocal", fallbackOptions, fallbackIndex) + + sf.addValidatedInputField(form, "CanonicalizeMaxDots:", "CanonicalizeMaxDots", defaultValues.CanonicalizeMaxDots, 10, GetFieldPlaceholder("CanonicalizeMaxDots")) + + sf.addInputFieldWithHelp(form, "CanonicalizePermittedCNAMEs:", "CanonicalizePermittedCNAMEs", defaultValues.CanonicalizePermittedCNAMEs, 40, GetFieldPlaceholder("CanonicalizePermittedCNAMEs")) + + form.AddTextView("\n[yellow]β–Ά Keep-Alive[-]", "", 0, 1, true, false) + sf.addValidatedInputField(form, "ServerAliveInterval:", "ServerAliveInterval", defaultValues.ServerAliveInterval, 10, GetFieldPlaceholder("ServerAliveInterval")) + sf.addValidatedInputField(form, "ServerAliveCountMax:", "ServerAliveCountMax", defaultValues.ServerAliveCountMax, 10, GetFieldPlaceholder("ServerAliveCountMax")) + + // Compression dropdown + compressionOptions := createOptionsWithDefault("Compression", []string{"", "yes", "no"}) + compressionIndex := sf.findOptionIndex(compressionOptions, defaultValues.Compression) + sf.addDropDownWithHelp(form, "Compression:", "Compression", compressionOptions, compressionIndex) + + // TCPKeepAlive dropdown + tcpKeepAliveOptions := createOptionsWithDefault("TCPKeepAlive", []string{"", "yes", "no"}) + tcpKeepAliveIndex := sf.findOptionIndex(tcpKeepAliveOptions, defaultValues.TCPKeepAlive) + sf.addDropDownWithHelp(form, "TCPKeepAlive:", "TCPKeepAlive", tcpKeepAliveOptions, tcpKeepAliveIndex) + + form.AddTextView("\n[yellow]β–Ά Multiplexing[-]", "", 0, 1, true, false) + // ControlMaster dropdown + controlMasterOptions := createOptionsWithDefault("ControlMaster", []string{"", "yes", "no", "auto", "ask", "autoask"}) + controlMasterIndex := sf.findOptionIndex(controlMasterOptions, defaultValues.ControlMaster) + sf.addDropDownWithHelp(form, "ControlMaster:", "ControlMaster", controlMasterOptions, controlMasterIndex) + sf.addInputFieldWithHelp(form, "ControlPath:", "ControlPath", defaultValues.ControlPath, 40, GetFieldPlaceholder("ControlPath")) + sf.addInputFieldWithHelp(form, "ControlPersist:", "ControlPersist", defaultValues.ControlPersist, 20, GetFieldPlaceholder("ControlPersist")) + + // Add save and cancel buttons + form.AddButton("Save", sf.handleSaveButton) + form.AddButton("Cancel", sf.handleCancel) + + // Set up form-level input capture for shortcuts + sf.setupFormShortcuts(form) + + sf.forms["Connection"] = form + sf.pages.AddPage("Connection", form, true, false) +} + +// createForwardingForm creates the Port Forwarding tab +func (sf *ServerForm) createForwardingForm() { + form := tview.NewForm() + defaultValues := sf.getDefaultValues() + + form.AddTextView("\n[yellow]β–Ά Port Forwarding[-]", "", 0, 1, true, false) + sf.addValidatedInputField(form, "LocalForward:", "LocalForward", defaultValues.LocalForward, 40, GetFieldPlaceholder("LocalForward")) + sf.addValidatedInputField(form, "RemoteForward:", "RemoteForward", defaultValues.RemoteForward, 40, GetFieldPlaceholder("RemoteForward")) + sf.addValidatedInputField(form, "DynamicForward:", "DynamicForward", defaultValues.DynamicForward, 40, GetFieldPlaceholder("DynamicForward")) + + // ClearAllForwardings dropdown + clearAllForwardingsOptions := createOptionsWithDefault("ClearAllForwardings", []string{"", "yes", "no"}) + clearAllForwardingsIndex := sf.findOptionIndex(clearAllForwardingsOptions, defaultValues.ClearAllForwardings) + sf.addDropDownWithHelp(form, "ClearAllForwardings:", "ClearAllForwardings", clearAllForwardingsOptions, clearAllForwardingsIndex) + + // ExitOnForwardFailure dropdown + exitOnForwardFailureOptions := createOptionsWithDefault("ExitOnForwardFailure", []string{"", "yes", "no"}) + exitOnForwardFailureIndex := sf.findOptionIndex(exitOnForwardFailureOptions, defaultValues.ExitOnForwardFailure) + sf.addDropDownWithHelp(form, "ExitOnForwardFailure:", "ExitOnForwardFailure", exitOnForwardFailureOptions, exitOnForwardFailureIndex) + + // GatewayPorts dropdown + gatewayPortsOptions := createOptionsWithDefault("GatewayPorts", []string{"", "yes", "no", "clientspecified"}) + gatewayPortsIndex := sf.findOptionIndex(gatewayPortsOptions, defaultValues.GatewayPorts) + sf.addDropDownWithHelp(form, "GatewayPorts:", "GatewayPorts", gatewayPortsOptions, gatewayPortsIndex) + + form.AddTextView("\n[yellow]β–Ά Agent & X11 Forwarding[-]", "", 0, 1, true, false) + + // ForwardAgent dropdown + forwardAgentOptions := createOptionsWithDefault("ForwardAgent", []string{"", "yes", "no"}) + forwardAgentIndex := sf.findOptionIndex(forwardAgentOptions, defaultValues.ForwardAgent) + sf.addDropDownWithHelp(form, "ForwardAgent:", "ForwardAgent", forwardAgentOptions, forwardAgentIndex) + + // ForwardX11 dropdown + forwardX11Options := createOptionsWithDefault("ForwardX11", []string{"", "yes", "no"}) + forwardX11Index := sf.findOptionIndex(forwardX11Options, defaultValues.ForwardX11) + sf.addDropDownWithHelp(form, "ForwardX11:", "ForwardX11", forwardX11Options, forwardX11Index) + + // ForwardX11Trusted dropdown + forwardX11TrustedOptions := createOptionsWithDefault("ForwardX11Trusted", []string{"", "yes", "no"}) + forwardX11TrustedIndex := sf.findOptionIndex(forwardX11TrustedOptions, defaultValues.ForwardX11Trusted) + sf.addDropDownWithHelp(form, "ForwardX11Trusted:", "ForwardX11Trusted", forwardX11TrustedOptions, forwardX11TrustedIndex) + + // Add save and cancel buttons + form.AddButton("Save", sf.handleSaveButton) + form.AddButton("Cancel", sf.handleCancel) + + // Set up form-level input capture for shortcuts + sf.setupFormShortcuts(form) + + sf.forms["Forwarding"] = form + sf.pages.AddPage("Forwarding", form, true, false) +} + +// Algorithm suggestions for autocomplete +var ( + pubkeyAlgorithms = []string{ + "ssh-ed25519", "ssh-ed25519-cert-v01@openssh.com", + "sk-ssh-ed25519@openssh.com", "sk-ssh-ed25519-cert-v01@openssh.com", + "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", + "ecdsa-sha2-nistp256-cert-v01@openssh.com", + "ecdsa-sha2-nistp384-cert-v01@openssh.com", + "ecdsa-sha2-nistp521-cert-v01@openssh.com", + "sk-ecdsa-sha2-nistp256@openssh.com", + "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", + "rsa-sha2-512", "rsa-sha2-256", + "rsa-sha2-512-cert-v01@openssh.com", + "rsa-sha2-256-cert-v01@openssh.com", + "ssh-rsa", "ssh-rsa-cert-v01@openssh.com", + "ssh-dss", "ssh-dss-cert-v01@openssh.com", + } + + cipherAlgorithms = []string{ + "aes128-ctr", "aes192-ctr", "aes256-ctr", + "aes128-gcm@openssh.com", "aes256-gcm@openssh.com", + "chacha20-poly1305@openssh.com", + "aes128-cbc", "aes192-cbc", "aes256-cbc", "3des-cbc", + } + + macAlgorithms = []string{ + "hmac-sha2-256", "hmac-sha2-512", + "hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com", + "umac-64@openssh.com", "umac-128@openssh.com", + "umac-64-etm@openssh.com", "umac-128-etm@openssh.com", + "hmac-sha1", "hmac-sha1-96", + "hmac-sha1-etm@openssh.com", "hmac-sha1-96-etm@openssh.com", + "hmac-md5", "hmac-md5-96", + "hmac-md5-etm@openssh.com", "hmac-md5-96-etm@openssh.com", + } + + kexAlgorithms = []string{ + "curve25519-sha256", "curve25519-sha256@libssh.org", + "ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521", + "diffie-hellman-group-exchange-sha256", + "diffie-hellman-group16-sha512", "diffie-hellman-group18-sha512", + "diffie-hellman-group14-sha256", "diffie-hellman-group14-sha1", + "diffie-hellman-group-exchange-sha1", + "diffie-hellman-group1-sha1", + } + + hostKeyAlgorithms = []string{ + "ssh-ed25519", "ssh-ed25519-cert-v01@openssh.com", + "sk-ssh-ed25519@openssh.com", "sk-ssh-ed25519-cert-v01@openssh.com", + "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", + "ecdsa-sha2-nistp256-cert-v01@openssh.com", + "ecdsa-sha2-nistp384-cert-v01@openssh.com", + "ecdsa-sha2-nistp521-cert-v01@openssh.com", + "rsa-sha2-512", "rsa-sha2-256", + "rsa-sha2-512-cert-v01@openssh.com", + "rsa-sha2-256-cert-v01@openssh.com", + "ssh-rsa", "ssh-rsa-cert-v01@openssh.com", + "ssh-dss", "ssh-dss-cert-v01@openssh.com", + } +) + +// createAuthenticationForm creates the Authentication tab +func (sf *ServerForm) createAuthenticationForm() { + form := tview.NewForm() + defaultValues := sf.getDefaultValues() + + // Most common: Public key authentication + form.AddTextView("\n[yellow]β–Ά Public Key Authentication[-]", "", 0, 1, true, false) + + // PubkeyAuthentication dropdown + pubkeyOptions := createOptionsWithDefault("PubkeyAuthentication", []string{"", "yes", "no"}) + pubkeyIndex := sf.findOptionIndex(pubkeyOptions, defaultValues.PubkeyAuthentication) + sf.addDropDownWithHelp(form, "PubkeyAuthentication:", "PubkeyAuthentication", pubkeyOptions, pubkeyIndex) + + // IdentitiesOnly dropdown - controls whether to use only specified identity files + identitiesOnlyOptions := createOptionsWithDefault("IdentitiesOnly", []string{"", "yes", "no"}) + identitiesOnlyIndex := sf.findOptionIndex(identitiesOnlyOptions, defaultValues.IdentitiesOnly) + sf.addDropDownWithHelp(form, "IdentitiesOnly:", "IdentitiesOnly", identitiesOnlyOptions, identitiesOnlyIndex) + + // SSH Agent settings + form.AddTextView("\n[yellow]β–Ά SSH Agent[-]", "", 0, 1, true, false) + + // AddKeysToAgent dropdown + addKeysOptions := createOptionsWithDefault("AddKeysToAgent", []string{"", "yes", "no", "ask", "confirm"}) + addKeysIndex := sf.findOptionIndex(addKeysOptions, defaultValues.AddKeysToAgent) + sf.addDropDownWithHelp(form, "AddKeysToAgent:", "AddKeysToAgent", addKeysOptions, addKeysIndex) + + sf.addInputFieldWithHelp(form, "IdentityAgent:", "IdentityAgent", defaultValues.IdentityAgent, 40, GetFieldPlaceholder("IdentityAgent")) + + // Password/Interactive authentication + form.AddTextView("\n[yellow]β–Ά Password & Interactive[-]", "", 0, 1, true, false) + + // PasswordAuthentication dropdown + passwordOptions := createOptionsWithDefault("PasswordAuthentication", []string{"", "yes", "no"}) + passwordIndex := sf.findOptionIndex(passwordOptions, defaultValues.PasswordAuthentication) + sf.addDropDownWithHelp(form, "PasswordAuthentication:", "PasswordAuthentication", passwordOptions, passwordIndex) + + // KbdInteractiveAuthentication dropdown + kbdInteractiveOptions := createOptionsWithDefault("KbdInteractiveAuthentication", []string{"", "yes", "no"}) + kbdInteractiveIndex := sf.findOptionIndex(kbdInteractiveOptions, defaultValues.KbdInteractiveAuthentication) + sf.addDropDownWithHelp(form, "KbdInteractiveAuthentication:", "KbdInteractiveAuthentication", kbdInteractiveOptions, kbdInteractiveIndex) + + // NumberOfPasswordPrompts field + sf.addValidatedInputField(form, "NumberOfPasswordPrompts:", "NumberOfPasswordPrompts", defaultValues.NumberOfPasswordPrompts, 10, GetFieldPlaceholder("NumberOfPasswordPrompts")) + + // Advanced: Authentication order preference + form.AddTextView("\n[yellow]β–Ά Advanced[-]", "", 0, 1, true, false) + + sf.addInputFieldWithHelp(form, "PreferredAuthentications:", "PreferredAuthentications", defaultValues.PreferredAuthentications, 40, GetFieldPlaceholder("PreferredAuthentications")) + + // PubkeyAcceptedAlgorithms with autocomplete support (moved from Advanced/Cryptography) + pubkeyAlgField := sf.addInputFieldWithHelp(form, "PubkeyAcceptedAlgorithms:", "PubkeyAcceptedAlgorithms", defaultValues.PubkeyAcceptedAlgorithms, 40, GetFieldPlaceholder("PubkeyAcceptedAlgorithms")) + pubkeyAlgField.SetAutocompleteFunc(sf.createAlgorithmAutocomplete(pubkeyAlgorithms)) + + // HostbasedAcceptedAlgorithms with autocomplete support (moved from Advanced/Cryptography) + hostbasedAlgField := sf.addInputFieldWithHelp(form, "HostbasedAcceptedAlgorithms:", "HostbasedAcceptedAlgorithms", defaultValues.HostbasedAcceptedAlgorithms, 40, GetFieldPlaceholder("HostbasedAcceptedAlgorithms")) + hostbasedAlgField.SetAutocompleteFunc(sf.createAlgorithmAutocomplete(pubkeyAlgorithms)) + + // Add save and cancel buttons + form.AddButton("Save", sf.handleSaveButton) + form.AddButton("Cancel", sf.handleCancel) + + // Set up form-level input capture for shortcuts + sf.setupFormShortcuts(form) + + sf.forms["Authentication"] = form + sf.pages.AddPage("Authentication", form, true, false) +} + +// createAdvancedForm creates the Advanced settings tab +func (sf *ServerForm) createAdvancedForm() { + form := tview.NewForm() + defaultValues := sf.getDefaultValues() + + form.AddTextView("\n[yellow]β–Ά Security[-]", "", 0, 1, true, false) + + // StrictHostKeyChecking dropdown + strictHostKeyOptions := createOptionsWithDefault("StrictHostKeyChecking", []string{"", "yes", "no", "ask", "accept-new"}) + strictHostKeyIndex := sf.findOptionIndex(strictHostKeyOptions, defaultValues.StrictHostKeyChecking) + sf.addDropDownWithHelp(form, "StrictHostKeyChecking:", "StrictHostKeyChecking", strictHostKeyOptions, strictHostKeyIndex) + + // CheckHostIP dropdown + checkHostIPOptions := createOptionsWithDefault("CheckHostIP", []string{"", "yes", "no"}) + checkHostIPIndex := sf.findOptionIndex(checkHostIPOptions, defaultValues.CheckHostIP) + sf.addDropDownWithHelp(form, "CheckHostIP:", "CheckHostIP", checkHostIPOptions, checkHostIPIndex) + + // FingerprintHash dropdown + fingerprintHashOptions := createOptionsWithDefault("FingerprintHash", []string{"", "md5", "sha256"}) + fingerprintHashIndex := sf.findOptionIndex(fingerprintHashOptions, defaultValues.FingerprintHash) + sf.addDropDownWithHelp(form, "FingerprintHash:", "FingerprintHash", fingerprintHashOptions, fingerprintHashIndex) + + // VerifyHostKeyDNS dropdown + verifyHostKeyDNSOptions := createOptionsWithDefault("VerifyHostKeyDNS", []string{"", "yes", "no", "ask"}) + verifyHostKeyDNSIndex := sf.findOptionIndex(verifyHostKeyDNSOptions, defaultValues.VerifyHostKeyDNS) + sf.addDropDownWithHelp(form, "VerifyHostKeyDNS:", "VerifyHostKeyDNS", verifyHostKeyDNSOptions, verifyHostKeyDNSIndex) + + // UpdateHostKeys dropdown + updateHostKeysOptions := createOptionsWithDefault("UpdateHostKeys", []string{"", "yes", "no", "ask"}) + updateHostKeysIndex := sf.findOptionIndex(updateHostKeysOptions, defaultValues.UpdateHostKeys) + sf.addDropDownWithHelp(form, "UpdateHostKeys:", "UpdateHostKeys", updateHostKeysOptions, updateHostKeysIndex) + + // HashKnownHosts dropdown + hashKnownHostsOptions := createOptionsWithDefault("HashKnownHosts", []string{"", "yes", "no"}) + hashKnownHostsIndex := sf.findOptionIndex(hashKnownHostsOptions, defaultValues.HashKnownHosts) + sf.addDropDownWithHelp(form, "HashKnownHosts:", "HashKnownHosts", hashKnownHostsOptions, hashKnownHostsIndex) + + // VisualHostKey dropdown + visualHostKeyOptions := createOptionsWithDefault("VisualHostKey", []string{"", "yes", "no"}) + visualHostKeyIndex := sf.findOptionIndex(visualHostKeyOptions, defaultValues.VisualHostKey) + sf.addDropDownWithHelp(form, "VisualHostKey:", "VisualHostKey", visualHostKeyOptions, visualHostKeyIndex) + + // UserKnownHostsFile field with autocomplete and validation + knownHostsField := sf.addValidatedInputField(form, "UserKnownHostsFile:", "UserKnownHostsFile", defaultValues.UserKnownHostsFile, 40, GetFieldPlaceholder("UserKnownHostsFile")) + knownHostsField.SetAutocompleteFunc(sf.createKnownHostsAutocomplete()) + + form.AddTextView("\n[yellow]β–Ά Cryptography[-]", "", 0, 1, true, false) + + // Ciphers with autocomplete support + ciphersField := sf.addInputFieldWithHelp(form, "Ciphers:", "Ciphers", defaultValues.Ciphers, 40, GetFieldPlaceholder("Ciphers")) + ciphersField.SetAutocompleteFunc(sf.createAlgorithmAutocomplete(cipherAlgorithms)) + + // MACs with autocomplete support + macsField := sf.addInputFieldWithHelp(form, "MACs:", "MACs", defaultValues.MACs, 40, GetFieldPlaceholder("MACs")) + macsField.SetAutocompleteFunc(sf.createAlgorithmAutocomplete(macAlgorithms)) + + // KexAlgorithms with autocomplete support + kexField := sf.addInputFieldWithHelp(form, "KexAlgorithms:", "KexAlgorithms", defaultValues.KexAlgorithms, 40, GetFieldPlaceholder("KexAlgorithms")) + kexField.SetAutocompleteFunc(sf.createAlgorithmAutocomplete(kexAlgorithms)) + + // HostKeyAlgorithms with autocomplete support + hostKeyField := sf.addInputFieldWithHelp(form, "HostKeyAlgorithms:", "HostKeyAlgorithms", defaultValues.HostKeyAlgorithms, 40, GetFieldPlaceholder("HostKeyAlgorithms")) + hostKeyField.SetAutocompleteFunc(sf.createAlgorithmAutocomplete(hostKeyAlgorithms)) + + form.AddTextView("\n[yellow]β–Ά Command Execution[-]", "", 0, 1, true, false) + sf.addInputFieldWithHelp(form, "LocalCommand:", "LocalCommand", defaultValues.LocalCommand, 40, GetFieldPlaceholder("LocalCommand")) + + // PermitLocalCommand dropdown + permitLocalCommandOptions := createOptionsWithDefault("PermitLocalCommand", []string{"", "yes", "no"}) + permitLocalCommandIndex := sf.findOptionIndex(permitLocalCommandOptions, defaultValues.PermitLocalCommand) + sf.addDropDownWithHelp(form, "PermitLocalCommand:", "PermitLocalCommand", permitLocalCommandOptions, permitLocalCommandIndex) + + // EscapeChar input field + sf.addValidatedInputField(form, "EscapeChar:", "EscapeChar", defaultValues.EscapeChar, 10, GetFieldPlaceholder("EscapeChar")) + + form.AddTextView("\n[yellow]β–Ά Environment[-]", "", 0, 1, true, false) + sf.addInputFieldWithHelp(form, "SendEnv:", "SendEnv", defaultValues.SendEnv, 40, GetFieldPlaceholder("SendEnv")) + sf.addInputFieldWithHelp(form, "SetEnv:", "SetEnv", defaultValues.SetEnv, 40, GetFieldPlaceholder("SetEnv")) + + form.AddTextView("\n[yellow]β–Ά Debugging[-]", "", 0, 1, true, false) + + // LogLevel dropdown + logLevelOptions := createOptionsWithDefault("LogLevel", []string{"", "QUIET", "FATAL", "ERROR", "INFO", "VERBOSE", "DEBUG", "DEBUG1", "DEBUG2", "DEBUG3"}) + logLevelIndex := sf.findOptionIndex(logLevelOptions, defaultValues.LogLevel) + sf.addDropDownWithHelp(form, "LogLevel:", "LogLevel", logLevelOptions, logLevelIndex) + + // Add save and cancel buttons + form.AddButton("Save", sf.handleSaveButton) + form.AddButton("Cancel", sf.handleCancel) + + // Set up form-level input capture for shortcuts + sf.setupFormShortcuts(form) + + sf.forms["Advanced"] = form + sf.pages.AddPage("Advanced", form, true, false) } type ServerFormData struct { @@ -108,44 +1645,500 @@ type ServerFormData struct { Port string Key string Tags string + + // Connection and proxy settings + ProxyJump string + ProxyCommand string + RemoteCommand string + RequestTTY string + SessionType string + ConnectTimeout string + ConnectionAttempts string + BindAddress string + BindInterface string + AddressFamily string + ExitOnForwardFailure string + IPQoS string + // Hostname canonicalization + CanonicalizeHostname string + CanonicalDomains string + CanonicalizeFallbackLocal string + CanonicalizeMaxDots string + CanonicalizePermittedCNAMEs string + + // Port forwarding + LocalForward string + RemoteForward string + DynamicForward string + ClearAllForwardings string + GatewayPorts string + + // Authentication and key management + // Public key + PubkeyAuthentication string + IdentitiesOnly string + // SSH Agent + AddKeysToAgent string + IdentityAgent string + // Password & Interactive + PasswordAuthentication string + KbdInteractiveAuthentication string + 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 + ServerAliveInterval string + ServerAliveCountMax string + Compression string + TCPKeepAlive string + BatchMode string + + // Security settings + StrictHostKeyChecking string + CheckHostIP string + FingerprintHash string + UserKnownHostsFile string + HostKeyAlgorithms string + PubkeyAcceptedAlgorithms string + HostbasedAcceptedAlgorithms string + MACs string + Ciphers string + KexAlgorithms string + VerifyHostKeyDNS string + UpdateHostKeys string + HashKnownHosts string + VisualHostKey string + + // Command execution + LocalCommand string + PermitLocalCommand string + EscapeChar string + + // Environment settings + SendEnv string + SetEnv string + + // Debugging settings + LogLevel string } +// stripColorTags removes tview color tags from a string +// e.g., "[red]Port:[-]" becomes "Port:" func (sf *ServerForm) getFormData() ServerFormData { + // Helper function to get text from InputField across all forms + getFieldText := func(fieldName string) string { + for _, form := range sf.forms { + for i := 0; i < form.GetFormItemCount(); i++ { + if field, ok := form.GetFormItem(i).(*tview.InputField); ok { + label := strings.TrimSpace(field.GetLabel()) + // Strip color tags from label for comparison + // Labels can be: "Port:", "[red]Port:[-]", "[green]Port:[-]" + cleanLabel := stripColorTags(label) + if strings.HasPrefix(cleanLabel, fieldName) { + return strings.TrimSpace(field.GetText()) + } + } + } + } + return "" + } + + // Helper function to get selected option from DropDown across all forms + getDropdownValue := func(fieldName string) string { + for _, form := range sf.forms { + for i := 0; i < form.GetFormItemCount(); i++ { + if dropdown, ok := form.GetFormItem(i).(*tview.DropDown); ok { + label := strings.TrimSpace(dropdown.GetLabel()) + // Strip color tags from label for comparison + cleanLabel := stripColorTags(label) + if strings.HasPrefix(cleanLabel, fieldName) { + _, text := dropdown.GetCurrentOption() + // Parse the option value to handle "default (value)" format + return parseOptionValue(text) + } + } + } + } + return "" + } + return ServerFormData{ - Alias: strings.TrimSpace(sf.Form.GetFormItem(0).(*tview.InputField).GetText()), - Host: strings.TrimSpace(sf.Form.GetFormItem(1).(*tview.InputField).GetText()), - User: strings.TrimSpace(sf.Form.GetFormItem(2).(*tview.InputField).GetText()), - Port: strings.TrimSpace(sf.Form.GetFormItem(3).(*tview.InputField).GetText()), - Key: strings.TrimSpace(sf.Form.GetFormItem(4).(*tview.InputField).GetText()), - Tags: strings.TrimSpace(sf.Form.GetFormItem(5).(*tview.InputField).GetText()), + Alias: getFieldText("Alias:"), + Host: getFieldText("Host/IP:"), + User: getFieldText("User:"), + Port: getFieldText("Port:"), + Key: getFieldText("Keys:"), + Tags: getFieldText("Tags:"), + // Connection and proxy settings + ProxyJump: getFieldText("ProxyJump:"), + ProxyCommand: getFieldText("ProxyCommand:"), + RemoteCommand: getFieldText("RemoteCommand:"), + RequestTTY: getDropdownValue("RequestTTY:"), + SessionType: sf.parseSessionType(getDropdownValue("SessionType:")), + ConnectTimeout: getFieldText("ConnectTimeout:"), + ConnectionAttempts: getFieldText("ConnectionAttempts:"), + BindAddress: getFieldText("BindAddress:"), + BindInterface: getDropdownValue("BindInterface:"), + AddressFamily: getDropdownValue("AddressFamily:"), + ExitOnForwardFailure: getDropdownValue("ExitOnForwardFailure:"), + // Port forwarding + LocalForward: getFieldText("LocalForward:"), + RemoteForward: getFieldText("RemoteForward:"), + DynamicForward: getFieldText("DynamicForward:"), + ClearAllForwardings: getDropdownValue("ClearAllForwardings:"), + // Authentication and key management + // Public key + PubkeyAuthentication: getDropdownValue("PubkeyAuthentication:"), + IdentitiesOnly: getDropdownValue("IdentitiesOnly:"), + // SSH Agent + AddKeysToAgent: getDropdownValue("AddKeysToAgent:"), + IdentityAgent: getFieldText("IdentityAgent:"), + // Password & Interactive + PasswordAuthentication: getDropdownValue("PasswordAuthentication:"), + KbdInteractiveAuthentication: getDropdownValue("KbdInteractiveAuthentication:"), + NumberOfPasswordPrompts: getFieldText("NumberOfPasswordPrompts:"), + // Advanced + PreferredAuthentications: getFieldText("PreferredAuthentications:"), + // Agent and X11 forwarding + ForwardAgent: getDropdownValue("ForwardAgent:"), + ForwardX11: getDropdownValue("ForwardX11:"), + ForwardX11Trusted: getDropdownValue("ForwardX11Trusted:"), + // Connection multiplexing + ControlMaster: getDropdownValue("ControlMaster:"), + ControlPath: getFieldText("ControlPath:"), + ControlPersist: getFieldText("ControlPersist:"), + // Connection reliability settings + ServerAliveInterval: getFieldText("ServerAliveInterval:"), + ServerAliveCountMax: getFieldText("ServerAliveCountMax:"), + Compression: getDropdownValue("Compression:"), + TCPKeepAlive: getDropdownValue("TCPKeepAlive:"), + BatchMode: getDropdownValue("BatchMode:"), + // Security settings + StrictHostKeyChecking: getDropdownValue("StrictHostKeyChecking:"), + UserKnownHostsFile: getFieldText("UserKnownHostsFile:"), + HostKeyAlgorithms: getFieldText("HostKeyAlgorithms:"), + PubkeyAcceptedAlgorithms: getFieldText("PubkeyAcceptedAlgorithms:"), + MACs: getFieldText("MACs:"), + Ciphers: getFieldText("Ciphers:"), + KexAlgorithms: getFieldText("KexAlgorithms:"), + VerifyHostKeyDNS: getDropdownValue("VerifyHostKeyDNS:"), + UpdateHostKeys: getDropdownValue("UpdateHostKeys:"), + HashKnownHosts: getDropdownValue("HashKnownHosts:"), + VisualHostKey: getDropdownValue("VisualHostKey:"), + // Command execution + LocalCommand: getFieldText("LocalCommand:"), + PermitLocalCommand: getDropdownValue("PermitLocalCommand:"), + EscapeChar: getFieldText("EscapeChar:"), + // Environment settings + SendEnv: getFieldText("SendEnv:"), + SetEnv: getFieldText("SetEnv:"), + // Debugging settings + LogLevel: getDropdownValue("LogLevel:"), } } -func (sf *ServerForm) handleSave() { +// parseSessionType converts dropdown display value to actual value +func (sf *ServerForm) parseSessionType(value string) string { + // First handle the default value format + if strings.HasPrefix(value, "default (") && strings.HasSuffix(value, ")") { + return "" // Return empty string for default values + } + + // Then handle specific display values + switch value { + case "none (-N)": + return sessionTypeNone + case "subsystem (-s)": + return sessionTypeSubsystem + case "default": + return sessionTypeDefault + default: + return value + } +} + +// handleSaveButton is a wrapper for button callback (no return value) +func (sf *ServerForm) handleSaveButton() { + sf.handleSave() +} + +// handleSave validates and saves the form, returns true if successful +func (sf *ServerForm) handleSave() bool { + // First validate all fields with the new validation system + if !sf.validateAllFields() { + // Show validation errors + if sf.app != nil { + errors := sf.validation.GetAllErrors() + if len(errors) > 0 { + // Limit the number of errors to display to prevent overflow + maxErrorsToShow := 5 + truncated := false + if len(errors) > maxErrorsToShow { + errors = errors[:maxErrorsToShow] + truncated = true + } + + // Build error message + errorMsg := fmt.Sprintf("Validation failed (%d error%s):\n\n", + sf.validation.GetErrorCount(), + func() string { + if sf.validation.GetErrorCount() == 1 { + return "" + } + return "s" + }()) + + for i, err := range errors { + errorMsg += fmt.Sprintf("%d. %s\n", i+1, err) + } + + if truncated { + errorMsg += fmt.Sprintf("\n... and %d more error%s", + sf.validation.GetErrorCount()-maxErrorsToShow, + func() string { + if sf.validation.GetErrorCount()-maxErrorsToShow == 1 { + return "" + } + return "s" + }()) + } + + // Use tview's built-in Modal + modal := tview.NewModal(). + SetText(errorMsg). + AddButtons([]string{"OK"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + sf.app.SetRoot(sf.Flex, true) + }) + + sf.app.SetRoot(modal, true) + } + } + return false // Validation failed + } + data := sf.getFormData() - if errMsg := validateServerForm(data); errMsg != "" { - - sf.Form.SetTitle(fmt.Sprintf("%s β€” [red::b]%s[-]", sf.titleForMode(), errMsg)) - sf.Form.SetBorderColor(tcell.ColorRed) - return - } - - sf.Form.SetTitle(sf.titleForMode()) - sf.Form.SetBorderColor(tcell.Color238) + // Reset title and border (validation already done above) + sf.formPanel.SetTitle(" " + sf.titleForMode() + " ") + sf.formPanel.SetBorderColor(tcell.Color238) server := sf.dataToServer(data) if sf.onSave != nil { sf.onSave(server, sf.original) } + return true // Save successful } func (sf *ServerForm) handleCancel() { - if sf.onCancel != nil { - sf.onCancel() + // Check if there are unsaved changes + if sf.hasUnsavedChanges() { + // If app reference is available, show confirmation dialog + if sf.app != nil { + modal := tview.NewModal(). + SetText("You have unsaved changes. Are you sure you want to exit?"). + AddButtons([]string{"[yellow]S[-]ave", "[yellow]D[-]iscard", "[yellow]C[-]ancel"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + switch buttonIndex { + case 0: // Save + // Try to save, if successful it will exit + if sf.handleSave() { + // Save successful, modal will be replaced by onSave callback + } else { + // Validation failed, return to form + sf.app.SetRoot(sf.Flex, true) + } + case 1: // Discard + if sf.onCancel != nil { + sf.onCancel() + } + case 2: // Cancel + // Restore the form view + sf.app.SetRoot(sf.Flex, true) + } + }) + + // Set up keyboard shortcuts for the modal + modal.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 's', 'S': + if sf.handleSave() { + // Save successful + } else { + // Validation failed, return to form + sf.app.SetRoot(sf.Flex, true) + } + return nil + case 'd', 'D': + if sf.onCancel != nil { + sf.onCancel() + } + return nil + case 'c', 'C': + sf.app.SetRoot(sf.Flex, true) + return nil + } + return event + }) + + // Show modal + sf.app.SetRoot(modal, true) + } else if sf.onCancel != nil { + // No app reference, fallback to direct cancel (shouldn't happen in normal use) + sf.onCancel() + } + } else { + // No unsaved changes, just exit + if sf.onCancel != nil { + sf.onCancel() + } } } +// hasUnsavedChanges checks if current form data differs from original +func (sf *ServerForm) hasUnsavedChanges() bool { + // If creating new server, any non-empty required fields mean changes + if sf.mode == ServerFormAdd { + data := sf.getFormData() + return data.Alias != "" || data.Host != "" || data.User != "" + } + + // If editing, compare with original + if sf.original == nil { + return false + } + + currentData := sf.getFormData() + currentServer := sf.dataToServer(currentData) + + // Use DeepEqual for simple comparison first + if reflect.DeepEqual(currentServer, *sf.original) { + return false + } + + // If DeepEqual says they're different, use our custom comparison + // that handles nil vs empty slice and other normalization + return sf.serversDiffer(currentServer, *sf.original) +} + +// serversDiffer compares two servers for differences using reflection +func (sf *ServerForm) serversDiffer(a, b domain.Server) bool { + // Use reflection to compare all fields + valA := reflect.ValueOf(a) + valB := reflect.ValueOf(b) + typeA := valA.Type() + + // Fields to skip during comparison (lazyssh metadata fields) + skipFields := map[string]bool{ + "Aliases": true, // Computed field + "LastSeen": true, // Metadata field + "PinnedAt": true, // Metadata field + "SSHCount": true, // Metadata field + } + + // Iterate through all fields + for i := 0; i < valA.NumField(); i++ { + fieldA := valA.Field(i) + fieldB := valB.Field(i) + fieldName := typeA.Field(i).Name + + // Skip unexported fields and metadata fields + if !fieldA.CanInterface() || skipFields[fieldName] { + continue + } + + // Compare based on field type + differs := false + switch fieldA.Kind() { + case reflect.String: + if fieldA.String() != fieldB.String() { + differs = true + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if fieldA.Int() != fieldB.Int() { + differs = true + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + if fieldA.Uint() != fieldB.Uint() { + differs = true + } + case reflect.Slice: + if !sf.slicesEqual(fieldA, fieldB) { + differs = true + } + case reflect.Bool: + if fieldA.Bool() != fieldB.Bool() { + differs = true + } + case reflect.Float32, reflect.Float64: + if fieldA.Float() != fieldB.Float() { + differs = true + } + case reflect.Complex64, reflect.Complex128: + if fieldA.Complex() != fieldB.Complex() { + differs = true + } + case reflect.Array, reflect.Chan, reflect.Func, reflect.Interface, + reflect.Map, reflect.Ptr, reflect.Struct, reflect.UnsafePointer, reflect.Invalid: + // For these types, use reflect.DeepEqual + if !reflect.DeepEqual(fieldA.Interface(), fieldB.Interface()) { + differs = true + } + } + + if differs { + return true + } + } + + return false +} + +// slicesEqual compares two reflect.Value slices for equality +func (sf *ServerForm) slicesEqual(a, b reflect.Value) bool { + // Handle nil slices - treat nil and empty slice as equal + if a.IsNil() && b.IsNil() { + return true + } + if a.IsNil() && b.Len() == 0 { + return true + } + if b.IsNil() && a.Len() == 0 { + return true + } + + if a.Len() != b.Len() { + return false + } + + for i := 0; i < a.Len(); i++ { + // For string slices + if a.Index(i).Kind() == reflect.String { + if a.Index(i).String() != b.Index(i).String() { + return false + } + } else { + // For other types, use DeepEqual + if !reflect.DeepEqual(a.Index(i).Interface(), b.Index(i).Interface()) { + return false + } + } + } + + return true +} + func (sf *ServerForm) dataToServer(data ServerFormData) domain.Server { port := 22 if data.Port != "" { @@ -154,6 +2147,7 @@ func (sf *ServerForm) dataToServer(data ServerFormData) domain.Server { } } + // Use nil for empty slices to match original state var tags []string if data.Tags != "" { for _, t := range strings.Split(data.Tags, ",") { @@ -163,7 +2157,7 @@ func (sf *ServerForm) dataToServer(data ServerFormData) domain.Server { } } - keys := make([]string, 0) + var keys []string if data.Key != "" { parts := strings.Split(data.Key, ",") for _, p := range parts { @@ -172,59 +2166,96 @@ func (sf *ServerForm) dataToServer(data ServerFormData) domain.Server { } } } - return domain.Server{ - Alias: data.Alias, - Host: data.Host, - User: data.User, - Port: port, - IdentityFiles: keys, - Tags: tags, - } -} -// validateServerForm returns an error message string if validation fails; empty string means valid. -func validateServerForm(data ServerFormData) string { - alias := data.Alias - if alias == "" { - return "Alias is required" - } - if !regexp.MustCompile(`^[A-Za-z0-9_.-]+$`).MatchString(alias) { - return "Alias may contain letters, digits, dot, dash, underscore" - } - - host := data.Host - if host == "" { - return "Host/IP is required" - } - if ip := net.ParseIP(host); ip == nil { - - if strings.Contains(host, " ") { - return "Host must not contain spaces" + // Helper to split comma-separated values + splitComma := func(s string) []string { + if s == "" { + return nil } - if !regexp.MustCompile(`^[A-Za-z0-9.-]+$`).MatchString(host) { - return "Host contains invalid characters" - } - if strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") { - return "Host must not start or end with a dot" - } - for _, lbl := range strings.Split(host, ".") { - if lbl == "" { - return "Host must not contain empty labels" - } - if strings.HasPrefix(lbl, "-") || strings.HasSuffix(lbl, "-") { - return "Hostname labels must not start or end with a hyphen" + var result []string + for _, item := range strings.Split(s, ",") { + if trimmed := strings.TrimSpace(item); trimmed != "" { + result = append(result, trimmed) } } + return result } - if data.Port != "" { - p, err := strconv.Atoi(data.Port) - if err != nil || p < 1 || p > 65535 { - return "Port must be a number between 1 and 65535" - } + server := domain.Server{ + Alias: data.Alias, + Host: data.Host, + User: data.User, + Port: port, + IdentityFiles: keys, + Tags: tags, + ProxyJump: data.ProxyJump, + ProxyCommand: data.ProxyCommand, + RemoteCommand: data.RemoteCommand, + RequestTTY: data.RequestTTY, + SessionType: data.SessionType, + ConnectTimeout: data.ConnectTimeout, + ConnectionAttempts: data.ConnectionAttempts, + BindAddress: data.BindAddress, + BindInterface: data.BindInterface, + AddressFamily: data.AddressFamily, + ExitOnForwardFailure: data.ExitOnForwardFailure, + LocalForward: splitComma(data.LocalForward), + RemoteForward: splitComma(data.RemoteForward), + DynamicForward: splitComma(data.DynamicForward), + ClearAllForwardings: data.ClearAllForwardings, + // Public key + PubkeyAuthentication: data.PubkeyAuthentication, + IdentitiesOnly: data.IdentitiesOnly, + // SSH Agent + AddKeysToAgent: data.AddKeysToAgent, + IdentityAgent: data.IdentityAgent, + // Password & Interactive + PasswordAuthentication: data.PasswordAuthentication, + KbdInteractiveAuthentication: data.KbdInteractiveAuthentication, + NumberOfPasswordPrompts: data.NumberOfPasswordPrompts, + // Advanced + PreferredAuthentications: data.PreferredAuthentications, + ForwardAgent: data.ForwardAgent, + ForwardX11: data.ForwardX11, + ForwardX11Trusted: data.ForwardX11Trusted, + ControlMaster: data.ControlMaster, + ControlPath: data.ControlPath, + ControlPersist: data.ControlPersist, + ServerAliveInterval: data.ServerAliveInterval, + ServerAliveCountMax: data.ServerAliveCountMax, + Compression: data.Compression, + TCPKeepAlive: data.TCPKeepAlive, + BatchMode: data.BatchMode, + StrictHostKeyChecking: data.StrictHostKeyChecking, + UserKnownHostsFile: data.UserKnownHostsFile, + HostKeyAlgorithms: data.HostKeyAlgorithms, + PubkeyAcceptedAlgorithms: data.PubkeyAcceptedAlgorithms, + HostbasedAcceptedAlgorithms: data.HostbasedAcceptedAlgorithms, + MACs: data.MACs, + Ciphers: data.Ciphers, + KexAlgorithms: data.KexAlgorithms, + VerifyHostKeyDNS: data.VerifyHostKeyDNS, + UpdateHostKeys: data.UpdateHostKeys, + HashKnownHosts: data.HashKnownHosts, + VisualHostKey: data.VisualHostKey, + LocalCommand: data.LocalCommand, + PermitLocalCommand: data.PermitLocalCommand, + EscapeChar: data.EscapeChar, + SendEnv: splitComma(data.SendEnv), + SetEnv: splitComma(data.SetEnv), + LogLevel: data.LogLevel, } - return "" + // Preserve metadata fields from original if in edit mode + if sf.mode == ServerFormEdit && sf.original != nil { + server.PinnedAt = sf.original.PinnedAt + server.LastSeen = sf.original.LastSeen + server.SSHCount = sf.original.SSHCount + // Also preserve Aliases (computed field) + server.Aliases = sf.original.Aliases + } + + return server } func (sf *ServerForm) OnSave(fn func(domain.Server, *domain.Server)) *ServerForm { @@ -236,3 +2267,21 @@ func (sf *ServerForm) OnCancel(fn func()) *ServerForm { sf.onCancel = fn return sf } + +func (sf *ServerForm) SetApp(app *tview.Application) *ServerForm { + sf.app = app + return sf +} + +func (sf *ServerForm) SetVersionInfo(version, commit string) *ServerForm { + sf.version = version + sf.commit = commit + // Build the form now that we have version info + if sf.header == nil { + sf.build() + } else { + // Rebuild header if already exists + sf.header = NewAppHeader(sf.version, sf.commit, RepoURL) + } + return sf +} diff --git a/internal/adapters/ui/server_list.go b/internal/adapters/ui/server_list.go index 2ac6644..b175014 100644 --- a/internal/adapters/ui/server_list.go +++ b/internal/adapters/ui/server_list.go @@ -38,7 +38,8 @@ func NewServerList() *ServerList { func (sl *ServerList) build() { sl.List.ShowSecondaryText(false) sl.List.SetBorder(true). - SetTitle("Servers"). + SetTitle(" Servers "). + SetTitleAlign(tview.AlignCenter). SetBorderColor(tcell.Color238). SetTitleColor(tcell.Color250) sl.List. diff --git a/internal/adapters/ui/tui.go b/internal/adapters/ui/tui.go index 6e12fcd..6046071 100644 --- a/internal/adapters/ui/tui.go +++ b/internal/adapters/ui/tui.go @@ -141,6 +141,6 @@ func (t *tui) loadInitialData() *tui { func (t *tui) updateListTitle() { if t.serverList != nil { - t.serverList.SetTitle("Servers β€” Sort: " + t.sortMode.String()) + t.serverList.SetTitle(" Servers β€” Sort: " + t.sortMode.String() + " ") } } diff --git a/internal/adapters/ui/utils.go b/internal/adapters/ui/utils.go index 0ad6196..95a3996 100644 --- a/internal/adapters/ui/utils.go +++ b/internal/adapters/ui/utils.go @@ -16,6 +16,8 @@ package ui import ( "fmt" + "os" + "path/filepath" "strings" "time" @@ -23,6 +25,18 @@ import ( "github.com/mattn/go-runewidth" ) +// SSH config value constants +const ( + sshYes = "yes" + sshNo = "no" + sshForce = "force" + sshAuto = "auto" + + // SessionType values + sessionTypeNone = "none" + sessionTypeSubsystem = "subsystem" +) + // renderTagBadgesForList renders up to two colored tag chips for the server list. // If there are more tags, it appends a subtle gray "+N" badge. Returns an empty // string when there are no tags to avoid cluttering the list. @@ -107,9 +121,54 @@ func humanizeDuration(t time.Time) string { } // BuildSSHCommand constructs a ready-to-run ssh command for the given server. -// Format: ssh [user@]host [-p PORT if not 22] [-i KEY if provided] +// Format: ssh [options] [user@]host [command] func BuildSSHCommand(s domain.Server) string { parts := []string{"ssh"} + + // Add proxy and connection options + addProxyOptions(&parts, s) + addConnectionTimingOptions(&parts, s) + + // Add port forwarding options + addPortForwardingOptions(&parts, s) + + // Add authentication options + addAuthOptions(&parts, s) + + // Add agent and forwarding options + addForwardingOptions(&parts, s) + + // Add connection multiplexing options + addMultiplexingOptions(&parts, s) + + // Add connection reliability options + addConnectionOptions(&parts, s) + + // Add security options + addSecurityOptions(&parts, s) + + // Add command execution options + addCommandExecutionOptions(&parts, s) + + // Add environment options + addEnvironmentOptions(&parts, s) + + // Add TTY and logging options + addTTYAndLoggingOptions(&parts, s) + + // Port option + if s.Port != 0 && s.Port != 22 { + parts = append(parts, "-p", fmt.Sprintf("%d", s.Port)) + } + + // Identity file option + if len(s.IdentityFiles) > 0 { + for _, keyFile := range s.IdentityFiles { + parts = append(parts, "-i", quoteIfNeeded(keyFile)) + } + } + + // Host specification userHost := "" switch { case s.User != "" && s.Host != "": @@ -121,15 +180,285 @@ func BuildSSHCommand(s domain.Server) string { } parts = append(parts, userHost) - if s.Port != 0 && s.Port != 22 { - parts = append(parts, "-p", fmt.Sprintf("%d", s.Port)) - } - if len(s.IdentityFiles) > 0 { - parts = append(parts, "-i", quoteIfNeeded(s.IdentityFiles[0])) + // RemoteCommand (must come after the host) + if s.RemoteCommand != "" { + // Handle special case: RemoteCommand=none clears the command (OpenSSH 7.6+) + if s.RemoteCommand == sessionTypeNone { + parts = append(parts, "-o", "RemoteCommand=none") + } else { + parts = append(parts, quoteIfNeeded(s.RemoteCommand)) + } } + return strings.Join(parts, " ") } +// addOption adds an SSH option in the format "-o Key=Value" if value is not empty +func addOption(parts *[]string, key, value string) { + if value != "" { + *parts = append(*parts, "-o", fmt.Sprintf("%s=%s", key, value)) + } +} + +// addQuotedOption adds an SSH option with quoted value if needed +func addQuotedOption(parts *[]string, key, value string) { + if value != "" { + *parts = append(*parts, "-o", fmt.Sprintf("%s=%s", key, quoteIfNeeded(value))) + } +} + +// addProxyOptions adds proxy-related options to the SSH command +func addProxyOptions(parts *[]string, s domain.Server) { + if s.ProxyJump != "" { + *parts = append(*parts, "-J", quoteIfNeeded(s.ProxyJump)) + } + addQuotedOption(parts, "ProxyCommand", s.ProxyCommand) +} + +// addConnectionTimingOptions adds connection timing options to the SSH command +func addConnectionTimingOptions(parts *[]string, s domain.Server) { + addOption(parts, "ConnectTimeout", s.ConnectTimeout) + addOption(parts, "ConnectionAttempts", s.ConnectionAttempts) + if s.BindAddress != "" { + *parts = append(*parts, "-b", s.BindAddress) + } + if s.BindInterface != "" { + *parts = append(*parts, "-B", s.BindInterface) + } + addOption(parts, "AddressFamily", s.AddressFamily) + addOption(parts, "IPQoS", s.IPQoS) + // Hostname canonicalization options + addOption(parts, "CanonicalizeHostname", s.CanonicalizeHostname) + addOption(parts, "CanonicalDomains", s.CanonicalDomains) + addOption(parts, "CanonicalizeFallbackLocal", s.CanonicalizeFallbackLocal) + addOption(parts, "CanonicalizeMaxDots", s.CanonicalizeMaxDots) + addQuotedOption(parts, "CanonicalizePermittedCNAMEs", s.CanonicalizePermittedCNAMEs) +} + +// addPortForwardingOptions adds port forwarding options to the SSH command +func addPortForwardingOptions(parts *[]string, s domain.Server) { + for _, forward := range s.LocalForward { + *parts = append(*parts, "-L", forward) + } + for _, forward := range s.RemoteForward { + *parts = append(*parts, "-R", forward) + } + for _, forward := range s.DynamicForward { + *parts = append(*parts, "-D", forward) + } + if s.ClearAllForwardings == sshYes { + *parts = append(*parts, "-o", "ClearAllForwardings=yes") + } + if s.ExitOnForwardFailure == sshYes { + *parts = append(*parts, "-o", "ExitOnForwardFailure=yes") + } + if s.GatewayPorts != "" { + *parts = append(*parts, "-o", fmt.Sprintf("GatewayPorts=%s", s.GatewayPorts)) + } +} + +// addAuthOptions adds authentication-related options to the SSH command +func addAuthOptions(parts *[]string, s domain.Server) { + if s.PubkeyAuthentication != "" { + *parts = append(*parts, "-o", fmt.Sprintf("PubkeyAuthentication=%s", s.PubkeyAuthentication)) + } + if s.PubkeyAcceptedAlgorithms != "" { + *parts = append(*parts, "-o", fmt.Sprintf("PubkeyAcceptedAlgorithms=%s", s.PubkeyAcceptedAlgorithms)) + } + if s.HostbasedAcceptedAlgorithms != "" { + *parts = append(*parts, "-o", fmt.Sprintf("HostbasedAcceptedAlgorithms=%s", s.HostbasedAcceptedAlgorithms)) + } + if s.PasswordAuthentication != "" { + *parts = append(*parts, "-o", fmt.Sprintf("PasswordAuthentication=%s", s.PasswordAuthentication)) + } + if s.PreferredAuthentications != "" { + *parts = append(*parts, "-o", fmt.Sprintf("PreferredAuthentications=%s", s.PreferredAuthentications)) + } + if s.IdentitiesOnly != "" { + *parts = append(*parts, "-o", fmt.Sprintf("IdentitiesOnly=%s", s.IdentitiesOnly)) + } + if s.AddKeysToAgent != "" { + *parts = append(*parts, "-o", fmt.Sprintf("AddKeysToAgent=%s", s.AddKeysToAgent)) + } + if s.IdentityAgent != "" { + *parts = append(*parts, "-o", fmt.Sprintf("IdentityAgent=%s", quoteIfNeeded(s.IdentityAgent))) + } + if s.KbdInteractiveAuthentication != "" { + *parts = append(*parts, "-o", fmt.Sprintf("KbdInteractiveAuthentication=%s", s.KbdInteractiveAuthentication)) + } + if s.NumberOfPasswordPrompts != "" { + *parts = append(*parts, "-o", fmt.Sprintf("NumberOfPasswordPrompts=%s", s.NumberOfPasswordPrompts)) + } +} + +// addForwardingOptions adds agent and X11 forwarding options to the SSH command +func addForwardingOptions(parts *[]string, s domain.Server) { + if s.ForwardAgent != "" { + if s.ForwardAgent == sshYes { + *parts = append(*parts, "-A") + } else if s.ForwardAgent == sshNo { + *parts = append(*parts, "-a") + } + } + if s.ForwardX11 != "" { + if s.ForwardX11 == sshYes { + *parts = append(*parts, "-X") + } else if s.ForwardX11 == sshNo { + *parts = append(*parts, "-x") + } + } + if s.ForwardX11Trusted == sshYes { + *parts = append(*parts, "-Y") + } +} + +// addMultiplexingOptions adds connection multiplexing options to the SSH command +func addMultiplexingOptions(parts *[]string, s domain.Server) { + if s.ControlMaster != "" { + *parts = append(*parts, "-o", fmt.Sprintf("ControlMaster=%s", s.ControlMaster)) + } + if s.ControlPath != "" { + *parts = append(*parts, "-o", fmt.Sprintf("ControlPath=%s", quoteIfNeeded(s.ControlPath))) + } + if s.ControlPersist != "" { + *parts = append(*parts, "-o", fmt.Sprintf("ControlPersist=%s", s.ControlPersist)) + } +} + +// addConnectionOptions adds connection reliability options to the SSH command +func addConnectionOptions(parts *[]string, s domain.Server) { + if s.ServerAliveInterval != "" { + *parts = append(*parts, "-o", fmt.Sprintf("ServerAliveInterval=%s", s.ServerAliveInterval)) + } + if s.ServerAliveCountMax != "" { + *parts = append(*parts, "-o", fmt.Sprintf("ServerAliveCountMax=%s", s.ServerAliveCountMax)) + } + if s.Compression == sshYes { + *parts = append(*parts, "-C") + } + if s.TCPKeepAlive != "" { + *parts = append(*parts, "-o", fmt.Sprintf("TCPKeepAlive=%s", s.TCPKeepAlive)) + } + if s.BatchMode == sshYes { + *parts = append(*parts, "-o", "BatchMode=yes") + } +} + +// addCommandExecutionOptions adds command execution options to the SSH command +func addCommandExecutionOptions(parts *[]string, s domain.Server) { + if s.LocalCommand != "" { + *parts = append(*parts, "-o", fmt.Sprintf("LocalCommand=%s", quoteIfNeeded(s.LocalCommand))) + } + if s.PermitLocalCommand != "" { + *parts = append(*parts, "-o", fmt.Sprintf("PermitLocalCommand=%s", s.PermitLocalCommand)) + } + if s.EscapeChar != "" { + *parts = append(*parts, "-e", s.EscapeChar) + } +} + +// addEnvironmentOptions adds environment variable options to the SSH command +func addEnvironmentOptions(parts *[]string, s domain.Server) { + for _, env := range s.SendEnv { + *parts = append(*parts, "-o", fmt.Sprintf("SendEnv=%s", env)) + } + for _, env := range s.SetEnv { + *parts = append(*parts, "-o", fmt.Sprintf("SetEnv=%s", quoteIfNeeded(env))) + } +} + +// addSecurityOptions adds security-related options to the SSH command +func addSecurityOptions(parts *[]string, s domain.Server) { + if s.StrictHostKeyChecking != "" { + *parts = append(*parts, "-o", fmt.Sprintf("StrictHostKeyChecking=%s", s.StrictHostKeyChecking)) + } + if s.CheckHostIP != "" { + *parts = append(*parts, "-o", fmt.Sprintf("CheckHostIP=%s", s.CheckHostIP)) + } + if s.FingerprintHash != "" { + *parts = append(*parts, "-o", fmt.Sprintf("FingerprintHash=%s", s.FingerprintHash)) + } + if s.UserKnownHostsFile != "" { + *parts = append(*parts, "-o", fmt.Sprintf("UserKnownHostsFile=%s", quoteIfNeeded(s.UserKnownHostsFile))) + } + if s.HostKeyAlgorithms != "" { + *parts = append(*parts, "-o", fmt.Sprintf("HostKeyAlgorithms=%s", s.HostKeyAlgorithms)) + } + if s.MACs != "" { + *parts = append(*parts, "-m", s.MACs) + } + if s.Ciphers != "" { + *parts = append(*parts, "-c", s.Ciphers) + } + if s.KexAlgorithms != "" { + *parts = append(*parts, "-o", fmt.Sprintf("KexAlgorithms=%s", s.KexAlgorithms)) + } + if s.VerifyHostKeyDNS != "" { + *parts = append(*parts, "-o", fmt.Sprintf("VerifyHostKeyDNS=%s", s.VerifyHostKeyDNS)) + } + if s.UpdateHostKeys != "" { + *parts = append(*parts, "-o", fmt.Sprintf("UpdateHostKeys=%s", s.UpdateHostKeys)) + } + if s.HashKnownHosts != "" { + *parts = append(*parts, "-o", fmt.Sprintf("HashKnownHosts=%s", s.HashKnownHosts)) + } + if s.VisualHostKey != "" { + *parts = append(*parts, "-o", fmt.Sprintf("VisualHostKey=%s", s.VisualHostKey)) + } +} + +// addTTYAndLoggingOptions adds TTY and logging options to the SSH command +func addTTYAndLoggingOptions(parts *[]string, s domain.Server) { + // RequestTTY option + if s.RequestTTY != "" { + switch s.RequestTTY { + case sshYes: + *parts = append(*parts, "-t") + case sshNo: + *parts = append(*parts, "-T") + case sshForce: + *parts = append(*parts, "-tt") + case sshAuto: + // auto is the default behavior, no flag needed + default: + // For any other value, pass it as-is via -o + *parts = append(*parts, "-o", fmt.Sprintf("RequestTTY=%s", s.RequestTTY)) + } + } + + // LogLevel option + if s.LogLevel != "" { + switch strings.ToLower(s.LogLevel) { + case "quiet": + *parts = append(*parts, "-q") + case "verbose": + *parts = append(*parts, "-v") + case "debug", "debug1": + *parts = append(*parts, "-v") + case "debug2": + *parts = append(*parts, "-vv") + case "debug3": + *parts = append(*parts, "-vvv") + } + } + + // SessionType option (OpenSSH 8.7+) + // "none" is equivalent to -N flag + if s.SessionType != "" { + switch s.SessionType { + case sessionTypeNone: + // Use -N flag for better compatibility + *parts = append(*parts, "-N") + case sessionTypeSubsystem: + // Use -s flag for subsystem + *parts = append(*parts, "-s") + default: + // For other values, use -o SessionType= + *parts = append(*parts, "-o", fmt.Sprintf("SessionType=%s", s.SessionType)) + } + } +} + // quoteIfNeeded returns the value quoted if it contains spaces. func quoteIfNeeded(val string) string { if strings.ContainsAny(val, " \t") { @@ -137,3 +466,169 @@ func quoteIfNeeded(val string) string { } return val } + +// GetAvailableSSHKeys returns a list of available SSH private key files in the user's .ssh directory. +// It safely handles file permission issues and only returns readable key files. +func GetAvailableSSHKeys() []string { + homeDir, err := os.UserHomeDir() + if err != nil { + return []string{} + } + + sshDir := filepath.Join(homeDir, ".ssh") + keys := []string{} + + // Common SSH key filenames to look for + commonKeyFiles := []string{ + "id_ed25519", + "id_rsa", + "id_ecdsa", + "id_dsa", + "id_ed25519_sk", + "id_ecdsa_sk", + } + + // First, add common key files if they exist and are readable + for _, keyName := range commonKeyFiles { + keyPath := filepath.Join(sshDir, keyName) + if info, err := os.Stat(keyPath); err == nil && !info.IsDir() { + // Check if file is readable + // #nosec G304 - keyPath is constructed from known safe values + if file, err := os.Open(keyPath); err == nil { + _ = file.Close() + // Use tilde notation for user-friendly display + keys = append(keys, "~/.ssh/"+keyName) + } + } + } + + // Additionally, scan for any other files that look like private keys + // (files without .pub extension and with appropriate permissions) + entries, err := os.ReadDir(sshDir) + if err != nil { + return keys // Return what we have so far + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + // Skip public keys, known_hosts, config, and authorized_keys + if strings.HasSuffix(name, ".pub") || + name == "known_hosts" || + name == "config" || + name == "authorized_keys" || + strings.HasPrefix(name, ".") { + continue + } + + // Skip if already added as common key + isCommon := false + for _, commonKey := range commonKeyFiles { + if name == commonKey { + isCommon = true + break + } + } + if isCommon { + continue + } + + // Check if file is readable and looks like a private key + keyPath := filepath.Join(sshDir, name) + // #nosec G304 - keyPath is constructed from safe directory enumeration + if file, err := os.Open(keyPath); err == nil { + // Read first few bytes to check if it might be a private key + buffer := make([]byte, 100) + n, readErr := file.Read(buffer) + _ = file.Close() + if readErr == nil && n > 0 { + content := string(buffer[:n]) + if strings.Contains(content, "PRIVATE KEY") || + strings.Contains(content, "SSH2 ENCRYPTED PRIVATE KEY") { + keys = append(keys, "~/.ssh/"+name) + } + } + } + } + + return keys +} + +// GetAvailableKnownHostsFiles returns a list of available known_hosts files in common locations. +// It safely handles file permission issues and only returns readable files. +func GetAvailableKnownHostsFiles() []string { + homeDir, err := os.UserHomeDir() + if err != nil { + return []string{} + } + + files := []string{} + + // Common known_hosts file locations to check + commonPaths := []string{ + filepath.Join(homeDir, ".ssh", "known_hosts"), + filepath.Join(homeDir, ".ssh", "known_hosts2"), + filepath.Join(homeDir, ".ssh", "known_hosts.old"), + "/etc/ssh/ssh_known_hosts", + "/etc/ssh/ssh_known_hosts2", + } + + // Check each common location + for _, path := range commonPaths { + if info, err := os.Stat(path); err == nil && !info.IsDir() { + // Check if file is readable + // #nosec G304 - path is constructed from known safe values + if file, err := os.Open(path); err == nil { + _ = file.Close() + // Convert to user-friendly path with tilde notation + if strings.HasPrefix(path, homeDir) { + relPath := strings.TrimPrefix(path, homeDir) + files = append(files, "~"+relPath) + } else { + files = append(files, path) + } + } + } + } + + // Also check for any other files in .ssh directory that might be known_hosts files + sshDir := filepath.Join(homeDir, ".ssh") + entries, err := os.ReadDir(sshDir) + if err == nil { + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + // Look for files that might be known_hosts variants + if strings.Contains(name, "known_hosts") && !strings.HasSuffix(name, ".pub") { + // Skip if already added from common paths + fullPath := filepath.Join(sshDir, name) + tildeNotation := "~/.ssh/" + name + + alreadyAdded := false + for _, existing := range files { + if existing == tildeNotation { + alreadyAdded = true + break + } + } + + if !alreadyAdded { + // Check if readable + // #nosec G304 - fullPath is constructed from safe directory enumeration + if file, err := os.Open(fullPath); err == nil { + _ = file.Close() + files = append(files, tildeNotation) + } + } + } + } + } + + return files +} diff --git a/internal/adapters/ui/utils_test.go b/internal/adapters/ui/utils_test.go new file mode 100644 index 0000000..aad5ad8 --- /dev/null +++ b/internal/adapters/ui/utils_test.go @@ -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) + } +} diff --git a/internal/adapters/ui/validation.go b/internal/adapters/ui/validation.go new file mode 100644 index 0000000..248c7d6 --- /dev/null +++ b/internal/adapters/ui/validation.go @@ -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, "") +} diff --git a/internal/adapters/ui/validation_test.go b/internal/adapters/ui/validation_test.go new file mode 100644 index 0000000..9968372 --- /dev/null +++ b/internal/adapters/ui/validation_test.go @@ -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()) + } +} diff --git a/internal/core/domain/server.go b/internal/core/domain/server.go index 0c534c5..c23b301 100644 --- a/internal/core/domain/server.go +++ b/internal/core/domain/server.go @@ -27,4 +27,91 @@ type Server struct { LastSeen time.Time PinnedAt time.Time SSHCount int + + // Additional SSH config fields + // Connection and proxy settings + ProxyJump string + ProxyCommand string + RemoteCommand string + RequestTTY string + SessionType string // none, subsystem, default (OpenSSH 8.7+) + ConnectTimeout string + ConnectionAttempts string + BindAddress string + BindInterface string + AddressFamily string // any, inet, inet6 + ExitOnForwardFailure string // yes, no + IPQoS string // af11, af12, af13, af21, af22, af23, af31, af32, af33, af41, af42, af43, cs0-cs7, ef, lowdelay, throughput, reliability, or numeric value + // Hostname canonicalization + CanonicalizeHostname string // yes, no, always + CanonicalDomains string + CanonicalizeFallbackLocal string // yes, no + CanonicalizeMaxDots string + CanonicalizePermittedCNAMEs string + + // Port forwarding settings + LocalForward []string + RemoteForward []string + DynamicForward []string + ClearAllForwardings string // yes, no + GatewayPorts string // yes, no, clientspecified + + // Authentication and key management + // Public key + PubkeyAuthentication string + PubkeyAcceptedAlgorithms string + HostbasedAcceptedAlgorithms string + IdentitiesOnly string + // SSH Agent + AddKeysToAgent string + IdentityAgent string + // Password & Interactive + PasswordAuthentication string + KbdInteractiveAuthentication string // yes, no + NumberOfPasswordPrompts string + // Advanced + PreferredAuthentications string + + // Agent and X11 forwarding + ForwardAgent string + ForwardX11 string + ForwardX11Trusted string + + // Connection multiplexing + ControlMaster string + ControlPath string + ControlPersist string + + // Connection reliability settings + ServerAliveInterval string + ServerAliveCountMax string + Compression string + TCPKeepAlive string + BatchMode string // yes, no - disable all interactive prompts + + // Security and cryptography settings + StrictHostKeyChecking string + CheckHostIP string // yes, no + FingerprintHash string // md5, sha256 + UserKnownHostsFile string + HostKeyAlgorithms string + MACs string + Ciphers string + KexAlgorithms string + VerifyHostKeyDNS string // yes, no, ask + UpdateHostKeys string // yes, no, ask + HashKnownHosts string // yes, no + VisualHostKey string // yes, no + + // Command execution + LocalCommand string + PermitLocalCommand string + EscapeChar string // single character or "none" + + // Environment settings + SendEnv []string + SetEnv []string + + // Debugging settings + LogLevel string }