mirror of
https://github.com/Adembc/lazyssh.git
synced 2026-07-14 12:13:34 +02:00
feat: Add 60+ SSH config field support with enhanced UI (#50)
This commit is contained in:
@@ -103,13 +103,89 @@ func (r *Repository) createHostFromServer(server domain.Server) *ssh_config.Host
|
||||
SpaceBeforeComment: strings.Repeat(" ", 4),
|
||||
}
|
||||
|
||||
// Basic config - always present
|
||||
r.addKVNodeIfNotEmpty(host, "HostName", server.Host)
|
||||
r.addKVNodeIfNotEmpty(host, "User", server.User)
|
||||
r.addKVNodeIfNotEmpty(host, "Port", fmt.Sprintf("%d", server.Port))
|
||||
if server.Port != 0 {
|
||||
r.addKVNodeIfNotEmpty(host, "Port", fmt.Sprintf("%d", server.Port))
|
||||
}
|
||||
for _, identityFile := range server.IdentityFiles {
|
||||
r.addKVNodeIfNotEmpty(host, "IdentityFile", identityFile)
|
||||
}
|
||||
|
||||
// Connection and proxy settings
|
||||
r.addKVNodeIfNotEmpty(host, "ProxyJump", server.ProxyJump)
|
||||
r.addKVNodeIfNotEmpty(host, "ProxyCommand", server.ProxyCommand)
|
||||
r.addKVNodeIfNotEmpty(host, "RemoteCommand", server.RemoteCommand)
|
||||
r.addKVNodeIfNotEmpty(host, "RequestTTY", server.RequestTTY)
|
||||
r.addKVNodeIfNotEmpty(host, "ConnectTimeout", server.ConnectTimeout)
|
||||
r.addKVNodeIfNotEmpty(host, "ConnectionAttempts", server.ConnectionAttempts)
|
||||
|
||||
// Port forwarding
|
||||
for _, forward := range server.LocalForward {
|
||||
configFormat := r.convertCLIForwardToConfigFormat(forward)
|
||||
r.addKVNodeIfNotEmpty(host, "LocalForward", configFormat)
|
||||
}
|
||||
for _, forward := range server.RemoteForward {
|
||||
configFormat := r.convertCLIForwardToConfigFormat(forward)
|
||||
r.addKVNodeIfNotEmpty(host, "RemoteForward", configFormat)
|
||||
}
|
||||
for _, forward := range server.DynamicForward {
|
||||
r.addKVNodeIfNotEmpty(host, "DynamicForward", forward)
|
||||
}
|
||||
|
||||
// Authentication and key management
|
||||
r.addKVNodeIfNotEmpty(host, "PubkeyAuthentication", server.PubkeyAuthentication)
|
||||
r.addKVNodeIfNotEmpty(host, "PubkeyAcceptedAlgorithms", server.PubkeyAcceptedAlgorithms)
|
||||
r.addKVNodeIfNotEmpty(host, "HostbasedAcceptedAlgorithms", server.HostbasedAcceptedAlgorithms)
|
||||
r.addKVNodeIfNotEmpty(host, "PasswordAuthentication", server.PasswordAuthentication)
|
||||
r.addKVNodeIfNotEmpty(host, "PreferredAuthentications", server.PreferredAuthentications)
|
||||
r.addKVNodeIfNotEmpty(host, "IdentitiesOnly", server.IdentitiesOnly)
|
||||
r.addKVNodeIfNotEmpty(host, "AddKeysToAgent", server.AddKeysToAgent)
|
||||
r.addKVNodeIfNotEmpty(host, "IdentityAgent", server.IdentityAgent)
|
||||
|
||||
// Agent and X11 forwarding
|
||||
r.addKVNodeIfNotEmpty(host, "ForwardAgent", server.ForwardAgent)
|
||||
r.addKVNodeIfNotEmpty(host, "ForwardX11", server.ForwardX11)
|
||||
r.addKVNodeIfNotEmpty(host, "ForwardX11Trusted", server.ForwardX11Trusted)
|
||||
|
||||
// Connection multiplexing
|
||||
r.addKVNodeIfNotEmpty(host, "ControlMaster", server.ControlMaster)
|
||||
r.addKVNodeIfNotEmpty(host, "ControlPath", server.ControlPath)
|
||||
r.addKVNodeIfNotEmpty(host, "ControlPersist", server.ControlPersist)
|
||||
|
||||
// Connection reliability
|
||||
r.addKVNodeIfNotEmpty(host, "ServerAliveInterval", server.ServerAliveInterval)
|
||||
r.addKVNodeIfNotEmpty(host, "ServerAliveCountMax", server.ServerAliveCountMax)
|
||||
r.addKVNodeIfNotEmpty(host, "Compression", server.Compression)
|
||||
r.addKVNodeIfNotEmpty(host, "TCPKeepAlive", server.TCPKeepAlive)
|
||||
r.addKVNodeIfNotEmpty(host, "BatchMode", server.BatchMode)
|
||||
|
||||
// Security
|
||||
r.addKVNodeIfNotEmpty(host, "StrictHostKeyChecking", server.StrictHostKeyChecking)
|
||||
r.addKVNodeIfNotEmpty(host, "UserKnownHostsFile", server.UserKnownHostsFile)
|
||||
r.addKVNodeIfNotEmpty(host, "HostKeyAlgorithms", server.HostKeyAlgorithms)
|
||||
r.addKVNodeIfNotEmpty(host, "VerifyHostKeyDNS", server.VerifyHostKeyDNS)
|
||||
r.addKVNodeIfNotEmpty(host, "UpdateHostKeys", server.UpdateHostKeys)
|
||||
r.addKVNodeIfNotEmpty(host, "HashKnownHosts", server.HashKnownHosts)
|
||||
r.addKVNodeIfNotEmpty(host, "VisualHostKey", server.VisualHostKey)
|
||||
|
||||
// Command execution
|
||||
r.addKVNodeIfNotEmpty(host, "LocalCommand", server.LocalCommand)
|
||||
r.addKVNodeIfNotEmpty(host, "PermitLocalCommand", server.PermitLocalCommand)
|
||||
r.addKVNodeIfNotEmpty(host, "EscapeChar", server.EscapeChar)
|
||||
|
||||
// Environment settings
|
||||
for _, env := range server.SendEnv {
|
||||
r.addKVNodeIfNotEmpty(host, "SendEnv", env)
|
||||
}
|
||||
for _, env := range server.SetEnv {
|
||||
r.addKVNodeIfNotEmpty(host, "SetEnv", env)
|
||||
}
|
||||
|
||||
// Debugging
|
||||
r.addKVNodeIfNotEmpty(host, "LogLevel", server.LogLevel)
|
||||
|
||||
return host
|
||||
}
|
||||
|
||||
@@ -127,38 +203,136 @@ func (r *Repository) addKVNodeIfNotEmpty(host *ssh_config.Host, key, value strin
|
||||
host.Nodes = append(host.Nodes, kvNode)
|
||||
}
|
||||
|
||||
// removeNodesByKey removes all nodes with the specified key from the nodes slice
|
||||
func removeNodesByKey(nodes []ssh_config.Node, key string) []ssh_config.Node {
|
||||
filtered := make([]ssh_config.Node, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
if kv, ok := node.(*ssh_config.KV); ok {
|
||||
if strings.EqualFold(kv.Key, key) {
|
||||
continue // skip nodes with matching key
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// updateHostNodes updates the nodes of an existing host with new server details.
|
||||
func (r *Repository) updateHostNodes(host *ssh_config.Host, newServer domain.Server) {
|
||||
updates := map[string]string{
|
||||
"hostname": newServer.Host,
|
||||
"user": newServer.User,
|
||||
"port": fmt.Sprintf("%d", newServer.Port),
|
||||
// Handle Port - include if explicitly set (even if it's 22)
|
||||
portValue := ""
|
||||
if newServer.Port != 0 {
|
||||
portValue = fmt.Sprintf("%d", newServer.Port)
|
||||
}
|
||||
|
||||
updates := map[string]string{
|
||||
"hostname": newServer.Host,
|
||||
"user": newServer.User,
|
||||
"port": portValue,
|
||||
"proxycommand": newServer.ProxyCommand,
|
||||
"proxyjump": newServer.ProxyJump,
|
||||
"remotecommand": newServer.RemoteCommand,
|
||||
"requesttty": newServer.RequestTTY,
|
||||
"sessiontype": newServer.SessionType,
|
||||
"connecttimeout": newServer.ConnectTimeout,
|
||||
"connectionattempts": newServer.ConnectionAttempts,
|
||||
"bindaddress": newServer.BindAddress,
|
||||
"bindinterface": newServer.BindInterface,
|
||||
"addressfamily": newServer.AddressFamily,
|
||||
"exitonforwardfailure": newServer.ExitOnForwardFailure,
|
||||
"ipqos": newServer.IPQoS,
|
||||
"canonicalizehostname": newServer.CanonicalizeHostname,
|
||||
"canonicaldomains": newServer.CanonicalDomains,
|
||||
"canonicalizefallbacklocal": newServer.CanonicalizeFallbackLocal,
|
||||
"canonicalizemaxdots": newServer.CanonicalizeMaxDots,
|
||||
"canonicalizepermittedcnames": newServer.CanonicalizePermittedCNAMEs,
|
||||
"clearallforwardings": newServer.ClearAllForwardings,
|
||||
"gatewayports": newServer.GatewayPorts,
|
||||
"pubkeyauthentication": newServer.PubkeyAuthentication,
|
||||
"passwordauthentication": newServer.PasswordAuthentication,
|
||||
"preferredauthentications": newServer.PreferredAuthentications,
|
||||
"pubkeyacceptedalgorithms": newServer.PubkeyAcceptedAlgorithms,
|
||||
"pubkeyacceptedkeytypes": newServer.PubkeyAcceptedAlgorithms, // Deprecated alias (since OpenSSH 8.5)
|
||||
"hostbasedacceptedalgorithms": newServer.HostbasedAcceptedAlgorithms,
|
||||
"hostbasedkeytypes": newServer.HostbasedAcceptedAlgorithms, // Deprecated alias (since OpenSSH 8.5)
|
||||
"hostbasedacceptedkeytypes": newServer.HostbasedAcceptedAlgorithms, // Deprecated alias (since OpenSSH 8.5)
|
||||
"identitiesonly": newServer.IdentitiesOnly,
|
||||
"addkeystoagent": newServer.AddKeysToAgent,
|
||||
"identityagent": newServer.IdentityAgent,
|
||||
"kbdinteractiveauthentication": newServer.KbdInteractiveAuthentication,
|
||||
"challengeresponseauthentication": newServer.KbdInteractiveAuthentication, // Deprecated alias
|
||||
"numberofpasswordprompts": newServer.NumberOfPasswordPrompts,
|
||||
"forwardagent": newServer.ForwardAgent,
|
||||
"forwardx11": newServer.ForwardX11,
|
||||
"forwardx11trusted": newServer.ForwardX11Trusted,
|
||||
"controlmaster": newServer.ControlMaster,
|
||||
"controlpath": newServer.ControlPath,
|
||||
"controlpersist": newServer.ControlPersist,
|
||||
"serveraliveinterval": newServer.ServerAliveInterval,
|
||||
"serveralivecountmax": newServer.ServerAliveCountMax,
|
||||
"compression": newServer.Compression,
|
||||
"tcpkeepalive": newServer.TCPKeepAlive,
|
||||
"batchmode": newServer.BatchMode,
|
||||
"stricthostkeychecking": newServer.StrictHostKeyChecking,
|
||||
"checkhostip": newServer.CheckHostIP,
|
||||
"fingerprinthash": newServer.FingerprintHash,
|
||||
"userknownhostsfile": newServer.UserKnownHostsFile,
|
||||
"hostkeyalgorithms": newServer.HostKeyAlgorithms,
|
||||
"macs": newServer.MACs,
|
||||
"ciphers": newServer.Ciphers,
|
||||
"kexalgorithms": newServer.KexAlgorithms,
|
||||
"verifyhostkeydns": newServer.VerifyHostKeyDNS,
|
||||
"updatehostkeys": newServer.UpdateHostKeys,
|
||||
"hashknownhosts": newServer.HashKnownHosts,
|
||||
"visualhostkey": newServer.VisualHostKey,
|
||||
"localcommand": newServer.LocalCommand,
|
||||
"permitlocalcommand": newServer.PermitLocalCommand,
|
||||
"escapechar": newServer.EscapeChar,
|
||||
"loglevel": newServer.LogLevel,
|
||||
}
|
||||
|
||||
// Update or remove nodes based on value
|
||||
for key, value := range updates {
|
||||
if value != "" {
|
||||
r.updateOrAddKVNode(host, key, value)
|
||||
} else {
|
||||
// Remove the key if value is empty (user selected default)
|
||||
r.removeKVNode(host, key)
|
||||
}
|
||||
}
|
||||
// Replace IdentityFile entries entirely to reflect the new state.
|
||||
// This ensures removing/clearing identity files works as expected.
|
||||
|
||||
removeKey := func(nodes []ssh_config.Node, key string) []ssh_config.Node {
|
||||
filtered := make([]ssh_config.Node, 0, len(nodes))
|
||||
for _, node := range nodes {
|
||||
if kv, ok := node.(*ssh_config.KV); ok {
|
||||
if strings.EqualFold(kv.Key, key) {
|
||||
continue // skip existing IdentityFile
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
host.Nodes = removeKey(host.Nodes, "IdentityFile")
|
||||
|
||||
// Replace multi-value entries entirely to reflect the new state
|
||||
host.Nodes = removeNodesByKey(host.Nodes, "IdentityFile")
|
||||
for _, identityFile := range newServer.IdentityFiles {
|
||||
r.addKVNodeIfNotEmpty(host, "IdentityFile", identityFile)
|
||||
}
|
||||
|
||||
host.Nodes = removeNodesByKey(host.Nodes, "LocalForward")
|
||||
for _, forward := range newServer.LocalForward {
|
||||
configFormat := r.convertCLIForwardToConfigFormat(forward)
|
||||
r.addKVNodeIfNotEmpty(host, "LocalForward", configFormat)
|
||||
}
|
||||
|
||||
host.Nodes = removeNodesByKey(host.Nodes, "RemoteForward")
|
||||
for _, forward := range newServer.RemoteForward {
|
||||
configFormat := r.convertCLIForwardToConfigFormat(forward)
|
||||
r.addKVNodeIfNotEmpty(host, "RemoteForward", configFormat)
|
||||
}
|
||||
|
||||
host.Nodes = removeNodesByKey(host.Nodes, "DynamicForward")
|
||||
for _, forward := range newServer.DynamicForward {
|
||||
r.addKVNodeIfNotEmpty(host, "DynamicForward", forward)
|
||||
}
|
||||
|
||||
host.Nodes = removeNodesByKey(host.Nodes, "SendEnv")
|
||||
for _, env := range newServer.SendEnv {
|
||||
r.addKVNodeIfNotEmpty(host, "SendEnv", env)
|
||||
}
|
||||
|
||||
host.Nodes = removeNodesByKey(host.Nodes, "SetEnv")
|
||||
for _, env := range newServer.SetEnv {
|
||||
r.addKVNodeIfNotEmpty(host, "SetEnv", env)
|
||||
}
|
||||
}
|
||||
|
||||
// updateOrAddKVNode updates an existing key-value node or adds a new one if it doesn't exist.
|
||||
@@ -183,14 +357,93 @@ func (r *Repository) updateOrAddKVNode(host *ssh_config.Host, key, newValue stri
|
||||
host.Nodes = append(host.Nodes, kvNode)
|
||||
}
|
||||
|
||||
// removeKVNode removes a key-value node from the host if it exists.
|
||||
func (r *Repository) removeKVNode(host *ssh_config.Host, key string) {
|
||||
filtered := make([]ssh_config.Node, 0, len(host.Nodes))
|
||||
for _, node := range host.Nodes {
|
||||
if kvNode, ok := node.(*ssh_config.KV); ok {
|
||||
if strings.EqualFold(kvNode.Key, key) {
|
||||
continue // Skip this node (remove it)
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, node)
|
||||
}
|
||||
host.Nodes = filtered
|
||||
}
|
||||
|
||||
// getProperKeyCase returns the proper case for known SSH config keys.
|
||||
// Reference: https://www.ssh.com/academy/ssh/config
|
||||
func (r *Repository) getProperKeyCase(key string) string {
|
||||
keyMap := map[string]string{
|
||||
"hostname": "HostName",
|
||||
"user": "User",
|
||||
"port": "Port",
|
||||
"identityfile": "IdentityFile",
|
||||
"hostname": "HostName",
|
||||
"user": "User",
|
||||
"port": "Port",
|
||||
"identityfile": "IdentityFile",
|
||||
"proxycommand": "ProxyCommand",
|
||||
"proxyjump": "ProxyJump",
|
||||
"remotecommand": "RemoteCommand",
|
||||
"requesttty": "RequestTTY",
|
||||
"sessiontype": "SessionType",
|
||||
"connecttimeout": "ConnectTimeout",
|
||||
"connectionattempts": "ConnectionAttempts",
|
||||
"bindaddress": "BindAddress",
|
||||
"bindinterface": "BindInterface",
|
||||
"addressfamily": "AddressFamily",
|
||||
"exitonforwardfailure": "ExitOnForwardFailure",
|
||||
"ipqos": "IPQoS",
|
||||
"canonicalizehostname": "CanonicalizeHostname",
|
||||
"canonicaldomains": "CanonicalDomains",
|
||||
"canonicalizefallbacklocal": "CanonicalizeFallbackLocal",
|
||||
"canonicalizemaxdots": "CanonicalizeMaxDots",
|
||||
"canonicalizepermittedcnames": "CanonicalizePermittedCNAMEs",
|
||||
"localforward": "LocalForward",
|
||||
"remoteforward": "RemoteForward",
|
||||
"dynamicforward": "DynamicForward",
|
||||
"clearallforwardings": "ClearAllForwardings",
|
||||
"gatewayports": "GatewayPorts",
|
||||
"pubkeyauthentication": "PubkeyAuthentication",
|
||||
"passwordauthentication": "PasswordAuthentication",
|
||||
"preferredauthentications": "PreferredAuthentications",
|
||||
"pubkeyacceptedalgorithms": "PubkeyAcceptedAlgorithms",
|
||||
"pubkeyacceptedkeytypes": "PubkeyAcceptedAlgorithms", // Deprecated alias (since OpenSSH 8.5)
|
||||
"hostbasedacceptedalgorithms": "HostbasedAcceptedAlgorithms",
|
||||
"hostbasedkeytypes": "HostbasedAcceptedAlgorithms", // Deprecated alias (since OpenSSH 8.5)
|
||||
"hostbasedacceptedkeytypes": "HostbasedAcceptedAlgorithms", // Deprecated alias (since OpenSSH 8.5)
|
||||
"identitiesonly": "IdentitiesOnly",
|
||||
"addkeystoagent": "AddKeysToAgent",
|
||||
"identityagent": "IdentityAgent",
|
||||
"kbdinteractiveauthentication": "KbdInteractiveAuthentication",
|
||||
"challengeresponseauthentication": "KbdInteractiveAuthentication", // Deprecated alias
|
||||
"numberofpasswordprompts": "NumberOfPasswordPrompts",
|
||||
"forwardagent": "ForwardAgent",
|
||||
"forwardx11": "ForwardX11",
|
||||
"forwardx11trusted": "ForwardX11Trusted",
|
||||
"controlmaster": "ControlMaster",
|
||||
"controlpath": "ControlPath",
|
||||
"controlpersist": "ControlPersist",
|
||||
"serveraliveinterval": "ServerAliveInterval",
|
||||
"serveralivecountmax": "ServerAliveCountMax",
|
||||
"compression": "Compression",
|
||||
"tcpkeepalive": "TCPKeepAlive",
|
||||
"stricthostkeychecking": "StrictHostKeyChecking",
|
||||
"checkhostip": "CheckHostIP",
|
||||
"fingerprinthash": "FingerprintHash",
|
||||
"verifyhostkeydns": "VerifyHostKeyDNS",
|
||||
"updatehostkeys": "UpdateHostKeys",
|
||||
"hashknownhosts": "HashKnownHosts",
|
||||
"visualhostkey": "VisualHostKey",
|
||||
"userknownhostsfile": "UserKnownHostsFile",
|
||||
"hostkeyalgorithms": "HostKeyAlgorithms",
|
||||
"macs": "MACs",
|
||||
"ciphers": "Ciphers",
|
||||
"kexalgorithms": "KexAlgorithms",
|
||||
"localcommand": "LocalCommand",
|
||||
"permitlocalcommand": "PermitLocalCommand",
|
||||
"escapechar": "EscapeChar",
|
||||
"sendenv": "SendEnv",
|
||||
"setenv": "SetEnv",
|
||||
"loglevel": "LogLevel",
|
||||
"batchmode": "BatchMode",
|
||||
}
|
||||
|
||||
if properCase, exists := keyMap[strings.ToLower(key)]; exists {
|
||||
@@ -199,6 +452,79 @@ func (r *Repository) getProperKeyCase(key string) string {
|
||||
return key
|
||||
}
|
||||
|
||||
// convertCLIForwardToConfigFormat converts CLI format forwarding spec to SSH config format.
|
||||
// CLI format: [bind_address:]port:host:hostport
|
||||
// Config format: [bind_address:]port host:hostport
|
||||
func (r *Repository) convertCLIForwardToConfigFormat(forward string) string {
|
||||
// Handle IPv6 addresses in brackets like [2001:db8::1]
|
||||
// These should be treated as a single unit
|
||||
|
||||
// Find the last `:digits` that represents the final port
|
||||
lastPortStart := -1
|
||||
for i := len(forward) - 1; i >= 0; i-- {
|
||||
if forward[i] == ':' {
|
||||
// Check if everything after this colon is digits
|
||||
if i+1 < len(forward) {
|
||||
allDigits := true
|
||||
hasDigits := false
|
||||
for j := i + 1; j < len(forward); j++ {
|
||||
if forward[j] >= '0' && forward[j] <= '9' {
|
||||
hasDigits = true
|
||||
} else {
|
||||
allDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDigits && hasDigits {
|
||||
lastPortStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lastPortStart == -1 {
|
||||
// No port at the end, return as-is
|
||||
return forward
|
||||
}
|
||||
|
||||
// Now find the split point between local and remote parts
|
||||
// We need to handle bracket-enclosed addresses specially
|
||||
inBrackets := 0
|
||||
for i := lastPortStart - 1; i >= 0; i-- {
|
||||
switch forward[i] {
|
||||
case ']':
|
||||
inBrackets++
|
||||
case '[':
|
||||
inBrackets--
|
||||
case ':':
|
||||
if inBrackets != 0 {
|
||||
continue
|
||||
}
|
||||
// This colon is not inside brackets
|
||||
// Check if this looks like it could be the split point
|
||||
// The split point would be after a port number (digits after a colon)
|
||||
|
||||
// Look ahead to see what comes after this colon
|
||||
nextChar := byte(' ')
|
||||
if i+1 < len(forward) {
|
||||
nextChar = forward[i+1]
|
||||
}
|
||||
|
||||
// If the next character could be start of a host (letter, digit, bracket)
|
||||
// then this is our split point
|
||||
if nextChar != ':' {
|
||||
localPart := forward[:i]
|
||||
remotePart := forward[i+1:]
|
||||
return localPart + " " + remotePart
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no split point found, return as-is
|
||||
return forward
|
||||
}
|
||||
|
||||
// removeHostByAlias removes a host by its alias from the list of hosts.
|
||||
func (r *Repository) removeHostByAlias(hosts []*ssh_config.Host, alias string) []*ssh_config.Host {
|
||||
for i, host := range hosts {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright 2025.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ssh_config_file
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConvertCLIForwardToConfigFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "basic local forward",
|
||||
input: "8080:localhost:80",
|
||||
expected: "8080 localhost:80",
|
||||
},
|
||||
{
|
||||
name: "local forward with bind address",
|
||||
input: "127.0.0.1:8080:localhost:80",
|
||||
expected: "127.0.0.1:8080 localhost:80",
|
||||
},
|
||||
{
|
||||
name: "local forward with wildcard bind",
|
||||
input: "*:8080:localhost:80",
|
||||
expected: "*:8080 localhost:80",
|
||||
},
|
||||
{
|
||||
name: "remote forward",
|
||||
input: "8080:localhost:3000",
|
||||
expected: "8080 localhost:3000",
|
||||
},
|
||||
{
|
||||
name: "remote forward with bind address",
|
||||
input: "0.0.0.0:80:localhost:8080",
|
||||
expected: "0.0.0.0:80 localhost:8080",
|
||||
},
|
||||
{
|
||||
name: "forward with IPv6 address",
|
||||
input: "8080:[2001:db8::1]:80",
|
||||
expected: "8080 [2001:db8::1]:80",
|
||||
},
|
||||
{
|
||||
name: "forward with domain",
|
||||
input: "3306:db.example.com:3306",
|
||||
expected: "3306 db.example.com:3306",
|
||||
},
|
||||
{
|
||||
name: "invalid format - only one colon",
|
||||
input: "8080:localhost",
|
||||
expected: "8080:localhost", // returned as-is
|
||||
},
|
||||
{
|
||||
name: "invalid format - no colons",
|
||||
input: "8080",
|
||||
expected: "8080", // returned as-is
|
||||
},
|
||||
}
|
||||
|
||||
r := &Repository{}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := r.convertCLIForwardToConfigFormat(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("convertCLIForwardToConfigFormat(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertConfigForwardToCLIFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "basic local forward",
|
||||
input: "8080 localhost:80",
|
||||
expected: "8080:localhost:80",
|
||||
},
|
||||
{
|
||||
name: "local forward with bind address",
|
||||
input: "127.0.0.1:8080 localhost:80",
|
||||
expected: "127.0.0.1:8080:localhost:80",
|
||||
},
|
||||
{
|
||||
name: "local forward with wildcard bind",
|
||||
input: "*:8080 localhost:80",
|
||||
expected: "*:8080:localhost:80",
|
||||
},
|
||||
{
|
||||
name: "remote forward",
|
||||
input: "8080 localhost:3000",
|
||||
expected: "8080:localhost:3000",
|
||||
},
|
||||
{
|
||||
name: "remote forward with bind address",
|
||||
input: "0.0.0.0:80 localhost:8080",
|
||||
expected: "0.0.0.0:80:localhost:8080",
|
||||
},
|
||||
{
|
||||
name: "forward with IPv6 address",
|
||||
input: "8080 [2001:db8::1]:80",
|
||||
expected: "8080:[2001:db8::1]:80",
|
||||
},
|
||||
{
|
||||
name: "forward with domain",
|
||||
input: "3306 db.example.com:3306",
|
||||
expected: "3306:db.example.com:3306",
|
||||
},
|
||||
{
|
||||
name: "already in CLI format",
|
||||
input: "8080:localhost:80",
|
||||
expected: "8080:localhost:80", // returned as-is
|
||||
},
|
||||
{
|
||||
name: "no space separator",
|
||||
input: "8080",
|
||||
expected: "8080", // returned as-is
|
||||
},
|
||||
}
|
||||
|
||||
r := &Repository{}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := r.convertConfigForwardToCLIFormat(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("convertConfigForwardToCLIFormat(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,19 +65,230 @@ func (r *Repository) toDomainServer(cfg *ssh_config.Config) []domain.Server {
|
||||
|
||||
// mapKVToServer maps an ssh_config.KV node to the corresponding fields in domain.Server.
|
||||
func (r *Repository) mapKVToServer(server *domain.Server, kvNode *ssh_config.KV) {
|
||||
switch strings.ToLower(kvNode.Key) {
|
||||
key := strings.ToLower(kvNode.Key)
|
||||
value := kvNode.Value
|
||||
|
||||
// Try mapping in order of categories
|
||||
if r.mapBasicConfig(server, key, value) {
|
||||
return
|
||||
}
|
||||
if r.mapConnectionConfig(server, key, value) {
|
||||
return
|
||||
}
|
||||
if r.mapForwardingConfig(server, key, value) {
|
||||
return
|
||||
}
|
||||
if r.mapAuthenticationConfig(server, key, value) {
|
||||
return
|
||||
}
|
||||
if r.mapSecurityConfig(server, key, value) {
|
||||
return
|
||||
}
|
||||
if r.mapEnvironmentConfig(server, key, value) {
|
||||
return
|
||||
}
|
||||
r.mapDebugConfig(server, key, value)
|
||||
}
|
||||
|
||||
// mapBasicConfig maps basic SSH configuration fields
|
||||
func (r *Repository) mapBasicConfig(server *domain.Server, key, value string) bool {
|
||||
switch key {
|
||||
case "hostname":
|
||||
server.Host = kvNode.Value
|
||||
server.Host = value
|
||||
case "user":
|
||||
server.User = kvNode.Value
|
||||
server.User = value
|
||||
case "port":
|
||||
port, err := strconv.Atoi(kvNode.Value)
|
||||
port, err := strconv.Atoi(value)
|
||||
if err == nil {
|
||||
server.Port = port
|
||||
}
|
||||
case "identityfile":
|
||||
server.IdentityFiles = append(server.IdentityFiles, kvNode.Value)
|
||||
server.IdentityFiles = append(server.IdentityFiles, value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// mapConnectionConfig maps connection and proxy configuration fields
|
||||
func (r *Repository) mapConnectionConfig(server *domain.Server, key, value string) bool {
|
||||
switch key {
|
||||
case "proxycommand":
|
||||
server.ProxyCommand = value
|
||||
case "proxyjump":
|
||||
server.ProxyJump = value
|
||||
case "remotecommand":
|
||||
server.RemoteCommand = value
|
||||
case "requesttty":
|
||||
server.RequestTTY = value
|
||||
case "sessiontype":
|
||||
server.SessionType = value
|
||||
case "connecttimeout":
|
||||
server.ConnectTimeout = value
|
||||
case "connectionattempts":
|
||||
server.ConnectionAttempts = value
|
||||
case "bindaddress":
|
||||
server.BindAddress = value
|
||||
case "bindinterface":
|
||||
server.BindInterface = value
|
||||
case "addressfamily":
|
||||
server.AddressFamily = value
|
||||
case "exitonforwardfailure":
|
||||
server.ExitOnForwardFailure = value
|
||||
case "ipqos":
|
||||
server.IPQoS = value
|
||||
case "canonicalizehostname":
|
||||
server.CanonicalizeHostname = value
|
||||
case "canonicaldomains":
|
||||
server.CanonicalDomains = value
|
||||
case "canonicalizefallbacklocal":
|
||||
server.CanonicalizeFallbackLocal = value
|
||||
case "canonicalizemaxdots":
|
||||
server.CanonicalizeMaxDots = value
|
||||
case "canonicalizepermittedcnames":
|
||||
server.CanonicalizePermittedCNAMEs = value
|
||||
case "serveraliveinterval":
|
||||
server.ServerAliveInterval = value
|
||||
case "serveralivecountmax":
|
||||
server.ServerAliveCountMax = value
|
||||
case "compression":
|
||||
server.Compression = value
|
||||
case "tcpkeepalive":
|
||||
server.TCPKeepAlive = value
|
||||
case "batchmode":
|
||||
server.BatchMode = value
|
||||
case "controlmaster":
|
||||
server.ControlMaster = value
|
||||
case "controlpath":
|
||||
server.ControlPath = value
|
||||
case "controlpersist":
|
||||
server.ControlPersist = value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// mapForwardingConfig maps port forwarding and agent forwarding fields
|
||||
func (r *Repository) mapForwardingConfig(server *domain.Server, key, value string) bool {
|
||||
switch key {
|
||||
case "localforward":
|
||||
cliFormat := r.convertConfigForwardToCLIFormat(value)
|
||||
server.LocalForward = append(server.LocalForward, cliFormat)
|
||||
case "remoteforward":
|
||||
cliFormat := r.convertConfigForwardToCLIFormat(value)
|
||||
server.RemoteForward = append(server.RemoteForward, cliFormat)
|
||||
case "dynamicforward":
|
||||
server.DynamicForward = append(server.DynamicForward, value)
|
||||
case "clearallforwardings":
|
||||
server.ClearAllForwardings = value
|
||||
case "gatewayports":
|
||||
server.GatewayPorts = value
|
||||
case "forwardagent":
|
||||
server.ForwardAgent = value
|
||||
case "forwardx11":
|
||||
server.ForwardX11 = value
|
||||
case "forwardx11trusted":
|
||||
server.ForwardX11Trusted = value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// mapAuthenticationConfig maps authentication-related fields
|
||||
func (r *Repository) mapAuthenticationConfig(server *domain.Server, key, value string) bool {
|
||||
switch key {
|
||||
case "pubkeyauthentication":
|
||||
server.PubkeyAuthentication = value
|
||||
case "pubkeyacceptedalgorithms", "pubkeyacceptedkeytypes":
|
||||
// PubkeyAcceptedKeyTypes is deprecated alias for PubkeyAcceptedAlgorithms (since OpenSSH 8.5)
|
||||
server.PubkeyAcceptedAlgorithms = value
|
||||
case "hostbasedacceptedalgorithms", "hostbasedkeytypes", "hostbasedacceptedkeytypes":
|
||||
// HostbasedKeyTypes and HostbasedAcceptedKeyTypes are deprecated aliases (since OpenSSH 8.5)
|
||||
server.HostbasedAcceptedAlgorithms = value
|
||||
case "passwordauthentication":
|
||||
server.PasswordAuthentication = value
|
||||
case "preferredauthentications":
|
||||
server.PreferredAuthentications = value
|
||||
case "identitiesonly":
|
||||
server.IdentitiesOnly = value
|
||||
case "addkeystoagent":
|
||||
server.AddKeysToAgent = value
|
||||
case "identityagent":
|
||||
server.IdentityAgent = value
|
||||
case "kbdinteractiveauthentication", "challengeresponseauthentication":
|
||||
// ChallengeResponseAuthentication is deprecated alias for KbdInteractiveAuthentication
|
||||
server.KbdInteractiveAuthentication = value
|
||||
case "numberofpasswordprompts":
|
||||
server.NumberOfPasswordPrompts = value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// mapSecurityConfig maps security-related fields
|
||||
func (r *Repository) mapSecurityConfig(server *domain.Server, key, value string) bool {
|
||||
switch key {
|
||||
case "stricthostkeychecking":
|
||||
server.StrictHostKeyChecking = value
|
||||
case "checkhostip":
|
||||
server.CheckHostIP = value
|
||||
case "fingerprinthash":
|
||||
server.FingerprintHash = value
|
||||
case "userknownhostsfile":
|
||||
server.UserKnownHostsFile = value
|
||||
case "hostkeyalgorithms":
|
||||
server.HostKeyAlgorithms = value
|
||||
case "macs":
|
||||
server.MACs = value
|
||||
case "ciphers":
|
||||
server.Ciphers = value
|
||||
case "kexalgorithms":
|
||||
server.KexAlgorithms = value
|
||||
case "verifyhostkeydns":
|
||||
server.VerifyHostKeyDNS = value
|
||||
case "updatehostkeys":
|
||||
server.UpdateHostKeys = value
|
||||
case "hashknownhosts":
|
||||
server.HashKnownHosts = value
|
||||
case "visualhostkey":
|
||||
server.VisualHostKey = value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// mapEnvironmentConfig maps environment and command execution fields
|
||||
func (r *Repository) mapEnvironmentConfig(server *domain.Server, key, value string) bool {
|
||||
switch key {
|
||||
case "localcommand":
|
||||
server.LocalCommand = value
|
||||
case "permitlocalcommand":
|
||||
server.PermitLocalCommand = value
|
||||
case "escapechar":
|
||||
server.EscapeChar = value
|
||||
case "sendenv":
|
||||
server.SendEnv = append(server.SendEnv, value)
|
||||
case "setenv":
|
||||
server.SetEnv = append(server.SetEnv, value)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// mapDebugConfig maps debugging-related fields
|
||||
func (r *Repository) mapDebugConfig(server *domain.Server, key, value string) bool {
|
||||
switch key {
|
||||
case "loglevel":
|
||||
server.LogLevel = value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// mergeMetadata merges additional metadata into the servers.
|
||||
@@ -104,3 +315,19 @@ func (r *Repository) mergeMetadata(servers []domain.Server, metadata map[string]
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
// convertConfigForwardToCLIFormat converts SSH config format forwarding spec to CLI format.
|
||||
// Config format: [bind_address:]port host:hostport
|
||||
// CLI format: [bind_address:]port:host:hostport
|
||||
func (r *Repository) convertConfigForwardToCLIFormat(forward string) string {
|
||||
// Find the last space which separates the local part from the remote part
|
||||
lastSpace := strings.LastIndex(forward, " ")
|
||||
if lastSpace != -1 {
|
||||
localPart := forward[:lastSpace]
|
||||
remotePart := forward[lastSpace+1:]
|
||||
// Join them with a colon for CLI format
|
||||
return localPart + ":" + remotePart
|
||||
}
|
||||
// If no space found, return as-is (might already be in CLI format)
|
||||
return forward
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user