// 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 ( "fmt" "strings" "github.com/Adembc/lazyssh/internal/core/domain" "github.com/kevinburke/ssh_config" ) const ( MaxBackups = 10 TempSuffix = ".tmp" BackupSuffix = "lazyssh.backup" SSHConfigPerms = 0o600 OriginalBackupName = "config.original.backup" ) // filterServers filters servers based on the query string. func (r *Repository) filterServers(servers []domain.Server, query string) []domain.Server { query = strings.ToLower(query) filtered := make([]domain.Server, 0) for _, server := range servers { if r.matchesQuery(server, query) { filtered = append(filtered, server) } } return filtered } // matchesQuery checks if any field of the server matches the query string. func (r *Repository) matchesQuery(server domain.Server, query string) bool { fields := []string{ strings.ToLower(server.Host), strings.ToLower(server.User), } for _, tag := range server.Tags { fields = append(fields, strings.ToLower(tag)) } for _, alias := range server.Aliases { fields = append(fields, strings.ToLower(alias)) } for _, field := range fields { if strings.Contains(field, query) { return true } } return false } // serverExists checks if a server with the given alias already exists in the config. func (r *Repository) serverExists(cfg *ssh_config.Config, alias string) bool { return r.findHostByAlias(cfg, alias) != nil } // findHostByAlias finds a host by its alias in the SSH config. func (r *Repository) findHostByAlias(cfg *ssh_config.Config, alias string) *ssh_config.Host { for _, host := range cfg.Hosts { if r.hostContainsPattern(host, alias) { return host } } return nil } // hostContainsPattern checks if a host contains a specific pattern. func (r *Repository) hostContainsPattern(host *ssh_config.Host, target string) bool { for _, pattern := range host.Patterns { if pattern.String() == target { return true } } return false } // createHostFromServer creates a new ssh_config.Host from a domain.Server. func (r *Repository) createHostFromServer(server domain.Server) *ssh_config.Host { host := &ssh_config.Host{ Patterns: []*ssh_config.Pattern{ {Str: server.Alias}, }, Nodes: make([]ssh_config.Node, 0), EOLComment: "Added by lazyssh", SpaceBeforeComment: strings.Repeat(" ", 4), } // Basic config - always present r.addKVNodeIfNotEmpty(host, "HostName", server.Host) r.addKVNodeIfNotEmpty(host, "User", server.User) 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 } // addKVNodeIfNotEmpty adds a key-value node to the host if the value is not empty. func (r *Repository) addKVNodeIfNotEmpty(host *ssh_config.Host, key, value string) { if value == "" { return } kvNode := &ssh_config.KV{ Key: key, Value: value, LeadingSpace: 4, } r.insertKVNodeAfterLastKV(host, kvNode) } // insertKVNodeAfterLastKV inserts a KV node immediately after the last existing KV node in the host. // This preserves any trailing non-KV nodes (blank lines, comments) that may exist after the host's // configuration block, preventing formatting shifts when adding new fields to a host entry. // // Example: If a host block has trailing blank lines separating it from the next host entry, // this function ensures new fields are inserted before those blank lines, maintaining the // visual separation between host blocks. func (r *Repository) insertKVNodeAfterLastKV(host *ssh_config.Host, kvNode *ssh_config.KV) { // STEP 1: Find the last KV node (search backwards) lastKVIndex := -1 for i := len(host.Nodes) - 1; i >= 0; i-- { if _, ok := host.Nodes[i].(*ssh_config.KV); ok { lastKVIndex = i break } } // STEP 2: Handle case where no KV nodes exist if lastKVIndex == -1 { if len(host.Nodes) == 0 { // Case A: Empty host - just append host.Nodes = append(host.Nodes, kvNode) } else { // Case B: Only comments/blanks exist - prepend before them host.Nodes = append([]ssh_config.Node{kvNode}, host.Nodes...) } return } // STEP 3: We found KV nodes - insert after the last one insertAt := lastKVIndex + 1 if insertAt == len(host.Nodes) { // Case C: Last KV is at the end - just append host.Nodes = append(host.Nodes, kvNode) return } // Case D: Last KV has trailing nodes (blanks/comments) - insert between them host.Nodes = append(host.Nodes[:insertAt], append([]ssh_config.Node{kvNode}, host.Nodes[insertAt:]...)...) } // 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) { // 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 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. func (r *Repository) updateOrAddKVNode(host *ssh_config.Host, key, newValue string) { // Try to update existing node for _, node := range host.Nodes { kvNode, ok := node.(*ssh_config.KV) if ok && strings.EqualFold(kvNode.Key, key) { kvNode.Value = newValue return } } // Add new node if not found kvNode := &ssh_config.KV{ Key: r.getProperKeyCase(key), Value: newValue, LeadingSpace: 4, } r.insertKVNodeAfterLastKV(host, 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", "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 { return properCase } 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 { if r.hostContainsPattern(host, alias) { return append(hosts[:i], hosts[i+1:]...) } } return hosts }