-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Loading status checks…
Merge pull request #181 from k0sproject/ssh-config-parser
Implement an SSH config parser
Showing
27 changed files
with
9,044 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
## sshconfig | ||
|
||
[](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. |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 "" | ||
} |
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
} | ||
} |