Skip to content

Commit

Permalink
Merge pull request #181 from k0sproject/ssh-config-parser
Browse files Browse the repository at this point in the history
Implement an SSH config parser
kke authored Apr 4, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
2 parents 199f76e + 45ccc38 commit e63043c
Showing 27 changed files with 9,044 additions and 7 deletions.
11 changes: 11 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -18,6 +18,8 @@ linters:
- forbidigo
- gochecknoinits
- gochecknoglobals
- gocognit # cyclop is used instead
- gocyclo # cyclop is used instead
- godox
- golint
- interfacer
@@ -50,8 +52,11 @@ linters-settings:
ignore-decls:
- w http.ResponseWriter
- r *http.Request
- r io.Reader
- i int
- n int
- j int
- ok bool
- p []byte
- mu sync.Mutex
- wg sync.WaitGroup
@@ -73,3 +78,9 @@ issues:
- EXC0015
max-issues-per-linter: 0
max-same-issues: 0
exclude-rules:
- linters:
- errname # lots of false positives for an unknown reason
- revive # no need for comments on obvious vars like BooleanOptionYes
path: 'options/options\.go'

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@

### Rig

[![GoDoc](https://godoc.org/github.com/k0sproject/rig?status.svg)](https://godoc.org/github.com/k0sproject/rig)
[![GoDoc](https://godoc.org/github.com/k0sproject/rig/v2/?status.svg)](https://godoc.org/github.com/k0sproject/rig/v2)
[![Go Report Card](https://goreportcard.com/badge/github.com/k0sproject/rig)](https://goreportcard.com/report/github.com/k0sproject/rig)
[![Build Status](https://travis-ci.com/k0sproject/rig.svg?branch=main)](https://travis-ci.com/k0sproject/rig)
[![codecov](https://codecov.io/gh/k0sproject/rig/branch/main/graph/badge.svg)](https://codecov.io/gh/k0sproject/rig)
12 changes: 7 additions & 5 deletions homedir/expand.go
Original file line number Diff line number Diff line change
@@ -6,19 +6,20 @@ import (
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strings"
)

var errNotImplemented = errors.New("not implemented")

// Expand does ~/ style path expansion for files under current user home. ~user/ style paths are not supported.
func Expand(path string) (string, error) {
if !strings.HasPrefix(path, "~") {
return path, nil
func Expand(dir string) (string, error) {
if !strings.HasPrefix(dir, "~") {
return dir, nil
}

parts := strings.Split(path, string(os.PathSeparator))
parts := strings.Split(dir, string(os.PathSeparator))
if parts[0] != "~" {
return "", fmt.Errorf("%w: ~user/ style paths not supported", errNotImplemented)
}
@@ -27,8 +28,9 @@ func Expand(path string) (string, error) {
if err != nil {
return "", fmt.Errorf("homedir expand: %w", err)
}
home = strings.ReplaceAll(filepath.Clean(home), "\\", "/")

parts[0] = home

return filepath.Join(parts...), nil
return path.Join(parts...), nil
}
3 changes: 3 additions & 0 deletions homedir/expand_windows.go
Original file line number Diff line number Diff line change
@@ -9,6 +9,9 @@ import (

// Expand does ~/ style path expansion for files under current user home. On windows, this supports paths like %USERPROFILE%\path.
func Expand(path string) (string, error) {
if strings.Contains(path, "/") {
path = filepath.FromSlash(path)
}
parts := strings.Split(path, string(os.PathSeparator))
if parts[0] != "~" && parts[0] != "%USERPROFILE%" && parts[0] != "%userprofile%" && parts[0] != "%HOME%" && parts[0] != "%home%" {
return path, nil
5 changes: 5 additions & 0 deletions log/logger.go
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion sh/shellescape/README.md
Original file line number Diff line number Diff line change
@@ -2,10 +2,12 @@

A drop-in replacement for [alessio/shellescape](https://github.com/alessio/shellescape) / ["gopkg.in/alessio/shellescape.v1"]("gopkg.in/alessio/shellescape.v1").

It's a bit faster and allocates a little bit less. It's quite unlikely that anyone will notice any difference in a real-world application, the is here just to reduce dependencies of the `rig` package.
It's a tiny bit faster and allocates a tiny bit less. It's quite unlikely that anyone will notice any difference in a real-world application, the is here just to reduce dependencies of the `rig` package.

To use, replace `alessio/shellescape` with `github.com/k0sproject/rig/v2/sh/shellescape` in your imports.

In addition to the original package, this package also includes `Unquote`, `Split` and `Expand` (which supports `${var}`, `$var`, `$(cmd)` and `${var:-word}` and some other expansions).

## Benchmarks

Just out of curiosity.
449 changes: 449 additions & 0 deletions sh/shellescape/expand.go

Large diffs are not rendered by default.

138 changes: 138 additions & 0 deletions sh/shellescape/expand_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package shellescape_test

import (
"os"
"strings"
"testing"

"github.com/k0sproject/rig/v2/sh/shellescape"
"github.com/stretchr/testify/require"
)

func TestExpand(t *testing.T) {
originalValue := os.Getenv("TEST_VAR")
defer os.Setenv("TEST_VAR", originalValue)
os.Setenv("TEST_VAR", "test value")

t.Run("dollar", func(t *testing.T) {
result, err := shellescape.Expand("show me the $TEST_VAR.")
require.NoError(t, err)
require.Equal(t, "show me the test value.", result)
})
t.Run("curly", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${TEST_VAR}.")
require.NoError(t, err)
require.Equal(t, "show me the test value.", result)
})
t.Run("command", func(t *testing.T) {
result, err := shellescape.Expand("show me the $(echo test $(echo value)).", shellescape.ExpandExec())
require.NoError(t, err)
require.Equal(t, "show me the test value.", result)
})
t.Run("command with curly", func(t *testing.T) {
result, err := shellescape.Expand("show me the $(echo $(echo ${TEST_VAR})).", shellescape.ExpandExec())
require.NoError(t, err)
require.Equal(t, "show me the test value.", result)
})
}

func TestExpandParamExpansion(t *testing.T) {
originalValue := os.Getenv("TEST_VAR")
defer os.Setenv("TEST_VAR", originalValue)
os.Setenv("TEST_VAR", "test value")

originalValue2 := os.Getenv("TEST_VAR2")
defer os.Setenv("TEST_VAR2", originalValue2)
os.Unsetenv("TEST_VAR2")

t.Run("simple", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${TEST_VAR}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the test value.", result)
})

t.Run("colon", func(t *testing.T) {
t.Run("default", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${TEST_VAR:-default}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the test value.", result)
})
t.Run("default with empty", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${TEST_VAR:-}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the test value.", result)
})
t.Run("default with empty and space", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${TEST_VAR:- }.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the test value.", result)
})
t.Run("default with unset", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${TEST_VAR2:-default}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the default.", result)
})
t.Run("anti-default with unset", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${TEST_VAR2:+default}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the .", result)
result, err = shellescape.Expand("show me the ${TEST_VAR:+default}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the default.", result)
})
t.Run("offset", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${TEST_VAR:2}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the st value.", result)
})
t.Run("offset with empty", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${TEST_VAR2:2}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the .", result)
})
t.Run("offset with negative", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${TEST_VAR: -2}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the ue.", result)
})
t.Run("offset with negative and length", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${TEST_VAR: -3:2}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the lu.", result)
})
t.Run("offset with length", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${TEST_VAR:2:2}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the st.", result)
})
})

t.Run("length", func(t *testing.T) {
t.Run("length", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${#TEST_VAR}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the 10.", result)
})
t.Run("length with empty", func(t *testing.T) {
result, err := shellescape.Expand("show me the ${#TEST_VAR2}.", shellescape.ExpandParam())
require.NoError(t, err)
require.Equal(t, "show me the 0.", result)
})
})

t.Run("variable names", func(t *testing.T) {
if orig, ok := os.LookupEnv("TEST_VAR2"); ok {
defer os.Setenv("TEST_VAR2", orig)
} else {
defer os.Unsetenv("TEST_VAR2")
}
os.Setenv("TEST_VAR2", "test value2")
result, err := shellescape.Expand("show me the ${!TEST_VA*}", shellescape.ExpandParam())
require.NoError(t, err)
result = strings.TrimPrefix(result, "show me the ")
result = strings.TrimSuffix(result, ".")
items := strings.Split(result, " ")
require.Contains(t, items, "TEST_VAR")
require.Contains(t, items, "TEST_VAR2")
})
}
142 changes: 142 additions & 0 deletions sshconfig/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
## sshconfig

[![GoDoc](https://godoc.org/github.com/k0sproject/rig/v2/sshconfig/?status.svg)](https://godoc.org/github.com/k0sproject/rig/v2/sshconfig)

This directory contains an implementation of a parser for OpenSSH's [`ssh_config`](https://man7.org/linux/man-pages/man5/ssh_config.5.html) file format.

The format and its parsing rules are slightly complicated.

Further reading:

- [The ssh_config(5) man page](https://man.openbsd.org/ssh_config)
- [readconf.c](https://github.com/openssh/openssh-portable/blob/master/readconf.c) from the openssh source code.
- [Quirks of parsing SSH configs](https://sthbrx.github.io/blog/2023/08/04/quirks-of-parsing-ssh-configs/)

### This implementation

Implemented features:

- All of the fields listed in the [ssh_config(5)](https://man.openbsd.org/ssh_config) man page (and two additional Apple specific fields).
- Partial [`Match`](https://man.openbsd.org/ssh_config#Match) directive support. `Address`, `LocalAddress`, `LocalPort` and `RDomain` are not implemented (because they require an established connection, which could be achievable later if needed).
- Partial [`TOKENS`](https://man.openbsd.org/ssh_config#TOKENS) expansion support. Like above, expanding some of the tokens would require an established connection to the host while parsing the config.
- `Include` directive support, the parser will follow the `Include` directives as expected, in lexical order like the OpenSSH implementation. It will also detect circular includes.
- Expansion of `~` and environment variables in the values for the supported fields listed on the man page.
- Support for list modifier prefixes for fields like `HostKeyAlgorithms` or `KexAlgorithms` where you can use a `+` prefix to append to the default list, a `-` prefix to remove from the default list, or `^` to prepend to the default list.
- Support and validation for "multistate fields" as they are called in OpenSSH's `readconf.c` which can act like booleans but can also contain other string values, such as `yes`, `no`, `ask`, `always`, `none`, etc.
- A "strict" mode for supporting the [`IgnoreUnknown`](https://man.openbsd.org/ssh_config#IgnoreUnknown) directive. When enabled, the parser will throw an error when it encounters an unknown directive. To enable, use the `sshconfig.WithStrict()` option when creating the parser.
- The origin based value precedence is correctly implemented as described in the specification and as observed in the OpenSSH implementation.
- [Hostname canonicalization](https://sleeplessbeastie.eu/2020/08/24/how-to-perform-hostname-canonicalization/).
- Original-like unquoting and splitting of values based on `argv_split` from the original C source converted to go.

### Status

The parser has not been tested outside of the development environment **at all** yet.

If there's interest, the parser can be extracted from the `rig` repository and published as a separate module.

### Usage

Typically you first create a parser via `sshconfig.NewParser(nil)` and then call `Apply(obj)` on it with a struct that you want to populate with the values for a given host from the system's ssh configuration files. You can also pass in an `io.Reader` to `NewParser` to read from a custom source instead of the default locations.

You can use the provided `sshconfig.Config` which includes all the known configuration fields or you can define a struct with only a subset of the fields. The object must have the same field names as listed in the `ssh_config` man page and at least a `Host string` field must exist for the parsing to work.

```go
package main

import(
"fmt"
"github.com/k0sproject/rig/v2/sshconfig"
)

func main() {
// this will read the configurations from the default locations.
parser, err := sshconfig.NewParser(nil)
// To read from a specific file or a string, pass in an io.Reader like:
// parser, err := sshconfig.NewParser(strings.NewReader("Host example.com\nIdentityFile ~/.ssh/id_rsa\n"))

if err != nil {
panic(err)
}
host := &sshconfig.Config{}
if err := parser.Apply(host, "example.com"); err != nil {
panic(err)
}
fmt.Println(host.IdentityFile[0])
}
```

There's also a `sshconfig.ConfigFor` shorthand for when you're not expecting to need the configuration for more than one host:

```go
package main

import(
"fmt"
"github.com/k0sproject/rig/v2/sshconfig"
)

func main() {
config, err := sshconfig.ConfigFor("example.com")
if err != nil {
panic(err)
}
fmt.Println(config.IdentityFile[0])
}
```

You can output a `ssh_config` formatted string using `sshconfig.Dump`:

```go
package main

import(
"fmt"
"github.com/k0sproject/rig/v2/sshconfig"
)

func main() {
config, err := sshconfig.ConfigFor("example.com")
if err != nil {
panic(err)
}
str, err := sshconfig.Dump(config)
fmt.Println(str)
}
```

This will output something like:

```text
Host example.com
AddKeysToAgent no
AddressFamily any
BatchMode no
...
```

### Alternatives

Currently there seems to exist two alternatives:

- [`kevinburke/ssh_config`](https://github.com/kevinburke/ssh_config)

This is a somewhat complete implementation but there are several issues with it:

- Does not support [`Match`](https://man.openbsd.org/ssh_config#Match) directives at all.
- Does not support [Hostname canonicalization](https://man.openbsd.org/ssh_config#CanonicalizeHostname), which requires an additional pass of parsing if the hostname gets changed.
- Not all of the Boolean fields listed [here](https://github.com/kevinburke/ssh_config/blob/1d09c0b50564c4a7f8c56c9d5d6d935e06ee94da/validators.go#L19) are actually regular booleans. For example, `ForwardAgent` can be a path to an agent socket or an environment variable name.
- Not all of the default values are correct or set. In fact, [the list of defaults](https://github.com/kevinburke/ssh_config/blame/1d09c0b50564c4a7f8c56c9d5d6d935e06ee94da/validators.go#L18) is from 2017. The default for `IdentityFile` is [`~/.ssh/identity`](https://github.com/kevinburke/ssh_config/blob/1d09c0b50564c4a7f8c56c9d5d6d935e06ee94da/validators.go#L120) which started to phase out upon the release of OpenSSH 3.0 in 2001 which started defaulting to `~/.ssh/id_dsa`. The list of [`defaultProtocol2Identities`](https://github.com/kevinburke/ssh_config/blob/1d09c0b50564c4a7f8c56c9d5d6d935e06ee94da/validators.go#L165-L170) is not used and is missing a couple of entries.
- It does not support the list modifier prefixes for fields like `HostKeyAlgorithms` or `KexAlgorithms` where you can use the `+` or `^` prefixes to append or prepend to the default list or the `-` prefix to remove from it.
- When you need to know multiple settings for a given host, you have to query for each setting separately.
- Values need to be unquoted and expanded and converted to correct types manually and values for white-space or comma separated list fields need to be split manually.
- No expansion of `~/` or environment variables in the values (there's a [pull request](https://github.com/kevinburke/ssh_config/pull/31) from 2021).
- No expansion of the [TOKENS](https://man.openbsd.org/ssh_config#TOKENS) (there's a [pull request](https://github.com/kevinburke/ssh_config/pull/49) from 2022).
- Doesn't seem to be actively maintained, there are open unanswered issues and unmerged pull requests from several years ago. See [PR comment](https://github.com/kevinburke/ssh_config/pull/37#issuecomment-1095599334).

- [`mikkeloscar/sshconfig`](https://github.com/mikkeloscar/sshconfig)

A simplistic implementation that only supports a very limited subset of fields (`Host, HostName, User, Port, IdentityFile, HostKeyAlgorithms, ProxyCommand, LocalForward, RemoteForward, DynamicForward, Ciphers and MACs`). It implements even less features and quirks of the syntax. The GPL-3 license may be problematic for some.

### Contributing

Issues and PRs are welcome. Especially ones that eliminate any of the `//nolint` comments.
1,581 changes: 1,581 additions & 0 deletions sshconfig/config.go

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions sshconfig/defaultconfig_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package sshconfig

var defaultGlobalConfigPath = func() string {
return "/etc/ssh/ssh_config"
}

// sshDefaultConfig is the default configuration for an SSH client.
// this is obtained via "ssh -G" on a mac without any ssh config
// files. the output is sorted and the fields with key
// "identityfile" are joined into a single line.
//
// note that some of the boolean values are displayed as "true"/"false"
// instead of "yes"/"no".
const sshDefaultConfig = `
addkeystoagent false
addressfamily any
applemultipath no
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
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
gssapiauthentication no
gssapidelegatecredentials no
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 ~/.ssh/id_ecdsa ~/.ssh/id_ecdsa_sk ~/.ssh/id_ed25519 ~/.ssh/id_ed25519_sk ~/.ssh/id_rsa ~/.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 $SSH_SK_PROVIDER
serveralivecountmax 3
serveraliveinterval 30
sessiontype default
stdinnull 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
xauthlocation /usr/X11R6/bin/xauth
`
87 changes: 87 additions & 0 deletions sshconfig/defaultconfig_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
//go:build !windows && !darwin

package sshconfig

var defaultGlobalConfigPath = func() string {
return "/etc/ssh/ssh_config"
}

// sshDefaultConfig is the default configuration for an SSH client.
// this is obtained via "ssh -G" on a fresh linux machine without
// any ssh config files. the output is sorted and the fields with key
// "identityfile" are joined into a single line.
//
// note that some of the boolean values are displayed as "true"/"false"
// instead of "yes"/"no".
const sshDefaultConfig = `
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
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 ~/.ssh/id_ecdsa ~/.ssh/id_ecdsa_sk ~/.ssh/id_ed25519 ~/.ssh/id_ed25519_sk ~/.ssh/id_rsa ~/.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
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
xauthlocation /usr/bin/xauth
`
88 changes: 88 additions & 0 deletions sshconfig/defaultconfig_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package sshconfig

import (
"os"
"path"
)

var defaultGlobalConfigPath = func() string {
pd := os.Getenv("PROGRAMDATA")
if pd == "" {
pd = "C:/ProgramData"
}
return path.Join(pd, "ssh", "ssh_config")
}

// sshDefaultConfig is the default configuration for an SSH client.
// this is obtained via "ssh -G" on a fresh windows machine without
// any ssh config files.
//
// note that some of the boolean values are displayed as "true"/"false"
// instead of "yes"/"no" and the __PROGRAMDATA__ in some of the paths
// in addition to mixed path separators.
const sshDefaultConfig = `
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
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 ~/.ssh/id_ecdsa ~/.ssh/id_ed25519 ~/.ssh/id_rsa ~/.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
xauthlocation __PROGRAMDATA__/ssh/bin/xauth
`
74 changes: 74 additions & 0 deletions sshconfig/defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package sshconfig

import (
"bufio"
"bytes"
"os/exec"
"strings"
"sync"
)

var haveOpenSSH = sync.OnceValue(func() bool {
cmd := exec.Command("ssh", "-V")
return cmd.Run() == nil
})

func getFromOpenSSH(key string) string {
if !haveOpenSSH() {
return ""
}
cmd := exec.Command("ssh", "-Q", key)
out, err := cmd.Output()
if err != nil {
return ""
}
scanner := bufio.NewScanner(bytes.NewReader(out))
var items []string
for scanner.Scan() {
row := strings.TrimSpace(scanner.Text())
if row == "" {
continue
}
if strings.Contains(row, " ") {
continue
}
items = append(items, row)
}
if scanner.Err() != nil {
return ""
}
return strings.Join(items, ",")
}

// from openssh 9.4p1, libreSSL 3.3.6.
const (
defaultCASig = "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"
defaultCiphers = "chacha20-poly1305@openssh.com,aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm@openssh.com,aes256-gcm@openssh.com"
defaultHostkeyAlgos = "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"
defaultKexAlgos = "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"
defaultMACs = "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"
)

// defaultList returns the default list of algos for the given key. it tries to get them from the local openssh installation when available.
func defaultList(key string) string {
if fromSSH := getFromOpenSSH(key); fromSSH != "" {
return fromSSH
}
switch key {
case "casignaturealgorithms":
return defaultCASig
case "ciphers":
return defaultCiphers
case "hostbasedacceptedalgorithms":
return defaultHostkeyAlgos
case "hostkeyalgorithms":
return defaultHostkeyAlgos
case "kexalgorithms":
return defaultKexAlgos
case "macs":
return defaultMACs
case "pubkeyacceptedalgorithms":
return defaultHostkeyAlgos
}
return ""
}
174 changes: 174 additions & 0 deletions sshconfig/keys.go

Large diffs are not rendered by default.

931 changes: 931 additions & 0 deletions sshconfig/options/options.go

Large diffs are not rendered by default.

387 changes: 387 additions & 0 deletions sshconfig/parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,387 @@
// Package sshconfig provides a parser for OpenSSH ssh_config files as
// documented in the [man page].
//
// # Implemented features:
//
// - Go mappings for all of the keys known to OpenSSH client (and two
// additional Apple specific fields from the Mac port).
// - Partial Match directive support. Address, LocalAddress, LocalPort and
// RDomain are not implemented because they require passing
// in a connection, which is not (yet?) implemented.
// - Partial token expansion support. Like above, expanding some of the
// tokens would require an established connection.
// - Include directive support, the parser will follow the Include directives
// in lexical order as specified in the spec.
// - Expansion of ~ and environment variables in the values where applicable
// (the enabled fields are listed in the [man page]).
// - Support for list modifier prefixes for fields like HostKeyAlgorithms or
// KexAlgorithms where you can use "+" prefix to append to default list,
// "-" to remove from the default list and ^ to prepend to the default
// list.
// - Support for boolean fields which can also have string values (yes, no,
// ask, always, none, etc.). These are not enforced or validated like in
// the OpenSSH implementation, if the field is a MultiStateBooleanValue,
// it will accept any string value, but Bool() will return the boolean
// value and an ok flag indicating if the value is one of the known boolean
// values.
// - "Strict" mode for supporting the IgnoreUnknown directive. When enabled,
// the parser will throw an error when it encounters an unknown directive.
// By default this is not enabled. To enable, use the
// [WithErrorOnUnknown] option when creating the parser.
// - The origin of each value can be determined.
// - The order based value precedence is correctly implemented as described
// in the specification.
// - Hostname canonicalization.
// - Original-like unquoting and splitting of values based on `argv_split`
// from the OpenSSH source converted to go.
//
// # Usage:
//
// What you create a new [Parser], what you get is not a key-value store that
// you can use to query values for keys. Upon initialization, the Parser
// reads in the ssh configuration files and creates an internal tree structure
// that will be iterated over for each host configuration object to be
// populated with values from the configuration.
//
// The SSH configuration files do not define a list of hosts, it's not a
// phone-book. The configuration is a set of rules that match hostnames or
// other attributes like the username or the current local address and
// the settings are applied only when they match the current connection.
//
// You can either use the full [Config] which includes all the known keys
// or you can define a struct with a subset of the keys that you are
// interested in.
//
// [man page]: https://man7.org/linux/man-pages/man5/ssh_config.5.html
package sshconfig

import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/user"
"sync"

"github.com/k0sproject/rig/v2/log"
)

/*
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 overridden 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)
*/

// Exported errors.
var (
// ErrSyntax is returned when the config file has a syntax error.
ErrSyntax = errors.New("syntax error")

// ErrInvalidObject is returned when the object passed to Apply is not compatible.
ErrInvalidObject = errors.New("invalid object")

// ErrNotImplemented is returned when encountering a ssh_config feature that has not been
// implemented.
ErrNotImplemented = errors.New("not implemented")

username = sync.OnceValue(
func() string {
if user, err := user.Current(); err == nil {
return user.Username
}
return ""
},
)

userhome = sync.OnceValue(
func() string {
if home, err := os.UserHomeDir(); err == nil {
return home
}
return ""
},
)
)

// the word "host" is used so many times it's worth making a constant for it.
const fkHost = "host"

// Parser for OpenSSH ssh_config files.
type Parser struct {
mu sync.Mutex

iter *treeIterator

options parserOptions
}

// parserOptions for the ssh config parser.
type parserOptions struct {
errorOnUnknown bool
globalConfigPath string
userConfigPath string
globalConfigReader io.Reader
userConfigReader io.Reader
executor executor
nofinalize bool
home string
}

type executor interface {
Run(cmd string, args ...string) error
}

type defaultExecutor struct{}

func (d defaultExecutor) Run(cmd string, args ...string) error {
if err := exec.Command(cmd, args...).Run(); err != nil {
return fmt.Errorf("run command %q: %w", cmd, err)
}
return nil
}

// ParserOption is a function that sets a parser option.
type ParserOption func(*parserOptions)

// newParserOptions returns a new ParserOptions with the given options applied.
func newParserOptions(opts ...ParserOption) parserOptions {
options := parserOptions{executor: defaultExecutor{}}
for _, opt := range opts {
opt(&options)
}
return options
}

// WithStrict is a functional option that makes the parser respect the 'IgnoreUnknown'
// directive, thus making it error out on any encountered key that is not found and is not listed
// in the "IgnoreUnknown" config field.
func WithStrict() ParserOption {
return func(o *parserOptions) {
o.errorOnUnknown = true
}
}

// WithNoFinalize is a functional option that disables the finalization of the object.
func WithNoFinalize() ParserOption {
return func(o *parserOptions) {
o.nofinalize = true
}
}

// WithGlobalConfigPath is a functional option that overrides the default global config path
// (/etc/ssh/ssh_config or %PROGRAMDATA%/ssh/ssh_config on Windows).
func WithGlobalConfigPath(path string) ParserOption {
return func(o *parserOptions) {
o.globalConfigPath = path
}
}

// WithUserConfigPath is a functional option that overrides the default user config path (~/.ssh/config).
func WithUserConfigPath(path string) ParserOption {
return func(o *parserOptions) {
o.userConfigPath = path
}
}

// WithGlobalConfigReader is a functional option that overrides the default global config reader.
func WithGlobalConfigReader(r io.Reader) ParserOption {
return func(o *parserOptions) {
o.globalConfigReader = r
}
}

// WithUserConfigReader is a functional option that overrides the default user config reader.
func WithUserConfigReader(r io.Reader) ParserOption {
return func(o *parserOptions) {
o.userConfigReader = r
}
}

// WithExecutor is a functional option that overrides the default executor for testing (or disabling?) purposes.
func WithExecutor(e executor) ParserOption {
return func(o *parserOptions) {
o.executor = e
}
}

// WithUserHome is a functional option that sets the home directory for the current user.
func WithUserHome(home string) ParserOption {
return func(o *parserOptions) {
o.home = home
}
}

// NewParser returns a new Parser. If r is nil, the default ssh config files will be read
// from ~/.ssh/config and /etc/ssh/ssh_config (or %PROGRAMDATA%/ssh/ssh_config on Windows).
//
// Calling NewParser will perform the initial parsing of the configuration files and returns
// an error if it fails.
//
// If your use-case is to parse the configuration for multiple hosts, you only need to
// create one parser and use it to apply settings to multiple host objects by calling
// Apply multiple times with different objects..
func NewParser(r io.Reader, opts ...ParserOption) (*Parser, error) {
options := newParserOptions(opts...)
treeParser := newTreeParser(r)
if options.globalConfigPath != "" {
treeParser.GlobalConfigPath = options.globalConfigPath
}
if options.userConfigPath != "" {
treeParser.UserConfigPath = options.userConfigPath
}
if options.globalConfigReader != nil {
treeParser.GlobalConfigReader = options.globalConfigReader
}
if options.userConfigReader != nil {
treeParser.UserConfigReader = options.userConfigReader
}

iter, err := treeParser.Parse()
if err != nil {
return nil, fmt.Errorf("failed to tokenize ssh config: %w", err)
}
return &Parser{iter: iter, options: options}, nil
}

// ConfigFor returns a new Config for the given host.
//
// This is a shorthand for creating a [Parser], and using it to populate a [Config] object.
//
// Do not use this if you need to get configurations for multiple hosts.
func ConfigFor(host string, opts ...ParserOption) (*Config, error) {
parser, err := NewParser(nil, opts...)
if err != nil {
return nil, fmt.Errorf("create parser: %w", err)
}
config := &Config{}
if err := parser.Apply(config, host); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return config, nil
}

func (p *Parser) apply(setter *Setter) error {
for p.iter.Next() {
key := p.iter.Key()
values := p.iter.Values()
path := p.iter.Path()
row := p.iter.Row()

setter.applyingDefaults(path == "__default__")

switch key {
case "include":
// just keep on iterating, the tree parser has already included the file as just another beanch
continue
case fkHost:
match, err := setter.matchesHost(values...)
if err != nil {
return err
}
if !match {
// Not for this host, skip the block.
p.iter.Skip()
}
case "match":
match, err := setter.matchesMatch(values...)
if err != nil {
return fmt.Errorf("can't process Match directive %q in %s:%d: %w", values, path, row, err)
}
log.Trace(context.Background(), "match directive result", "match", match, "values", values, "path", path, "row", row)
if !match {
// Not for this host, skip the block.
p.iter.Skip()
}
default:
if err := setter.Set(key, values...); err != nil {
return fmt.Errorf("set %q: %w", key, err)
}
}
}
return nil
}

// Apply the ssh config into the passed in object, such as an instance
// of [Config]. The object needs at least a Host (string) field.
func (p *Parser) Apply(obj any, host string) error {
p.mu.Lock()
defer p.mu.Unlock()

p.reset()

setter, err := NewSetter(obj)
if err != nil {
return fmt.Errorf("create setter: %w", err)
}

if p.options.errorOnUnknown {
setter.ErrorOnUnknownFields = true
}

if p.options.home != "" {
setter.home = p.options.home
}

setter.executor = p.options.executor

if err := setter.Set(fkHost, host); err != nil {
return fmt.Errorf("set host %q: %w", host, err)
}

p.iter.Reset()
if err := p.apply(setter); err != nil {
return fmt.Errorf("failed to apply ssh config: %w", err)
}

if err := setter.CanonicalizeHostname(); err != nil {
return fmt.Errorf("canonicalize hostname: %w", err)
}

if setter.wantFinal || setter.HostChanged() {
setter.doingFinal()
p.iter.Reset()
if err := p.apply(setter); err != nil {
return fmt.Errorf("second pass of ssh config application failed: %w", err)
}
}

if !p.options.nofinalize {
if err := setter.Finalize(); err != nil {
return fmt.Errorf("finalize: %w", err)
}
}

return nil
}

func (p *Parser) reset() {
p.iter.Reset()
}

// Reset resets the parser to its initial state.
func (p *Parser) Reset() {
p.mu.Lock()
defer p.mu.Unlock()
p.reset()
}
430 changes: 430 additions & 0 deletions sshconfig/parser_test.go

Large diffs are not rendered by default.

87 changes: 87 additions & 0 deletions sshconfig/patternmatch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package sshconfig

import (
"fmt"
"regexp"
"strings"
"unicode"
)

// patternMatch compares a single pattern against a string.
func patternMatch(value, pattern string) (bool, error) {
if pattern == "*" {
return true, nil
}

if pattern == value {
return true, nil
}

if !strings.ContainsAny(pattern, "*?") {
return pattern == value, nil
}

var sb strings.Builder
sb.WriteString("^")
for _, ch := range pattern {
switch ch {
case '*':
sb.WriteString(".*")
case '?':
sb.WriteString(".")
default:
if !unicode.IsLetter(ch) && !unicode.IsNumber(ch) {
sb.WriteRune('\\')
}
sb.WriteRune(ch)
}
}
sb.WriteString("$")

regex, err := regexp.Compile(sb.String())
if err != nil {
return false, fmt.Errorf("invalid pattern: %w", err)
}

return regex.MatchString(value), nil
}

// patternMatchAll returns true if the value matches the combination of
// multiple patterns.
//
// A !negated patterns alone will never yield a match unless there is also a positive
// match in the combination.
func patternMatchAll(value string, patterns ...string) (bool, error) {
var hasPositiveMatch bool

for _, pattern := range patterns {
if pattern == "" {
continue
}
subPatterns := strings.Split(pattern, ",")
for _, subPattern := range subPatterns {
subPattern = strings.TrimSpace(subPattern)
if subPattern == "" {
continue
}
negate := strings.HasPrefix(subPattern, "!")
if negate {
subPattern = subPattern[1:]
}

match, err := patternMatch(value, subPattern)
if err != nil {
return false, err
}

if match {
if negate {
return false, nil
}
hasPositiveMatch = true
}
}
}

return hasPositiveMatch, nil
}
301 changes: 301 additions & 0 deletions sshconfig/printer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
package sshconfig

import (
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"time"
)

// Dump an SSH configuration to a string.
//
// Example:
//
// config, _ := sshconfig.ConfigFor("example.com")
// dump, _ := sshconfig.Dump(config)
// fmt.Println(dump)
// // Output:
// // Host example.com
// // Hostname example.com
// // User user
// // ..and so on
func Dump(obj any) (string, error) {
p, err := newprinter(obj)
if err != nil {
return "", fmt.Errorf("can't create printer: %w", err)
}
return p.dump(), nil
}

type printer struct {
setter *Setter
}

func newprinter(obj any) (*printer, error) {
setter, err := NewSetter(obj)
if err != nil {
return nil, fmt.Errorf("can't create a setter for importing field info: %w", err)
}
p := &printer{
setter: setter,
}
return p, nil
}

func (p *printer) fieldByName(key string) (reflect.Value, bool) {
return p.setter.fieldByName(key)
}

func (p *printer) get(key string, expectedKinds ...reflect.Kind) (reflect.Value, error) {
return p.setter.get(key, expectedKinds...)
}

func quote(s string) string {
if s == "" {
return ""
}
if strings.Contains(s, " ") {
return strconv.Quote(s)
}
return s
}

func (p *printer) stringify(field reflect.Value) (string, bool) {
if field.Kind() == reflect.String {
return quote(field.String()), true
}
if field.Kind() == reflect.Ptr && field.Elem().Kind() == reflect.String {
return quote(field.Elem().String()), true
}
if field.CanInterface() {
if s, ok := field.Interface().(fmt.Stringer); ok {
return quote(s.String()), true
}
}
if field.CanAddr() {
if s, ok := field.Addr().Interface().(fmt.Stringer); ok {
return quote(s.String()), true
}
}
return "", false
}

func (p *printer) stringer(key string) (string, bool) {
field, ok := p.fieldByName(key)
if !ok {
return "", false
}
return p.stringify(field)
}

func (p *printer) stringerslicejoin(key string, separator rune) (string, bool) {
field, err := p.get(key, reflect.Slice)
if err != nil {
return "", false
}
if field.Len() == 0 {
return "", false
}
sb := &strings.Builder{}
for i := 0; i < field.Len(); i++ {
if i > 0 {
sb.WriteRune(separator)
}
if s, ok := p.stringify(field.Index(i)); ok {
if strings.HasSuffix(key, "File") && s[1] == ':' {
// Windows path with a drive letter
s = strings.ReplaceAll(s, "/", "\\")
}
sb.WriteString(s)
}
}
return sb.String(), true
}

func (p *printer) stringerslice(key string) (string, bool) {
return p.stringerslicejoin(key, ' ')
}

func (p *printer) stringercsv(key string) (string, bool) {
return p.stringerslicejoin(key, ',')
}

func (p *printer) number(key string) (string, bool) {
if field, err := p.get(key, reflect.Int); err == nil {
return strconv.FormatInt(field.Int(), 10), true
}
if field, err := p.get(key, reflect.Uint); err == nil {
if key == "StreamLocalBindMask" {
return "0" + strconv.FormatInt(int64(field.Uint()), 8), true
}
return strconv.FormatInt(int64(field.Uint()), 10), true
}
return "", false
}

func (p *printer) boolean(key string) (string, bool) {
if field, err := p.get(key, reflect.Bool); err == nil {
if field.Bool() {
return yes, true
}
return no, true
}

if field, err := p.get(key, reflect.String); err == nil {
return field.String(), true
}

return "", false
}

func (p *printer) duration(key string) (string, bool) {
kind := reflect.TypeOf(time.Duration(0)).Kind()
if field, err := p.get(key, kind); err == nil {
if field.CanInterface() {
if d, ok := field.Interface().(time.Duration); ok {
return strconv.Itoa(int(d.Seconds())), true
}
}
}

if field, err := p.get(key, reflect.String); err == nil {
return field.String(), true
}

return "", false
}

func (p *printer) channeltimeout(key string) (string, bool) {
kind := reflect.TypeOf(map[string]time.Duration{}).Kind()
if field, err := p.get(key, kind); err == nil { //nolint:nestif
if field.CanInterface() {
if d, ok := field.Interface().(map[string]time.Duration); ok {
sb := &strings.Builder{}
for k, v := range d {
if sb.Len() > 0 {
sb.WriteRune(' ')
}
sb.WriteString(k)
sb.WriteRune('=')
sb.WriteString(strconv.Itoa(int(v.Seconds())))
}
return sb.String(), true
}
}
}
if field, err := p.get(key, reflect.Slice); err == nil {
sb := &strings.Builder{}
for i := 0; i < field.Len(); i++ {
if s, ok := p.stringify(field.Index(i)); ok {
if sb.Len() > 0 {
sb.WriteRune(' ')
}
sb.WriteString(s)
}
}
return sb.String(), true
}
if field, err := p.get(key, reflect.String); err == nil {
return field.String(), true
}
return "", false
}

func (p *printer) forward(key string) (string, bool) {
kind := reflect.TypeOf(map[string]string{}).Kind()
if field, err := p.get(key, kind); err == nil { //nolint:nestif
if field.CanInterface() {
if d, ok := field.Interface().(map[string]string); ok {
sb := &strings.Builder{}
for k, v := range d {
if sb.Len() > 0 {
sb.WriteRune(' ')
}
sb.WriteString(k)
sb.WriteRune(' ')
sb.WriteString(v)
}
return sb.String(), true
}
}
}
if field, err := p.get(key, reflect.Slice); err == nil {
sb := &strings.Builder{}
for i := 0; i < field.Len(); i++ {
if s, ok := p.stringify(field.Index(i)); ok {
if sb.Len() > 0 {
sb.WriteRune(' ')
}
sb.WriteString(s)
}
}
}
return "", false
}

func (p *printer) dump() string { //nolint:cyclop
sb := &strings.Builder{}
keys := make([]string, len(knownKeys))
i := 0
for key := range knownKeys {
keys[i] = key
i++
}
sort.Strings(keys)
host := p.setter.getHost()
if host != "" {
sb.WriteString("Host ")
sb.WriteString(host)
sb.WriteRune('\n')
}
for _, key := range keys {
if key == "Host" {
continue
}
keyinfo, ok := knownKeys[key]
if !ok {
continue
}
if keyinfo.printFunc == nil {
continue
}
if key == "localforward" || key == "remoteforward" { //nolint:nestif
slice, ok := p.stringerslice(key)
if !ok {
continue
}
if slice == "" {
continue
}
for i, s := range strings.Split(slice, " ") {
if i%2 == 0 {
sb.WriteRune('\t')
sb.WriteString(key)
sb.WriteRune(' ')
}
sb.WriteString(s)
if i%2 != 0 {
sb.WriteRune(' ')
} else {
sb.WriteRune('\n')
}
}
continue
}
strval, ok := keyinfo.printFunc(p, keyinfo.key)
if !ok {
continue
}
if strval == "" {
continue
}
sb.WriteRune('\t')
sb.WriteString(keyinfo.key)
sb.WriteRune(' ')
sb.WriteString(strval)
sb.WriteRune('\n')
}
return sb.String()
}
23 changes: 23 additions & 0 deletions sshconfig/printer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package sshconfig_test

import (
"strings"
"testing"

"github.com/k0sproject/rig/v2/sshconfig"
"github.com/stretchr/testify/require"
)

func TestPrinter(t *testing.T) {
config, err := sshconfig.ConfigFor("example.com")
require.NoError(t, err)
configStr, err := sshconfig.Dump(config)
require.NoError(t, err)
parser, err := sshconfig.NewParser(strings.NewReader(configStr))
require.NoError(t, err)
configNew := &sshconfig.Config{}
require.NoError(t, parser.Apply(configNew, "example.com"))
configStr2, err := sshconfig.Dump(configNew)
require.Equal(t, configStr, configStr2)
require.Equal(t, config, configNew)
}
1,765 changes: 1,765 additions & 0 deletions sshconfig/set.go

Large diffs are not rendered by default.

1,519 changes: 1,519 additions & 0 deletions sshconfig/set_test.go

Large diffs are not rendered by default.

139 changes: 139 additions & 0 deletions sshconfig/setterbuilders.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package sshconfig

import (
"fmt"
"reflect"
"slices"
"strings"
)

// This file defines custom setter function builders.

// preloadedDefaultsSetter returns a setter for the algo/cipher fields where prefixes can be used
// to modify defaults.
func preloadedDefaultsSetter(defaultValues string) setterFunc { //nolint:cyclop
defaults := strings.Split(defaultValues, ",")

return func(s *Setter, key string, values ...string) error {
if err := expectOne(values); err != nil {
return fmt.Errorf("%w: expected comma separated values, got %q", errInvalidValue, values)
}
value := values[0]
field, err := s.get(key, reflect.Slice, reflect.String)
if err != nil {
return err
}
if field.Len() > 0 {
// the precedence in these fields is standard.
// the modifiers only work when the field is empty.
// the modifiers work ONCE against the default values.
// for example, something like -aes128 will apply the defaults minus aes128
// and after that ^aes256 or +aes128 won't do anything.
return nil
}
var prefix string
switch value[0] {
case '+':
prefix = "+"
case '-':
prefix = "-"
case '^':
prefix = "^"
}
if prefix == "" {
// no modifier, just set the values
field.Set(reflect.ValueOf(strings.Split(value, ",")))
return nil
}
values = strings.Split(value[1:], ",")
var newValues []string
switch prefix {
case "+":
// + prefix appends to the defaults list
newValues = append(newValues, defaults...)
for _, v := range values {
if !slices.Contains(newValues, v) {
newValues = append(newValues, v)
}
}
case "^":
// ^ prefix prepends (or shifts) the values to the beginning of defaults list
newValues = append(newValues, values...)
for _, v := range defaults {
if !slices.Contains(newValues, v) {
newValues = append(newValues, v)
}
}
case "-":
// - prefix removes matching values from the defaults list
defaultFor:
for _, v := range defaults {
for _, value := range values {
matches, err := patternMatch(value, v)
if err != nil {
return fmt.Errorf("%w: invalid pattern %q: %w", errInvalidValue, value, err)
}
if matches {
continue defaultFor
}
newValues = append(newValues, v)
}
}
}
field.Set(reflect.ValueOf(newValues))
return nil
}
}

// enum returns a setter that can set a field to one of the predefined values.
func enum(states ...string) setterFunc {
return func(s *Setter, key string, values ...string) error {
if err := expectOne(values); err != nil {
return err
}
if !slices.Contains(states, values[0]) {
return fmt.Errorf("%w: invalid value %q: expected one of %q", errInvalidValue, values[0], states)
}
return s.setString(key, values[0])
}
}

type normalizeable[T any] interface {
Normalize() (T, error)
String() string
}

// returns a setter for the bool-like fields in options/options.go.
func extendedBoolSetter[T normalizeable[T]]() setterFunc {
return func(s *Setter, key string, values ...string) error { //nolint:varnamelen
if err := expectOne(values); err != nil {
return err
}

var extbool T
extboolVal := reflect.ValueOf(&extbool).Elem()
extboolVal.Set(reflect.ValueOf(values[0]).Convert(extboolVal.Type()))
normalized, err := extbool.Normalize()
if err != nil {
return fmt.Errorf("not a valid %q value %q: %w", key, values[0], err)
}

value := reflect.ValueOf(normalized)
field, err := s.get(key, value.Kind())
if err != nil {
if f, err := s.get(key, reflect.String); err == nil {
if f.String() != "" {
return nil
}
f.SetString(normalized.String())
return nil
}
return fmt.Errorf("%w: field %q is not a %s or a string", errInvalidField, key, reflect.TypeOf(normalized))
}
if field.String() != "" {
return nil
}
field.Set(value)
return nil
}
}
77 changes: 77 additions & 0 deletions sshconfig/splitargs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package sshconfig

import (
"errors"
"strings"
"sync"
)

var (
builderPool = sync.Pool{
New: func() interface{} {
return &strings.Builder{}
},
}

// errMismatchedQuotes is returned when the input string has mismatched quotes when unquoting.
errMismatchedQuotes = errors.New("mismatched quotes")
)

// splitArgs splits a string into arguments like argv_split in ssh C source. It is in fact almost a direct
// copy of the original function.
func splitArgs(input string, terminateOnComment bool) ([]string, error) { //nolint:cyclop
var args []string

argBuilder, ok := builderPool.Get().(*strings.Builder)
if !ok {
argBuilder = &strings.Builder{}
}
defer func() {
argBuilder.Reset()
builderPool.Put(argBuilder)
}()
argBuilder.Grow(len(input))

var quote rune

for i := 0; i < len(input); i++ {
currCh := rune(input[i])

// Skip leading whitespace
if currCh == ' ' || currCh == '\t' {
continue
}

if terminateOnComment && currCh == '#' {
break
}

// Copy the token in, removing escapes
for ; i < len(input); i++ { // weird stuff originates from the C sources
currCh = rune(input[i])
if currCh == '\\' { //nolint:gocritic,nestif
if i+1 < len(input) && (input[i+1] == '\\' || input[i+1] == '\'' || input[i+1] == '"' || (quote == 0 && input[i+1] == ' ')) {
i++ // Skip '\'
}
argBuilder.WriteRune(rune(input[i]))
} else if quote == 0 && currCh == ' ' || currCh == '\t' {
// done
break
} else if quote == 0 && (currCh == '"' || currCh == '\'') {
quote = currCh
} else if quote != 0 && currCh == quote {
quote = 0 // quote end
} else {
argBuilder.WriteRune(currCh)
}
}
if quote != 0 {
return nil, errMismatchedQuotes
}
if argBuilder.Len() > 0 {
args = append(args, argBuilder.String())
argBuilder.Reset()
}
}
return args, nil
}
423 changes: 423 additions & 0 deletions sshconfig/tree.go

Large diffs are not rendered by default.

111 changes: 111 additions & 0 deletions sshconfig/tree_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package sshconfig

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TODO This file contains tests for unexported functions and structs.
// The tests can be modified to use the exported API.

func TestTreeParser(t *testing.T) {
for _, tc := range []struct{ name, content string }{
{"spaces",
`
GlobalKnownHostsFile /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2
Host example.com
IdentityFile ~/.ssh/id_new # comment
Host foo.example.com
StrictHostKeyChecking yes
Weird "=foo #foo"
`,
},
{"equals",
`
GlobalKnownHostsFile=/etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2
Host example.com
IdentityFile=~/.ssh/id_new # comment
Host=foo.example.com
StrictHostKeyChecking=yes
Weird="=foo #foo"
`,
},
{"equals with spaces",
`
GlobalKnownHostsFile = /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2
Host example.com
IdentityFile = ~/.ssh/id_new # comment
Host = foo.example.com
StrictHostKeyChecking=yes
Weird = "=foo #foo"
`,
},
{"tabs",
strings.ReplaceAll(strings.ReplaceAll(`
GlobalKnownHostsFile=/etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2
Host=example.com
IdentityFile=~/.ssh/id_new # comment
Host=foo.example.com
StrictHostKeyChecking=yes
Weird='?foo #foo'
`, "=", "\t"), "?", "="),
},
{"crazy spaces and equals",
`
GlobalKnownHostsFile = /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2
Host = example.com
IdentityFile = ~/.ssh/id_new # comment
Host = foo.example.com
StrictHostKeyChecking = yes
Weird = "=foo #foo"
`,
},
{"crazy spaces",
`
GlobalKnownHostsFile /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2
Host example.com
IdentityFile ~/.ssh/id_new # comment
Host foo.example.com
StrictHostKeyChecking yes
Weird '=foo #foo'
`,
},
} {
t.Run(tc.name, func(t *testing.T) {
parser := newTreeParser(strings.NewReader(tc.content))
iter, err := parser.Parse()
require.NoError(t, err)
sawFields := make(map[string][]string)
for iter.Next() {
if _, ok := sawFields[iter.Key()]; !ok {
sawFields[iter.Key()] = iter.Values()
}
if iter.Key() == "host" && iter.Values()[0] == "example.com" {
iter.Skip()
}
}
saw, ok := sawFields["globalknownhostsfile"]
assert.True(t, ok)
assert.Equal(t, []string{"/etc/ssh/ssh_known_hosts", "/etc/ssh/ssh_known_hosts2"}, saw)

saw, ok = sawFields["identityfile"]
assert.True(t, ok)
assert.NotEqual(t, []string{"~/.ssh/id_new"}, saw, "Host example.com block should have been skipped")

saw, ok = sawFields["host"]
assert.True(t, ok)
assert.Equal(t, []string{"example.com"}, saw) // the iter-loop only captures the first occurence

saw, ok = sawFields["stricthostkeychecking"]
assert.True(t, ok)
assert.Equal(t, []string{"yes"}, saw)

saw, ok = sawFields["weird"]
assert.True(t, ok)
assert.Equal(t, []string{"=foo #foo"}, saw)
})
}
}

0 comments on commit e63043c

Please sign in to comment.