diff --git a/log/logger.go b/log/logger.go index 6845bd7c..89f35e4f 100644 --- a/log/logger.go +++ b/log/logger.go @@ -58,6 +58,11 @@ func SetTraceLogger(l TraceLogger) { trace = sync.OnceValue(func() TraceLogger { return l }) } +// GetTraceLogger gets the current value of trace logger. +func GetTraceLogger() TraceLogger { + return trace() +} + // TraceLogger is a logger for rig's internal trace logging. type TraceLogger interface { Log(ctx context.Context, level slog.Level, msg string, keysAndValues ...any) diff --git a/protocol/ssh/sshconfig/configvalue.go b/protocol/ssh/sshconfig/configvalue.go index 441cbafc..ad634233 100644 --- a/protocol/ssh/sshconfig/configvalue.go +++ b/protocol/ssh/sshconfig/configvalue.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "math" - "net" "os/user" "path/filepath" "slices" @@ -60,6 +59,8 @@ const ( boolFalse = "false" boolYes = "yes" boolNo = "no" + + fkHost = "host" ) // Value is a generic type for a configuration value. It is necessary to track the origin of the value @@ -72,6 +73,10 @@ type Value[T any] struct { // Set the value and its origin. func (cv *Value[T]) Set(value T, originType ValueOriginType, origin string) { + // if the value is already set and the origin is not defaults, don't override it + if cv.IsSet() && cv.originType != ValueOriginDefault { + return + } cv.value = value cv.originType = originType cv.origin = origin @@ -97,6 +102,7 @@ func (cv Value[T]) Origin() string { return cv.origin } +// IsDefault returns true if the value is set from the defaults. func (cv Value[T]) IsDefault() bool { return cv.originType == ValueOriginDefault } @@ -145,21 +151,21 @@ func (v *BoolValue) String() string { return boolNo } -// MultiStateValue is a configuration value that can be a boolean or a string. Fields like this are used in the +// MultiStateBoolValue is a configuration value that can be a boolean or a string. Fields like this are used in the // ssh configuration for things like "yes/no/ask" or "yes/no/auto". The Bool() function returns the value as a boolean. -type MultiStateValue struct { +type MultiStateBoolValue struct { Value[string] } // SetString sets the value of the multi-state value and its origin. It accepts "yes", "true", "no" and "false" as boolean values. -func (v *MultiStateValue) SetString(value string, originType ValueOriginType, origin string) error { +func (v *MultiStateBoolValue) SetString(value string, originType ValueOriginType, origin string) error { v.Set(value, originType, origin) return nil } // Bool returns the value as a boolean. It returns the boolean value and a boolean indicating if the value was set to a boolean value. // If the value is not set to a boolean value, the string can be retrieved using the Get() function. -func (v *MultiStateValue) Bool() (bool, bool) { +func (v *MultiStateBoolValue) Bool() (bool, bool) { val, ok := v.Get() if !ok { return false, false @@ -174,7 +180,7 @@ func (v *MultiStateValue) Bool() (bool, bool) { } // String returns the value as a string. -func (v *MultiStateValue) String() string { +func (v *MultiStateBoolValue) String() string { val, _ := v.Get() return val } @@ -216,7 +222,7 @@ func (v *DurationValue) SetString(value string, originType ValueOriginType, orig return nil } unit := value[len(value)-1] - if unit >= '0' || unit <= '9' { + if unit >= '0' && unit <= '9' { value += "s" } d, err := time.ParseDuration(value) @@ -233,18 +239,18 @@ func (v *DurationValue) String() string { return val.String() } -// StringSliceValue is a configuration value that holds a slice of strings. When setting the value, it accepts a +// StringListValueis a configuration value that holds a slice of strings. When setting the value, it accepts a // comma-separated or whitespace-separated list of values. The values can be quoted using single or double quotes. // If the existing value is set from the defaults, the slice is cleared before setting the new value. Duplicate // values are ignored. -type StringSliceValue struct { +type StringListValue struct { Value[[]string] } // SetString appends the value to the slice and sets the origin of the value. If the value is already set from any // other origin than the defaults, it appends the new value to the slice. If the value is set from the defaults, it // clears the slice before setting the new values. -func (v *StringSliceValue) SetString(value string, originType ValueOriginType, origin string) error { +func (v *StringListValue) SetString(value string, originType ValueOriginType, origin string) error { var oldVals []string if v.OriginType() != ValueOriginDefault { oldVals, _ = v.Get() @@ -302,21 +308,21 @@ func formatStringSlice(vals []string, sep rune) string { } // String returns the value as a string. -func (v *StringSliceValue) String() string { +func (v *StringListValue) String() string { val, _ := v.Get() return formatStringSlice(val, ',') } -// IntSliceValue is a configuration value that holds a slice of integers. When setting the value, it accepts a +// IntListValue is a configuration value that holds a slice of integers. When setting the value, it accepts a // comma-separated or whitespace-separated list of values. The values can be quoted using single or double quotes. // If the existing value is set from the defaults, the slice is cleared before setting the new value. -type IntSliceValue struct { +type IntListValue struct { Value[[]int] } // SetString appends the value to the slice and sets the origin of the value. If the value is set from the defaults, it // clears the slice before setting the new values. Duplicate values are ignored. -func (v *IntSliceValue) SetString(value string, originType ValueOriginType, origin string) error { +func (v *IntListValue) SetString(value string, originType ValueOriginType, origin string) error { var oldVals []int if v.OriginType() != ValueOriginDefault { oldVals, _ = v.Get() @@ -364,7 +370,7 @@ func (v *IntSliceValue) SetString(value string, originType ValueOriginType, orig } // String returns the value as a string. -func (v *IntSliceValue) String() string { +func (v *IntListValue) String() string { val, _ := v.Get() strSlice := make([]string, len(val)) for i, v := range val { @@ -414,14 +420,14 @@ func (v *PathValue) String() string { return shellescape.Quote(val) } -// PathSliceValue is a list of [PathValue] entries. Duplicates are skipped. If the existing list +// PathListValue a list of [PathValue] entries. Duplicates are skipped. If the existing list // is set from the defaults, the list is cleared before setting the new value. -type PathSliceValue struct { - StringSliceValue +type PathListValue struct { + StringListValue } // SetString appends the value to the slice and sets the origin of the value. -func (v *PathSliceValue) SetString(value string, originType ValueOriginType, origin string) error { +func (v *PathListValue) SetString(value string, originType ValueOriginType, origin string) error { var oldVals []string if originType == ValueOriginDefault || v.OriginType() != ValueOriginDefault { if val, ok := v.Get(); ok { @@ -436,7 +442,9 @@ func (v *PathSliceValue) SetString(value string, originType ValueOriginType, ori for _, path := range paths { np := &PathValue{} - np.SetString(path, originType, origin) + if err := np.SetString(path, originType, origin); err != nil { + return err + } path, _ := np.Get() if !slices.Contains(oldVals, path) { oldVals = append(oldVals, path) @@ -447,44 +455,47 @@ func (v *PathSliceValue) SetString(value string, originType ValueOriginType, ori } // String returns the value as a string. -func (v *PathSliceValue) String() string { +func (v *PathListValue) String() string { val, _ := v.Get() return formatStringSlice(val, ' ') } -// AlwaysAppendStringSliceValue is like [StringValue] but it always appends the value to the existing value. -type AlwaysAppendStringSliceValue struct { - StringSliceValue +// AppendingStringListValue is like a [StringListValue] but it always appends the value to the existing value +// even if a value was already set. +type AppendingStringListValue struct { + StringListValue } // SetString appends the value to the existing value. -func (v *AlwaysAppendStringSliceValue) SetString(value string, originType ValueOriginType, origin string) error { +func (v *AppendingStringListValue) SetString(value string, originType ValueOriginType, origin string) error { if value == "" { return nil } - vals, _ := v.Get() newVals, err := parseStringSliceValue(value) if err != nil { return fmt.Errorf("can't parse string slice value %q: %w", value, err) } - vals = append(vals, newVals...) - v.Set(vals, originType, origin) + v.value = append(v.value, newVals...) + v.originType = originType + v.origin = origin return nil } -// SpecialStringSliceValue is like [StringSliceValue] but the list can be prefixed with +, - or ^ to alter how +// ModifiableStringListValue is like [StringSliceValue] but the list can be prefixed with +, - or ^ to alter how // the list is modified. // // + - appends the value to the existing list // - - removes the given value from the existing list. the values can be wildcard patterns. // ^ - clears the list and sets the value -type SpecialStringSliceValue struct { - StringSliceValue +// +// This is used in several fields in the ssh configuration, such as the lists of algorithms. +type ModifiableStringListValue struct { + StringListValue } // SetString appends the value to the slice and sets the origin of the value or if a prefix is used, // it modifies the list accordingly. -func (v *SpecialStringSliceValue) SetString(value string, originType ValueOriginType, origin string) error { +func (v *ModifiableStringListValue) SetString(value string, originType ValueOriginType, origin string) error { if value == "" { return nil } @@ -512,7 +523,9 @@ func (v *SpecialStringSliceValue) SetString(value string, originType ValueOrigin finalValues = valuesToSet } - v.Set(finalValues, originType, origin) + v.value = finalValues + v.originType = originType + v.origin = origin return nil } @@ -538,7 +551,7 @@ func parseStringSliceValue(value string) ([]string, error) { } // appendUniqueValues adds new values to the slice, avoiding duplicates. -func (v *SpecialStringSliceValue) appendUniqueValues(newValues []string) []string { +func (v *ModifiableStringListValue) appendUniqueValues(newValues []string) []string { existingValues, _ := v.Get() for _, newVal := range newValues { if !slices.Contains(existingValues, newVal) { @@ -549,7 +562,7 @@ func (v *SpecialStringSliceValue) appendUniqueValues(newValues []string) []strin } // removeAllOccurrences removes all occurrences of the specified values from the slice. -func (v *SpecialStringSliceValue) removeAllOccurrences(valuesToRemove []string) []string { +func (v *ModifiableStringListValue) removeAllOccurrences(valuesToRemove []string) []string { existingValues, _ := v.Get() for _, removeVal := range valuesToRemove { existingValues = filterOutMatchPattern(existingValues, removeVal) @@ -558,7 +571,7 @@ func (v *SpecialStringSliceValue) removeAllOccurrences(valuesToRemove []string) } // prependUniqueValues prepends new values to the slice after removing any existing occurrences. -func (v *SpecialStringSliceValue) prependUniqueValues(newValues []string) []string { +func (v *ModifiableStringListValue) prependUniqueValues(newValues []string) []string { existingValues, _ := v.Get() existingValues = filterOutMultiple(existingValues, newValues) return append(newValues, existingValues...) @@ -595,93 +608,16 @@ func filterOutMultiple(slice []string, valuesToRemove []string) []string { return slice } -// NetAddrValue is a configuration value that holds a net.Addr. -type NetAddrValue struct { - Value[net.Addr] -} - -// SetString parses the value as an address and sets the origin of the value. -// It accepts an address in the forms of: -// - hostname -// - hostname:port -// - ipv4:port -// - ipv4 -// - [ipv6-address] -// - [ipv6-address]:port -// - ipv4/netmask -// - [ipv6]/netmask -// - port -// And possibly some others through net.ResolveTCPAddr. -func (v *NetAddrValue) SetString(value string, originType ValueOriginType, origin string) error { - value, err := shellescape.Unquote(value) - if err != nil { - return fmt.Errorf("can't parse address value %q: %w", value, err) - } - if value == "" { - return fmt.Errorf("%w: can't set empty address", ErrInvalidValue) - } - if strings.Contains(value, "/") { - ip, ipnet, err := net.ParseCIDR(value) - if err != nil { - return fmt.Errorf("can't parse CIDR address %q: %w", value, err) - } - v.Set(&net.IPNet{IP: ip, Mask: ipnet.Mask}, originType, origin) - return nil - } - if strings.Contains(value, "[") { - ip, err := net.ResolveIPAddr("ip", value) - if err != nil { - return fmt.Errorf("can't parse IP address %q: %w", value, err) - } - v.Set(ip, originType, origin) - return nil - } - if strings.Contains(value, ":") { - host, port, err := net.SplitHostPort(value) - if err != nil { - return fmt.Errorf("can't parse address %q: %w", value, err) - } - if host == "" { - port = ":" + port - } - addr, err := net.ResolveTCPAddr("tcp", port) - if err != nil { - return fmt.Errorf("can't resolve TCP address %q: %w", value, err) - } - v.Set(addr, originType, origin) - return nil - } - if _, err := strconv.Atoi(value); err == nil { - addr, err := net.ResolveTCPAddr("tcp", value) - if err != nil { - return fmt.Errorf("can't resolve TCP address %q: %w", value, err) - } - v.Set(addr, originType, origin) - return nil - } - addr, err := net.ResolveTCPAddr("tcp", value) - if err != nil { - return fmt.Errorf("can't resolve TCP address %q: %w", value, err) - } - v.Set(addr, originType, origin) - return nil -} - -// String returns the value as a string. -func (v *NetAddrValue) String() string { - val, _ := v.Get() - return val.String() -} - -// SendEnvSliceValue is a configuration value that holds a slice of strings. It is used in the ssh configuration -// for the SendEnv field. Prefixing the value with - removes the value from the list. -type SendEnvSliceValue struct { - StringSliceValue +// RemovableStringListValue is a configuration value that holds a slice of strings. It is used in the ssh configuration +// for the SendEnv field. Prefixing the value with - removes the value from the list. Like [AppendingStringListValue], +// without the prefix it always appends even if a value was set before. +type RemovableStringListValue struct { + StringListValue } // SetString appends the value to the slice and sets the origin of the value or if a prefix is used, // it modifies the list accordingly. -func (v *SendEnvSliceValue) SetString(value string, originType ValueOriginType, origin string) error { +func (v *RemovableStringListValue) SetString(value string, originType ValueOriginType, origin string) error { if value == "" { return nil } @@ -701,6 +637,8 @@ func (v *SendEnvSliceValue) SetString(value string, originType ValueOriginType, } else { vals = append(vals, newVals...) } - v.Set(vals, originType, origin) + v.value = vals + v.originType = originType + v.origin = origin return nil } diff --git a/protocol/ssh/sshconfig/configvalue_test.go b/protocol/ssh/sshconfig/configvalue_test.go index 4fbb4422..b24bb337 100644 --- a/protocol/ssh/sshconfig/configvalue_test.go +++ b/protocol/ssh/sshconfig/configvalue_test.go @@ -9,7 +9,7 @@ import ( func TestConfigValue(t *testing.T) { t.Run("SpecialStringSliceValue", func(t *testing.T) { - ss := sshconfig.SpecialStringSliceValue{} + ss := sshconfig.ModifiableStringListValue{} require.NoError(t, ss.SetString("one,'two',three", sshconfig.ValueOriginOption, "")) val, ok := ss.Get() require.True(t, ok) @@ -43,7 +43,7 @@ func TestConfigValue(t *testing.T) { require.Equal(t, []string{"3", "5", "1", "2", "6"}, val, "should have prepended 3 and 5 and removed the old 5") }) t.Run("SpecialStringSliceValue with pattern", func(t *testing.T) { - ss := sshconfig.SpecialStringSliceValue{} + ss := sshconfig.ModifiableStringListValue{} require.NoError(t, ss.SetString("one,'two',three", sshconfig.ValueOriginOption, "")) val, ok := ss.Get() require.True(t, ok) diff --git a/protocol/ssh/sshconfig/defaultconfig_unix.go b/protocol/ssh/sshconfig/defaultconfig_unix.go index c7a9dd7c..d20bd40a 100644 --- a/protocol/ssh/sshconfig/defaultconfig_unix.go +++ b/protocol/ssh/sshconfig/defaultconfig_unix.go @@ -11,80 +11,80 @@ var defaultGlobalConfigPath = func() string { // note that some of the boolean values are displayed as "true"/"false" // instead of "yes"/"no". const sshDefaultConfig = ` -port 22 +addkeystoagent false addressfamily any batchmode no +canonicaldomains none +canonicalizePermittedcnames none canonicalizefallbacklocal yes canonicalizehostname false +canonicalizemaxdots 1 +casignaturealgorithms ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256 checkhostip no +ciphers chacha20-poly1305@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com +clearallforwardings no compression no +connectionattempts 1 +connecttimeout none controlmaster false +controlpersist no +enableescapecommandline no enablesshkeysign no -clearallforwardings no +escapechar ~ exitonforwardfailure no fingerprinthash SHA256 +forkafterauthentication no +forwardagent no forwardx11 no +forwardx11timeout 1200 forwardx11trusted no gatewayports no +globalknownhostsfile /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2 hashknownhosts no +hostbasedacceptedalgorithms ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256 hostbasedauthentication no +hostkeyalgorithms ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256 identitiesonly no +identityfile ~/.ssh/id_dsa +identityfile ~/.ssh/id_ecdsa +identityfile ~/.ssh/id_ecdsa_sk +identityfile ~/.ssh/id_ed25519 +identityfile ~/.ssh/id_ed25519_sk +identityfile ~/.ssh/id_rsa +identityfile ~/.ssh/id_xmss +ipqos af21 cs1 kbdinteractiveauthentication yes +kexalgorithms sntrup761x25519-sha512@openssh.com,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 +loglevel INFO +logverbose none +macs umac-64-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha1-etm@openssh.com,umac-64@openssh.com,umac-128@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1 nohostauthenticationforlocalhost no +numberofpasswordprompts 3 passwordauthentication yes permitlocalcommand no +permitremoteopen any +port 22 proxyusefdpass no +pubkeyacceptedalgorithms ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256 pubkeyauthentication true +rekeylimit 0 0 requesttty auto +requiredrsasize 1024 +securitykeyprovider internal +serveralivecountmax 3 +serveraliveinterval 0 sessiontype default stdinnull no -forkafterauthentication no +streamlocalbindmask 0177 streamlocalbindunlink no stricthostkeychecking ask +syslogfacility USER tcpkeepalive yes tunnel false +tunneldevice any:any +updatehostkeys true +userknownhostsfile ~/.ssh/known_hosts ~/.ssh/known_hosts2 verifyhostkeydns false visualhostkey no -updatehostkeys true -enableescapecommandline no -canonicalizemaxdots 1 -connectionattempts 1 -forwardx11timeout 1200 -numberofpasswordprompts 3 -serveralivecountmax 3 -serveraliveinterval 0 -requiredrsasize 1024 -ciphers chacha20-poly1305@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com -hostkeyalgorithms ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256 -hostbasedacceptedalgorithms ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256 -kexalgorithms sntrup761x25519-sha512@openssh.com,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 -casignaturealgorithms ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256 -loglevel INFO -macs umac-64-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha1-etm@openssh.com,umac-64@openssh.com,umac-128@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1 -securitykeyprovider internal -pubkeyacceptedalgorithms ssh-ed25519-cert-v01@openssh.com,ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,sk-ssh-ed25519-cert-v01@openssh.com,sk-ecdsa-sha2-nistp256-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-ed25519,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,sk-ssh-ed25519@openssh.com,sk-ecdsa-sha2-nistp256@openssh.com,rsa-sha2-512,rsa-sha2-256 xauthlocation /usr/bin/xauth -identityfile ~/.ssh/id_rsa -identityfile ~/.ssh/id_ecdsa -identityfile ~/.ssh/id_ecdsa_sk -identityfile ~/.ssh/id_ed25519 -identityfile ~/.ssh/id_ed25519_sk -identityfile ~/.ssh/id_xmss -identityfile ~/.ssh/id_dsa -canonicaldomains none -globalknownhostsfile /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2 -userknownhostsfile ~/.ssh/known_hosts ~/.ssh/known_hosts2 -logverbose none -permitremoteopen any -addkeystoagent false -forwardagent no -connecttimeout none -tunneldevice any:any -canonicalizePermittedcnames none -controlpersist no -escapechar ~ -ipqos af21 cs1 -rekeylimit 0 0 -streamlocalbindmask 0177 -syslogfacility USER ` diff --git a/protocol/ssh/sshconfig/defaultconfig_windows.go b/protocol/ssh/sshconfig/defaultconfig_windows.go index 56ca2275..238efc35 100644 --- a/protocol/ssh/sshconfig/defaultconfig_windows.go +++ b/protocol/ssh/sshconfig/defaultconfig_windows.go @@ -21,72 +21,72 @@ var defaultGlobalConfigPath = func() string { // instead of "yes"/"no" and the __PROGRAMDATA__ in some of the paths // in addition to mixed path separators. const sshDefaultConfig = ` -port 22 addkeystoagent false addressfamily any batchmode no +canonicaldomains canonicalizefallbacklocal yes canonicalizehostname false +canonicalizemaxdots 1 +casignaturealgorithms ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ssh-rsa challengeresponseauthentication yes checkhostip yes +ciphers chacha20-poly1305@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com +clearallforwardings no compression no +connectionattempts 1 +connecttimeout none controlmaster false +controlpersist no enablesshkeysign no -clearallforwardings no +escapechar ~ exitonforwardfailure no fingerprinthash SHA256 forwardagent no forwardx11 no +forwardx11timeout 1200 forwardx11trusted no gatewayports no +globalknownhostsfile __PROGRAMDATA__\ssh/ssh_known_hosts __PROGRAMDATA__\ssh/ssh_known_hosts2 gssapiauthentication no gssapidelegatecredentials no hashknownhosts no hostbasedauthentication no +hostbasedkeytypes ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ssh-rsa +hostkeyalgorithms ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ssh-rsa identitiesonly no +identityfile ~/.ssh/id_dsa +identityfile ~/.ssh/id_ecdsa +identityfile ~/.ssh/id_ed25519 +identityfile ~/.ssh/id_rsa +identityfile ~/.ssh/id_xmss +ipqos af21 cs1 kbdinteractiveauthentication yes +kexalgorithms 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 +loglevel INFO +macs umac-64-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha1-etm@openssh.com,umac-64@openssh.com,umac-128@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1 nohostauthenticationforlocalhost no +numberofpasswordprompts 3 passwordauthentication yes permitlocalcommand no +port 22 proxyusefdpass no +pubkeyacceptedkeytypes ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ssh-rsa pubkeyauthentication yes +rekeylimit 0 0 requesttty auto +serveralivecountmax 3 +serveraliveinterval 0 +streamlocalbindmask 0177 streamlocalbindunlink no stricthostkeychecking ask +syslogfacility USER tcpkeepalive yes tunnel false +tunneldevice any:any +updatehostkeys false +userknownhostsfile ~/.ssh/known_hosts ~/.ssh/known_hosts2 verifyhostkeydns false visualhostkey no -updatehostkeys false -canonicalizemaxdots 1 -connectionattempts 1 -forwardx11timeout 1200 -numberofpasswordprompts 3 -serveralivecountmax 3 -serveraliveinterval 0 -ciphers chacha20-poly1305@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com -hostkeyalgorithms ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ssh-rsa -hostbasedkeytypes ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ssh-rsa -kexalgorithms 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 -casignaturealgorithms ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ssh-rsa -loglevel INFO -macs umac-64-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha1-etm@openssh.com,umac-64@openssh.com,umac-128@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1 -pubkeyacceptedkeytypes ecdsa-sha2-nistp256-cert-v01@openssh.com,ecdsa-sha2-nistp384-cert-v01@openssh.com,ecdsa-sha2-nistp521-cert-v01@openssh.com,ssh-ed25519-cert-v01@openssh.com,rsa-sha2-512-cert-v01@openssh.com,rsa-sha2-256-cert-v01@openssh.com,ssh-rsa-cert-v01@openssh.com,ecdsa-sha2-nistp256,ecdsa-sha2-nistp384,ecdsa-sha2-nistp521,ssh-ed25519,rsa-sha2-512,rsa-sha2-256,ssh-rsa xauthlocation /usr/X11R6/bin/xauth -identityfile ~/.ssh/id_rsa -identityfile ~/.ssh/id_dsa -identityfile ~/.ssh/id_ecdsa -identityfile ~/.ssh/id_ed25519 -identityfile ~/.ssh/id_xmss -canonicaldomains -globalknownhostsfile __PROGRAMDATA__\ssh/ssh_known_hosts __PROGRAMDATA__\ssh/ssh_known_hosts2 -userknownhostsfile ~/.ssh/known_hosts ~/.ssh/known_hosts2 -connecttimeout none -tunneldevice any:any -controlpersist no -escapechar ~ -ipqos af21 cs1 -rekeylimit 0 0 -streamlocalbindmask 0177 -syslogfacility USER ` diff --git a/protocol/ssh/sshconfig/dump.go b/protocol/ssh/sshconfig/dump.go index 0fd96c15..2b90c931 100644 --- a/protocol/ssh/sshconfig/dump.go +++ b/protocol/ssh/sshconfig/dump.go @@ -1,21 +1,52 @@ package sshconfig import ( + "bytes" "fmt" + "sort" "strings" ) +const indentLevel = 4 + +// Dump returns a string representation of the given ssh config object. func Dump(obj withRequiredFields) (string, error) { - objAny := obj.(any) - fields, err := objFields(objAny) + fields, err := objFields(obj) if err != nil { return "", fmt.Errorf("dump config: failed to get fields: %w", err) } + host, ok := fields[fkHost] + if !ok { + return "", fmt.Errorf("%w: dump config: missing required field: host", ErrInvalidObject) + } builder := strings.Builder{} - for key, field := range fields { + builder.WriteString("Host ") + builder.WriteString(host.String()) + builder.WriteByte('\n') + + indent := bytes.Repeat([]byte(" "), indentLevel) + + keys := make([]string, 0, len(fields)-1) + for key := range fields { + if key != fkHost { + keys = append(keys, key) + } + } + + sort.Strings(keys) + + for _, key := range keys { + field, ok := fields[key] + if !ok { + return "", fmt.Errorf("%w: dump config: mysteriously missing field: %s", ErrInvalidObject, key) + } if !field.IsSet() { continue } + if capKey, ok := CapitalizeKey(key); ok { + key = capKey + } + builder.Write(indent) builder.WriteString(key) builder.WriteByte(' ') builder.WriteString(field.String()) diff --git a/protocol/ssh/sshconfig/dump_test.go b/protocol/ssh/sshconfig/dump_test.go new file mode 100644 index 00000000..00a6b405 --- /dev/null +++ b/protocol/ssh/sshconfig/dump_test.go @@ -0,0 +1,32 @@ +package sshconfig_test + +import ( + "strings" + "testing" + + "github.com/k0sproject/rig/v2/protocol/ssh/sshconfig" + "github.com/k0sproject/rig/v2/rigtest" + "github.com/stretchr/testify/require" +) + +func TestDump(t *testing.T) { + obj := &sshconfig.SSHConfig{} + obj.SetHost("test") + parser, err := sshconfig.NewParser(nil) + require.NoError(t, err) + rigtest.TraceToStderr() + require.NoError(t, parser.Parse(obj)) + rigtest.TraceOff() + content, err := sshconfig.Dump(obj) + require.NoError(t, err) + require.True(t, strings.HasPrefix(content, "Host test"), "content should start with 'Host test'") + obj2 := &sshconfig.SSHConfig{} + obj2.SetHost("test") + parser, err = sshconfig.NewParser(strings.NewReader(content)) + require.NoError(t, err) + require.NoError(t, parser.Parse(obj2)) + content2, err := sshconfig.Dump(obj2) + require.NoError(t, err) + require.Equal(t, content, content2) + println(content) +} diff --git a/protocol/ssh/sshconfig/fieldbundles.go b/protocol/ssh/sshconfig/fieldbundles.go index 0ce526a0..c277b06f 100644 --- a/protocol/ssh/sshconfig/fieldbundles.go +++ b/protocol/ssh/sshconfig/fieldbundles.go @@ -84,7 +84,7 @@ type CanonicalizationFields struct { // When CanonicalizeHostname is enabled, this option // specifies the list of domain suffixes in which to search // for the specified destination host. - CanonicalDomains StringSliceValue + CanonicalDomains StringListValue // Controls whether explicit hostname canonicalization is // performed. The default, no, is not to perform any name @@ -101,7 +101,7 @@ type CanonicalizationFields struct { // are processed again using the new target name to pick up // any new configuration in matching Host and Match stanzas. // A value of none disables the use of a ProxyJump host. - CanonicalizeHostname MultiStateValue + CanonicalizeHostname MultiStateBoolValue // Specifies the maximum number of dot characters in a // hostname before canonicalization is disabled. The @@ -135,7 +135,7 @@ type CanonicalizationFields struct { // A single argument of "none" causes no CNAMEs to be // considered for canonicalization. This is the default // behaviour. - CanonicalizePermittedCNAMEs StringSliceValue + CanonicalizePermittedCNAMEs StringListValue } // Canonicalize takes an address, applies the canonicalization rules and returns two values: diff --git a/protocol/ssh/sshconfig/keys.go b/protocol/ssh/sshconfig/keys.go new file mode 100644 index 00000000..faac163f --- /dev/null +++ b/protocol/ssh/sshconfig/keys.go @@ -0,0 +1,111 @@ +package sshconfig + +var knownKeys = map[string]string{ + fkHost: "Host", + "match": "Match", + "addkeystoagent": "AddKeysToAgent", + "addressfamily": "AddressFamily", + "batchmode": "BatchMode", + "bindaddress": "BindAddress", + "bindinterface": "BindInterface", + "canonicaldomains": "CanonicalDomains", + "canonicalizefallbacklocal": "CanonicalizeFallbackLocal", + "canonicalizehostname": "CanonicalizeHostname", + "canonicalizemaxdots": "CanonicalizeMaxDots", + "canonicalizepermittedcnames": "CanonicalizePermittedCNAMEs", + "casignaturealgorithms": "CASignatureAlgorithms", + "certificatefile": "CertificateFile", + "channeltimeout": "ChannelTimeout", + "checkhostip": "CheckHostIP", + "ciphers": "Ciphers", + "clearallforwardings": "ClearAllForwardings", + "compression": "Compression", + "connectionattempts": "ConnectionAttempts", + "connecttimeout": "ConnectTimeout", + "controlmaster": "ControlMaster", + "controlpath": "ControlPath", + "controlpersist": "ControlPersist", + "dynamicforward": "DynamicForward", + "enableescapecommandline": "EnableEscapeCommandline", + "enablesshkeysign": "EnableSSHKeysign", + "escapechar": "EscapeChar", + "exitonforwardfailure": "ExitOnForwardFailure", + "fingerprinthash": "FingerprintHash", + "forkafterauthentication": "ForkAfterAuthentication", + "forwardagent": "ForwardAgent", + "forwardx11": "ForwardX11", + "forwardx11timeout": "ForwardX11Timeout", + "forwardx11trusted": "ForwardX11Trusted", + "gatewayports": "GatewayPorts", + "globalknownhostsfile": "GlobalKnownHostsFile", + "gssapiauthentication": "GSSAPIAuthentication", + "gssapidelegatecredentials": "GSSAPIDelegateCredentials", + "hashknownhosts": "HashKnownHosts", + "hostbasedacceptedalgorithms": "HostbasedAcceptedAlgorithms", + "hostbasedauthentication": "HostbasedAuthentication", + "hostkeyalgorithms": "HostKeyAlgorithms", + "hostkeyalias": "HostKeyAlias", + "hostname": "Hostname", + "identitiesonly": "IdentitiesOnly", + "identityagent": "IdentityAgent", + "identityfile": "IdentityFile", + "ignoreunknown": "IgnoreUnknown", + "include": "Include", + "ipqos": "IPQoS", + "kbdinteractiveauthentication": "KbdInteractiveAuthentication", + "kbdinteractivedevices": "KbdInteractiveDevices", + "kexalgorithms": "KexAlgorithms", + "knownhostscommand": "KnownHostsCommand", + "localcommand": "LocalCommand", + "localforward": "LocalForward", + "loglevel": "LogLevel", + "logverbose": "LogVerbose", + "macs": "MACs", + "nohostauthenticationforlocalhost": "NoHostAuthenticationForLocalhost", + "numberofpasswordprompts": "NumberOfPasswordPrompts", + "obscurekeystroketiming": "ObscureKeystrokeTiming", + "passwordauthentication": "PasswordAuthentication", + "permitlocalcommand": "PermitLocalCommand", + "permitremoteopen": "PermitRemoteOpen", + "pkcs11provider": "PKCS11Provider", + "port": "Port", + "preferredauthentications": "PreferredAuthentications", + "proxycommand": "ProxyCommand", + "proxyjump": "ProxyJump", + "proxyusefdpass": "ProxyUseFdpass", + "pubkeyacceptedalgorithms": "PubkeyAcceptedAlgorithms", + "pubkeyauthentication": "PubkeyAuthentication", + "rekeylimit": "RekeyLimit", + "remotecommand": "RemoteCommand", + "remoteforward": "RemoteForward", + "requesttty": "RequestTTY", + "requiredrsasize": "RequiredRSASize", + "revokedhostkeys": "RevokedHostKeys", + "securitykeyprovider": "SecurityKeyProvider", + "sendenv": "SendEnv", + "serveralivecountmax": "ServerAliveCountMax", + "serveraliveinterval": "ServerAliveInterval", + "sessiontype": "SessionType", + "setenv": "SetEnv", + "stdinnull": "StdinNull", + "streamlocalbindmask": "StreamLocalBindMask", + "streamlocalbindunlink": "StreamLocalBindUnlink", + "stricthostkeychecking": "StrictHostKeyChecking", + "syslogfacility": "SyslogFacility", + "tcpkeepalive": "TCPKeepAlive", + "tag": "Tag", + "tunnel": "Tunnel", + "tunneldevice": "TunnelDevice", + "updatehostkeys": "UpdateHostKeys", + "user": "User", + "userknownhostsfile": "UserKnownHostsFile", + "verifyhostkeydns": "VerifyHostKeyDNS", + "visualhostkey": "VisualHostKey", + "xauthlocation": "XAuthLocation", +} + +// CapitalizeKey transforms a key to its canonical mixed-case form. +func CapitalizeKey(key string) (string, bool) { + val, ok := knownKeys[key] + return val, ok +} diff --git a/protocol/ssh/sshconfig/parser.go b/protocol/ssh/sshconfig/parser.go index 15fe83cf..425ee963 100644 --- a/protocol/ssh/sshconfig/parser.go +++ b/protocol/ssh/sshconfig/parser.go @@ -4,9 +4,11 @@ package sshconfig import ( "bufio" "bytes" + "context" "errors" "fmt" "io" + "log/slog" "os" "os/exec" "os/user" @@ -14,14 +16,39 @@ import ( "reflect" "regexp" "slices" - "strconv" "strings" "unicode" "github.com/k0sproject/rig/v2/homedir" + "github.com/k0sproject/rig/v2/log" "github.com/k0sproject/rig/v2/sh/shellescape" ) +/* + SSH Configuration files parsing rules: + + Config value load order: + + When a configuration file is not supplied (ssh -F path or NewParser(nil)): + 1. Configuration defaults - any values set here can be overriden at any stage. + 2. Command-line options - any values set here have the highest precedence. + 3. User configuration file (~/.ssh/config) + 4. Global configuration file (/etc/ssh/ssh_config)) + 5. Canonicalization if enabled + 6. Final match blocks + + If a configuration file path is provided (or NewParser(reader) is used) + the stages 3 and 4 are replaced by parsing the supplied configuration file. + + Each stage can contain Include directives, which are processed as they + are encountered. + + For each parameter, the first obtained value will be used except + for some fields that can be used multiple times or can modify + existing values (remove items from lists of ciphers, append to lists, + etc) +*/ + var ( // ErrInvalidObject is returned when the object passed to Parse is not compatible. ErrInvalidObject = errors.New("invalid object") @@ -198,7 +225,7 @@ func supportsTokens(fieldname string) bool { return ok } -func expandToken(token string, fields map[string]configValue) string { //nolint:gocognit,cyclop +func expandToken(token string, fields map[string]configValue) string { //nolint:cyclop switch token { case "%%": return "%" @@ -209,46 +236,28 @@ func expandToken(token string, fields map[string]configValue) string { //nolint: case "%h": // TODO this casting of setters into different valuetypes is a bit ugly // and repetitive. - for _, fn := range []string{"hostname", "host"} { - if pf, ok := fields[fn]; ok { - if getter, ok := pf.(*StringValue); ok { - if v, ok := getter.Get(); ok { - return v - } + for _, fn := range []string{"hostname", fkHost} { + if f, ok := fields[fn]; ok { + if f.IsSet() { + return f.String() } } } case "%p": - if pf, ok := fields["port"]; ok { - if getter, ok := pf.(*UintValue); ok { - if v, ok := getter.Get(); ok { - return strconv.Itoa(int(v)) - } - } + if f, ok := fields["port"]; ok { + return f.String() } case "%n": - if pf, ok := fields["host"]; ok { - if getter, ok := pf.(*StringValue); ok { - if v, ok := getter.Get(); ok { - return v - } - } + if f, ok := fields[fkHost]; ok { + return f.String() } case "%r": - if pf, ok := fields["user"]; ok { - if getter, ok := pf.(*StringValue); ok { - if v, ok := getter.Get(); ok { - return v - } - } + if f, ok := fields["user"]; ok { + return f.String() } case "%j": - if pf, ok := fields["proxyjump"]; ok { - if getter, ok := pf.(*StringValue); ok { - if v, ok := getter.Get(); ok { - return v - } - } + if f, ok := fields["proxyjump"]; ok { + return f.String() } } // unsupported or unknown token @@ -325,7 +334,7 @@ func checkRequiredFields(fields map[string]configValue) error { if _, ok := fields["user"]; !ok { return fmt.Errorf("%w: user field not found in object", ErrInvalidObject) } - if _, ok := fields["host"]; !ok { + if _, ok := fields[fkHost]; !ok { return fmt.Errorf("%w: host field not found in object", ErrInvalidObject) } if _, ok := fields["hostname"]; !ok { @@ -362,7 +371,7 @@ func tokenizeRow(s string) (key string, value string, err error) { valModeEquals = true } valIndex = i - key = s[:valIndex] + key = strings.ToLower(s[:valIndex]) break } } @@ -387,6 +396,10 @@ func tokenizeRow(s string) (key string, value string, err error) { // matchesPattern compares a single pattern against a string. func matchesPattern(pattern, value string) (bool, error) { + if pattern == "*" { + return true, nil + } + if !strings.ContainsAny(pattern, "*?") { return pattern == value, nil } @@ -482,15 +495,12 @@ func (p *Parser) matchesMatch(matchValue string, fields map[string]configValue) if !match { return false, nil } - case "host": - hostValue, ok := fields["host"].(*StringValue) - if !ok { - return false, fmt.Errorf("%w: host field not found or is not a StringValue", ErrInvalidObject) - } - host, ok := hostValue.Get() + case fkHost: + hostValue, ok := fields[fkHost] if !ok { - return false, fmt.Errorf("%w: host not set for evaluating match condition", ErrInvalidObject) + return false, fmt.Errorf("%w: host field not found", ErrInvalidObject) } + host := hostValue.String() match, err := matchesPatterns(conditionValues, host) if err != nil { return false, fmt.Errorf("match host for match condition: %w", err) @@ -526,15 +536,11 @@ func (p *Parser) matchesMatch(matchValue string, fields map[string]configValue) } } case "tagged": - hostTagValue, ok := fields["tag"].(*StringValue) + hostTagValue, ok := fields["tag"] if !ok { return false, nil } - hostTag, ok := hostTagValue.Get() - if !ok { - return false, nil - } - match, err := matchesPatterns(conditionValues, hostTag) + match, err := matchesPatterns(conditionValues, hostTagValue.String()) if err != nil { return false, fmt.Errorf("match tag for match condition: %w", err) } @@ -587,7 +593,12 @@ func matchesPatterns(patterns []string, value string) (bool, error) { var tokenRe = regexp.MustCompile(`%[a-zA-Z%]`) -func (p *Parser) parse(obj withRequiredFields, fields map[string]configValue, reader io.Reader, originType ValueOriginType, origin string, appliesToCurrent bool) error { //nolint:cyclop,gocognit,funlen,gocyclo // TODO strong hint to split this up +type withAttrs interface { + log.TraceLogger + With(attrs ...any) *slog.Logger +} + +func (p *Parser) parse(obj withRequiredFields, fields map[string]configValue, reader io.Reader, originType ValueOriginType, origin string, appliesToCurrent bool) error { //nolint:cyclop,gocognit,funlen,gocyclo,maintidx // TODO a pretty strong hint to split this up host, ok := obj.GetHost() if !ok { return fmt.Errorf("%w: host field is not set", ErrInvalidObject) @@ -598,76 +609,96 @@ func (p *Parser) parse(obj withRequiredFields, fields map[string]configValue, re inMatch := false scanner := bufio.NewScanner(reader) + ctx := context.Background() + + var row int + var trace log.TraceLogger + tlog := log.GetTraceLogger() for scanner.Scan() { + row++ key, value, err := tokenizeRow(scanner.Text()) if err != nil { - return err + return fmt.Errorf("parse row %d: %w", row, err) } if key == "" { continue } - println("parsing", origin, key, value, err) + var orig string + if originType == ValueOriginDefault { + orig = "defaults" + } else { + orig = origin + } + if tl, ok := tlog.(withAttrs); ok { + trace = tl.With("origin", orig, "origintype", originType, "hostalias", host, "row", row, "key", key) + } else { + trace = tlog + } + + trace.Log(ctx, slog.LevelInfo, "parsing") if supportsTokens(key) { + trace.Log(ctx, slog.LevelInfo, "field supports token expansion") value = tokenRe.ReplaceAllStringFunc(value, func(token string) string { if isAllowedToken(key, token) { - return expandToken(token, fields) + expanded := expandToken(token, fields) + trace.Log(ctx, slog.LevelInfo, "expanded token", "token", token, "expanded", expanded) + return expanded } + trace.Log(ctx, slog.LevelInfo, "token unknown or not allowed", "token", token) return token }) } switch key { - case "host": + case fkHost: if p.didCanonicalize { + trace.Log(ctx, slog.LevelInfo, "canonicalization was done during the last block before Host") p.didCanonicalize = false - if co, ok := obj.(withCanonicalize); ok { - newHost, useOld := co.Canonicalize(host) - if newHost == "" { - if !useOld { - return fmt.Errorf("%w: hostname %q could not be canonicalized and fallback is disabled", ErrCanonicalizationFailed, host) - } - } else { - if p.canonicalized == nil { - p.canonicalized = make(map[string]string) - } - // check if this canonicalization has been done before, if not, do it now. this is done - // to avoid infinite loops. - if prevNh, ok := p.canonicalized[host]; ok && prevNh != newHost { - p.canonicalized[host] = newHost - obj.SetHost(newHost) - return errRedo - } - } + if err := p.canonicalize(obj); err != nil { + return fmt.Errorf("canonicalize host %q in %s:%d: %w", host, origin, row, err) } } inMatch = false patterns, err := shellescape.Split(value) if err != nil { - return fmt.Errorf("can't split Host directive %q: %w", value, err) + return fmt.Errorf("can't split Host directive %q in %s:%d: %w", value, origin, row, err) } match, err := matchesPatterns(patterns, host) if err != nil { return err } + trace.Log(ctx, slog.LevelInfo, "evaluated host block conditions", "conditions", patterns, "applies", match) appliesToCurrent = match case "include": + hostBefore, _ := obj.GetHost() matches, err := filepath.Glob(value) // TODO handle relative paths if err != nil { - return fmt.Errorf("can't glob Include path %q: %w", value, err) + return fmt.Errorf("can't glob Include path %q in %s:%d: %w", value, origin, row, err) } for _, match := range matches { f, err := os.Open(match) // TODO handle relative paths if err != nil { - return fmt.Errorf("can't open Include file %q: %w", match, err) + return fmt.Errorf("can't open Include file %q in %s:%d: %w", match, origin, row, err) } defer f.Close() if err := p.parse(obj, fields, f, ValueOriginFile, match, appliesToCurrent); err != nil { - return fmt.Errorf("failed to parse Include file %s: %w", match, err) + return fmt.Errorf("failed to parse Include file %s in %s:%d: %w", match, origin, row, err) + } + hostAfter, _ := obj.GetHost() + if hostBefore != hostAfter { + trace.Log(ctx, slog.LevelInfo, "host alias changed during Include, triggering a redo", "before", hostBefore, "after", hostAfter) + return errRedo } } case "match": - p.didCanonicalize = false + if p.didCanonicalize { + trace.Log(ctx, slog.LevelInfo, "canonicalization was done during the last block before Match") + p.didCanonicalize = false + if err := p.canonicalize(obj); err != nil { + return fmt.Errorf("canonicalize host %q in %s:%d: %w", host, origin, row, err) + } + } inMatch = true if value == "all" { appliesToCurrent = true @@ -676,8 +707,9 @@ func (p *Parser) parse(obj withRequiredFields, fields map[string]configValue, re matches, err := p.matchesMatch(value, fields) if err != nil { - return fmt.Errorf("can't parse Match directive %q: %w", value, err) + return fmt.Errorf("can't parse Match directive %q in %s:%d: %w", value, origin, row, err) } + trace.Log(ctx, slog.LevelInfo, "evaluated match conditions", "conditions", value, "applies", matches) appliesToCurrent = matches case "proxyjump": // proxyjump and proxycommand are mutually exclusive, the first one to be set wins. @@ -700,7 +732,7 @@ func (p *Parser) parse(obj withRequiredFields, fields map[string]configValue, re } if err := pjump.SetString(value, originType, origin); err != nil { - return fmt.Errorf("set value for proxycommand: %w", err) + return fmt.Errorf("set value %q for proxycommand in %s:%d: %w", value, origin, err, err) } case "proxycommand": // proxyjump and proxycommand are mutually exclusive, the first one to be set wins. @@ -722,27 +754,25 @@ func (p *Parser) parse(obj withRequiredFields, fields map[string]configValue, re } if err := pcmd.SetString(value, originType, origin); err != nil { - return fmt.Errorf("set value for proxycommand: %w", err) + return fmt.Errorf("set value %q for proxycommand in %s:%d: %w", value, origin, row, err) } default: if !appliesToCurrent { continue } if inMatch && !allowedInMatch(key) { - return fmt.Errorf("%w: field %q not allowed in match block", ErrSyntax, key) + return fmt.Errorf("%w: field %q not allowed inside match block in %s:%d", ErrSyntax, key, origin, row) } if slices.Contains(canonicalizationFields, key) { + trace.Log(ctx, slog.LevelInfo, "touched canonicalization field") p.didCanonicalize = true } if f, ok := fields[key]; ok { - if !f.IsDefault() { - // "Unless noted otherwise, for each parameter, the first obtained - // value will be used." - if err := f.SetString(value, originType, origin); err != nil { - return fmt.Errorf("set value for %q: %w", key, err) - } + trace.Log(ctx, slog.LevelInfo, "setting value", "value", value) + if err := f.SetString(value, originType, origin); err != nil { + return fmt.Errorf("set value for %q in %s:%d: %w", key, origin, row, err) } } } @@ -750,6 +780,32 @@ func (p *Parser) parse(obj withRequiredFields, fields map[string]configValue, re return nil } +func (p *Parser) canonicalize(obj withRequiredFields) error { + host, _ := obj.GetHost() + co, ok := obj.(withCanonicalize) + if !ok { + return nil + } + newHost, useOld := co.Canonicalize(host) + if newHost == "" { + if !useOld { + return fmt.Errorf("%w: hostname %q could not be canonicalized and fallback is disabled", ErrCanonicalizationFailed, host) + } + } else { + // store the old => new to avoid doing the same canonicalization again and again in an infinite loop + if p.canonicalized == nil { + p.canonicalized = make(map[string]string) + } + + if prevNh, ok := p.canonicalized[host]; ok && prevNh != newHost { + p.canonicalized[host] = newHost + obj.SetHost(newHost) + return errRedo + } + } + return nil +} + type withRequiredFields interface { SetUser(username string) error SetHost(host string) error @@ -794,24 +850,7 @@ func (p *Parser) Parse(obj withRequiredFields) error { //nolint:cyclop return nil } - // No reader provided, parse global and user config - if p.GlobalConfigPath == "" { - p.GlobalConfigPath = defaultGlobalConfigPath() - } - globalConfigFile, err := os.Open(p.GlobalConfigPath) - if err == nil { - defer globalConfigFile.Close() - if err := p.parse(obj, fields, globalConfigFile, ValueOriginFile, p.GlobalConfigPath, true); err != nil { - if errors.Is(err, errRedo) { - if err := p.Parse(obj); err != nil { - return fmt.Errorf("second pass after canonicalization failed: %w", err) - } - return nil - } - return fmt.Errorf("parsing global config from %s failed: %w", p.GlobalConfigPath, err) - } - } - + // No reader provided, parse user and global config if p.UserConfigPath == "" { if cfg, err := homedir.Expand("~/.ssh/config"); err == nil { p.UserConfigPath = cfg @@ -833,6 +872,23 @@ func (p *Parser) Parse(obj withRequiredFields) error { //nolint:cyclop } } + if p.GlobalConfigPath == "" { + p.GlobalConfigPath = defaultGlobalConfigPath() + } + globalConfigFile, err := os.Open(p.GlobalConfigPath) + if err == nil { + defer globalConfigFile.Close() + if err := p.parse(obj, fields, globalConfigFile, ValueOriginFile, p.GlobalConfigPath, true); err != nil { + if errors.Is(err, errRedo) { + if err := p.Parse(obj); err != nil { + return fmt.Errorf("second pass after canonicalization failed: %w", err) + } + return nil + } + return fmt.Errorf("parsing global config from %s failed: %w", p.GlobalConfigPath, err) + } + } + if !p.doingFinal && p.hasFinal { // second pass to handle Match final directives and other Matchblocks that // may match after hostname has been canonicalized. diff --git a/protocol/ssh/sshconfig/parser_test.go b/protocol/ssh/sshconfig/parser_test.go index d51bc61e..bd628694 100644 --- a/protocol/ssh/sshconfig/parser_test.go +++ b/protocol/ssh/sshconfig/parser_test.go @@ -10,7 +10,7 @@ import ( func TestParse(t *testing.T) { type hostconfig struct { sshconfig.RequiredFields - IdentityFile sshconfig.PathSliceValue + IdentityFile sshconfig.PathListValue } parser, err := sshconfig.NewParser(nil) require.NoError(t, err) @@ -19,7 +19,6 @@ func TestParse(t *testing.T) { obj.SetHost("example.com") err = parser.Parse(obj) require.NoError(t, err) - t.Logf("%+v", obj) } func TestParseFull(t *testing.T) { @@ -33,7 +32,4 @@ func TestParseFull(t *testing.T) { obj.SetHost("example.com") err = parser.Parse(obj) require.NoError(t, err) - dump, err := sshconfig.Dump(obj) - require.NoError(t, err) - t.Log(dump) } diff --git a/protocol/ssh/sshconfig/sshconfig.go b/protocol/ssh/sshconfig/sshconfig.go index de12f5f7..eb751a3f 100644 --- a/protocol/ssh/sshconfig/sshconfig.go +++ b/protocol/ssh/sshconfig/sshconfig.go @@ -106,7 +106,7 @@ Copied from the ssh_config man page for reference: system-wide files). Include - Include the specified configuration file(s). Multiple + Includes the specified configuration file(s). Multiple pathnames may be specified and each pathname may contain glob(7) wildcards and, for user configurations, shell- like `~' references to user home directories. Wildcards @@ -274,7 +274,7 @@ type SSHConfig struct { // it will automatically be removed. The argument must be // no (the default), yes, confirm (optionally followed by a // time interval), ask or a time interval. - AddKeysToAgent MultiStateValue + AddKeysToAgent MultiStateBoolValue // Specifies which address family to use when connecting. // Valid arguments are any (the default), inet (use IPv4 @@ -316,7 +316,7 @@ type SSHConfig struct { // // ssh(1) will not accept host certificates signed using // algorithms other than those specified. - CASignatureAlgorithms SpecialStringSliceValue + CASignatureAlgorithms ModifiableStringListValue // Specifies a file from which the user's certificate is // read. A corresponding private key must be provided @@ -335,7 +335,7 @@ type SSHConfig struct { // be tried in sequence. Multiple CertificateFile // directives will add to the list of certificates used for // authentication. - CertificateFile AlwaysAppendStringSliceValue + CertificateFile AppendingStringListValue // Specifies whether and how quickly ssh(1) should close // inactive channels. Timeouts are specified as one or more @@ -434,7 +434,7 @@ type SSHConfig struct { // // The list of available ciphers may also be obtained using // "ssh -Q cipher". - Ciphers SpecialStringSliceValue + Ciphers ModifiableStringListValue // Specifies that all local, remote, and dynamic port // forwardings specified in the configuration files or on @@ -542,7 +542,7 @@ type SSHConfig struct { // forwardings may be specified, and additional forwardings // can be given on the command line. Only the superuser can // forward privileged ports. - DynamicForward NetAddrValue + DynamicForward StringValue // Enables the command line option in the EscapeChar menu // for interactive sessions (default `~C'). By default, the @@ -614,7 +614,7 @@ type SSHConfig struct { // however they can perform operations on the keys that // enable them to authenticate using the identities loaded // into the agent. - ForwardAgent MultiStateValue + ForwardAgent MultiStateBoolValue // Specifies whether X11 connections will be automatically // redirected over the secure channel and DISPLAY set. The @@ -666,7 +666,7 @@ type SSHConfig struct { // Specifies one or more files to use for the global host // key database, separated by whitespace. The default is // /etc/ssh/ssh_known_hosts, /etc/ssh/ssh_known_hosts2. - GlobalKnownHostsFile PathSliceValue + GlobalKnownHostsFile PathListValue // Specifies whether user authentication based on GSSAPI is // allowed. The default is no. @@ -716,7 +716,7 @@ type SSHConfig struct { // The -Q option of ssh(1) may be used to list supported // signature algorithms. This was formerly named // HostbasedKeyTypes. - HostbasedAcceptedAlgorithms SpecialStringSliceValue + HostbasedAcceptedAlgorithms ModifiableStringListValue // Specifies whether to try rhosts based authentication with // public key authentication. The argument must be yes or @@ -755,7 +755,7 @@ type SSHConfig struct { // // The list of available signature algorithms may also be // obtained using "ssh -Q HostKeyAlgorithms". - HostKeyAlgorithms SpecialStringSliceValue + HostKeyAlgorithms ModifiableStringListValue // Specifies an alias that should be used instead of the // real host name when looking up or saving the host key in @@ -829,7 +829,7 @@ type SSHConfig struct { // used in conjunction with CertificateFile in order to // provide any certificate also needed for authentication // with the identity. - IdentityFile PathSliceValue + IdentityFile PathListValue // Specifies a pattern-list of unknown options to be ignored // if they are encountered in configuration parsing. This @@ -853,7 +853,7 @@ type SSHConfig struct { // the second for non-interactive sessions. The default is // af21 (Low-Latency Data) for interactive sessions and cs1 // (Lower Effort) for non-interactive sessions. - IPQoS StringSliceValue + IPQoS StringListValue // Specifies whether to use keyboard-interactive // authentication. The argument to this keyword must be yes @@ -867,7 +867,7 @@ type SSHConfig struct { // specified list. The methods available vary depending on // what the server supports. For an OpenSSH server, it may // be zero or more of: bsdauth and pam. - KbdInteractiveDevices StringSliceValue + KbdInteractiveDevices StringListValue // Specifies the available KEX (Key Exchange) algorithms. // Multiple algorithms must be comma-separated. If the @@ -891,7 +891,7 @@ type SSHConfig struct { // // The list of available key exchange algorithms may also be // obtained using "ssh -Q kex". - KexAlgorithms SpecialStringSliceValue + KexAlgorithms ModifiableStringListValue // Specifies a command to use to obtain a list of host keys, // in addition to those listed in UserKnownHostsFile and @@ -946,7 +946,7 @@ type SSHConfig struct { // domain socket paths may use the tokens described in the // "TOKENS" section and environment variables as described // in the "ENVIRONMENT VARIABLES" section. - LocalForward NetAddrValue + LocalForward StringValue // Gives the verbosity level that is used when logging // messages from ssh(1). The possible values are: QUIET, @@ -968,7 +968,7 @@ type SSHConfig struct { // and all code in the packet.c file. This option is // intended for debugging and no overrides are enabled by // default. - LogVerbose StringSliceValue + LogVerbose StringListValue // Specifies the MAC (message authentication code) // algorithms in order of preference. The MAC algorithm is @@ -998,7 +998,7 @@ type SSHConfig struct { // // The list of available MAC algorithms may also be obtained // using "ssh -Q mac". - MACs SpecialStringSliceValue + MACs ModifiableStringListValue // Disable host authentication for localhost (loopback // addresses). The argument to this keyword must be yes or @@ -1022,7 +1022,7 @@ type SSHConfig struct { // using a 20ms packet interval. Note that smaller // intervals will result in higher fake keystroke packet // rates. - ObscureKeystrokeTiming MultiStateValue + ObscureKeystrokeTiming MultiStateBoolValue // Specifies whether to use password authentication. The // argument to this keyword must be yes (the default) or no. @@ -1050,7 +1050,7 @@ type SSHConfig struct { // for host or port to allow all hosts or ports // respectively. Otherwise, no pattern matching or address // lookups are performed on supplied names. - PermitRemoteOpen StringSliceValue + PermitRemoteOpen StringListValue // Specifies which PKCS#11 provider to use or none to // indicate that no provider should be used (the default). @@ -1070,7 +1070,7 @@ type SSHConfig struct { // // gssapi-with-mic,hostbased,publickey, // keyboard-interactive,password - PreferredAuthentications StringSliceValue + PreferredAuthentications StringListValue // Specifies the command to use to connect to the server. // The command string extends to the end of the line, and is @@ -1150,7 +1150,7 @@ type SSHConfig struct { // // The list of available signature algorithms may also be // obtained using "ssh -Q PubkeyAcceptedAlgorithms". - PubkeyAcceptedAlgorithms SpecialStringSliceValue + PubkeyAcceptedAlgorithms ModifiableStringListValue // Specifies whether to try public key authentication. The // argument to this keyword must be yes (the default), no, @@ -1159,7 +1159,7 @@ type SSHConfig struct { // enabling the OpenSSH host-bound authentication protocol // extension required for restricted ssh-agent(1) // forwarding. - PubkeyAuthentication MultiStateValue + PubkeyAuthentication MultiStateBoolValue // Specifies the maximum amount of data that may be // transmitted or received before the session key is @@ -1220,7 +1220,7 @@ type SSHConfig struct { // to listen on all interfaces. Specifying a remote // bind_address will only succeed if the server's // GatewayPorts option is enabled (see sshd_config(5)). - RemoteForward StringSliceValue + RemoteForward StringListValue // Specifies whether to request a pseudo-tty for the // session. The argument may be one of: no (never request a @@ -1228,7 +1228,7 @@ type SSHConfig struct { // TTY), force (always request a TTY) or auto (request a TTY // when opening a login session). This option mirrors the // -t and -T flags for ssh(1). - RequestTTY MultiStateValue + RequestTTY MultiStateBoolValue // Specifies the minimum RSA key size (in bits) that ssh(1) // will accept. User authentication keys smaller than this @@ -1279,7 +1279,7 @@ type SSHConfig struct { // It is possible to clear previously set SendEnv variable // names by prefixing patterns with -. The default is not // to send any environment variables. - SendEnv SendEnvSliceValue + SendEnv RemovableStringListValue // Sets the number of server alive messages (see below) // which may be sent without ssh(1) receiving any messages @@ -1322,7 +1322,7 @@ type SSHConfig struct { // SendEnv, with the exception of the TERM variable, the // server must be prepared to accept the environment // variable. - SetEnv StringSliceValue + SetEnv StringListValue // Redirects stdin from /dev/null (actually, prevents // reading from stdin). Either this or the equivalent -n @@ -1376,7 +1376,7 @@ type SSHConfig struct { // ssh will refuse to connect to hosts whose host key has // changed. The host keys of known hosts will be verified // automatically in all cases. - StrictHostKeyChecking MultiStateValue + StrictHostKeyChecking MultiStateBoolValue // Gives the facility code that is used when logging // messages from ssh(1). The possible values are: DAEMON, @@ -1410,7 +1410,7 @@ type SSHConfig struct { // (layer 3), ethernet (layer 2), or no (the default). // Specifying yes requests the default tunnel mode, which is // point-to-point. - Tunnel MultiStateValue + Tunnel MultiStateBoolValue // Specifies the tun(4) devices to open on the client // (local_tun) and the server (remote_tun). @@ -1451,7 +1451,7 @@ type SSHConfig struct { // Presently, only sshd(8) from OpenSSH 6.8 and greater // support the "hostkeys@openssh.com" protocol extension // used to inform the client of all the server's hostkeys. - UpdateHostKeys MultiStateValue + UpdateHostKeys MultiStateBoolValue // Specifies one or more files to use for the user host key // database, separated by whitespace. Each filename may use @@ -1461,7 +1461,7 @@ type SSHConfig struct { // section. A value of none causes ssh(1) to ignore any // user-specific known hosts files. The default is // ~/.ssh/known_hosts, ~/.ssh/known_hosts2. - UserKnownHostsFile PathSliceValue + UserKnownHostsFile PathListValue // Specifies whether to verify the remote key using DNS and // SSHFP resource records. If this option is set to yes, @@ -1474,7 +1474,7 @@ type SSHConfig struct { // The default is no. // // See also "VERIFYING HOST KEYS" in ssh(1). - VerifyHostKeyDNS MultiStateValue + VerifyHostKeyDNS MultiStateBoolValue // If this flag is set to yes, an ASCII art representation // of the remote host key fingerprint is printed in addition