Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

expanded ssh_config parameters for qemu+ssh uri option #1059

Merged
merged 32 commits into from
Sep 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ba090fc
bump ssh_config to stable version which contains GetAll call
Dec 20, 2023
19893fc
refactor dialSSH and break out dialHost as support function
Dec 20, 2023
94ae671
move authentication parsing to per host part of loop
Dec 20, 2023
76ed8d0
implement per Host identity file lookup
Dec 20, 2023
7dacf0b
fix tilde (~) based home directory notation for convenience
Dec 20, 2023
7273b7d
updated go.sum
Dec 20, 2023
4abb01a
cleanup log outputs
Dec 21, 2023
f190d80
remove unnecessary local variable
Dec 21, 2023
329a202
make use of net package URI building to support correct ipv6
Jan 16, 2024
c1e4fbf
correctly use host:port format when dialing bastion host
Jan 28, 2024
100f82b
put quotes around target in case it is empty
Jan 28, 2024
e203600
if the hostname override isn't present, simply use target name
Jan 28, 2024
d47102d
add log output for port override
Jan 28, 2024
1516454
add default host key algorithm
Feb 22, 2024
7caaf18
move port configuration earlier so that hostkey callback works right
Feb 22, 2024
ad51d68
cleanup log output, add error handling for dial host
Feb 22, 2024
6754108
add support for sshconfig based known hosts file behaviour
Feb 22, 2024
60cf3f1
integrate HostKeyAlgorithms ssh_config option
Feb 22, 2024
060987d
move dial host impl so that bastion hosts have same features
Feb 22, 2024
31af282
add comments
Feb 22, 2024
3680059
use a more modern default host key
Feb 22, 2024
615b189
update auth method parse to allow for multiple private ssh keys
Feb 22, 2024
dec8f23
use a list of hostKeyAlgorithms instead of just one default
Feb 22, 2024
86ac64d
Merge branch 'dmacvicar:main' into ssh-config-parameters
memetb Mar 5, 2024
4c8eb53
use camelCase to match go coding styles
Sep 14, 2024
0da4763
use join instead of replace for a more predictable outcome
Sep 14, 2024
186e5ac
remove log.Fatal and let the upper layer deal with the logging
Sep 14, 2024
6f1618f
change magic number to constant
Sep 14, 2024
fe60b2c
code formatting to match go coding style
Sep 15, 2024
2c1c934
add missing import for filepath module (0da4763a)
Sep 15, 2024
d1aa5a8
Merge branch 'main' into ssh-config-parameters
dmacvicar Sep 15, 2024
2e39b4e
lint fixes
dmacvicar Sep 15, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/google/uuid v1.3.0
github.com/hashicorp/terraform-plugin-sdk/v2 v2.24.1
github.com/hooklift/iso9660 v1.0.0
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351
github.com/kevinburke/ssh_config v1.2.0
github.com/mattn/goveralls v0.0.11
github.com/stretchr/testify v1.8.1
golang.org/x/crypto v0.21.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVY
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck=
github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/keybase/go-crypto v0.0.0-20161004153544-93f5b35093ba/go.mod h1:ghbZscTyKdM07+Fw3KSi0hcJm+AlEUWj8QLlPtijN/M=
github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
Expand Down
237 changes: 190 additions & 47 deletions libvirt/uri/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net"
"os"
"os/user"
"path/filepath"
"strings"

"github.com/kevinburke/ssh_config"
Expand All @@ -15,26 +16,48 @@ import (
)

const (
maxHostHops = 10
defaultSSHPort = "22"
defaultSSHKeyPath = "${HOME}/.ssh/id_rsa"
defaultSSHKnownHostsPath = "${HOME}/.ssh/known_hosts"
defaultSSHConfigFile = "${HOME}/.ssh/config"
defaultSSHAuthMethods = "agent,privkey"
)

func (u *ConnectionURI) parseAuthMethods() []ssh.AuthMethod {
func (u *ConnectionURI) parseAuthMethods(target string, sshcfg *ssh_config.Config) []ssh.AuthMethod {
q := u.Query()

authMethods := q.Get("sshauth")
if authMethods == "" {
authMethods = defaultSSHAuthMethods
}

log.Printf("[DEBUG] auth methods for %v: %v", target, authMethods)

// keyfile order of precedence:
// 1. load uri encoded keyfile
// 2. load override as specified in ssh config
// 3. load default ssh keyfile path
sshKeyPaths := []string {}
sshKeyPath := q.Get("keyfile")
if sshKeyPath == "" {
sshKeyPath = defaultSSHKeyPath
if sshKeyPath != "" {
sshKeyPaths = append(sshKeyPaths, sshKeyPath)
}

keyPaths, err := sshcfg.GetAll(target, "IdentityFile")
if err != nil {
log.Printf("[WARN] unable to get IdentityFile values - ignoring")
} else {
sshKeyPaths = append(sshKeyPaths, keyPaths...)
}

if len(keyPaths) == 0 {
log.Printf("[DEBUG] found no ssh keys, using default keypath")
sshKeyPaths = []string{defaultSSHKeyPath}
}

log.Printf("[DEBUG] ssh identity files for host '%s': %s", target, sshKeyPaths);

auths := strings.Split(authMethods, ",")
result := make([]ssh.AuthMethod, 0)
for _, v := range auths {
Expand All @@ -53,17 +76,27 @@ func (u *ConnectionURI) parseAuthMethods() []ssh.AuthMethod {
agentClient := agent.NewClient(conn)
result = append(result, ssh.PublicKeysCallback(agentClient.Signers))
case "privkey":
sshKey, err := os.ReadFile(os.ExpandEnv(sshKeyPath))
if err != nil {
log.Printf("[ERROR] Failed to read ssh key: %v", err)
continue
}
for _, keypath := range sshKeyPaths {
log.Printf("[DEBUG] Reading ssh key '%s'", keypath)
path := os.ExpandEnv(keypath)
if strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err == nil {
path = filepath.Join(home, path[2:])
}
}
sshKey, err := os.ReadFile(path)
if err != nil {
log.Printf("[ERROR] Failed to read ssh key '%s': %v", keypath, err)
continue
}

signer, err := ssh.ParsePrivateKey(sshKey)
if err != nil {
log.Printf("[ERROR] Failed to parse ssh key: %v", err)
signer, err := ssh.ParsePrivateKey(sshKey)
if err != nil {
log.Printf("[ERROR] Failed to parse ssh key: %v", err)
}
result = append(result, ssh.PublicKeys(signer))
}
result = append(result, ssh.PublicKeys(signer))
case "ssh-password":
if sshPassword, ok := u.User.Password(); ok {
result = append(result, ssh.Password(sshPassword))
Expand All @@ -79,6 +112,8 @@ func (u *ConnectionURI) parseAuthMethods() []ssh.AuthMethod {
return result
}

// construct the whole ssh connection, which can consist of multiple hops if using proxy jumps,
// the ssh configuration file is loaded once and passed along to each host connection.
func (u *ConnectionURI) dialSSH() (net.Conn, error) {
sshConfigFile, err := os.Open(os.ExpandEnv(defaultSSHConfigFile))
if err != nil {
Expand All @@ -90,74 +125,182 @@ func (u *ConnectionURI) dialSSH() (net.Conn, error) {
log.Printf("[WARN] Failed to parse ssh config file: %v", err)
}

authMethods := u.parseAuthMethods()
if len(authMethods) < 1 {
return nil, fmt.Errorf("could not configure SSH authentication methods")
// configuration loaded, build tunnel
sshClient, err := u.dialHost(u.Host, sshcfg, 0)
if err != nil {
return nil, err
}

// tunnel established, connect to the libvirt unix socket to communicate
// e.g. /var/run/libvirt/libvirt-sock
address := u.Query().Get("socket")
if address == "" {
address = defaultUnixSock
}

c, err := sshClient.Dial("unix", address)
if err != nil {
return nil, fmt.Errorf("failed to connect to libvirt on the remote host: %w", err)
}

return c, nil
}

func (u *ConnectionURI) dialHost(target string, sshcfg *ssh_config.Config, depth int) (*ssh.Client, error) {

if depth > maxHostHops {
return nil, fmt.Errorf("[ERROR] dialHost failed: max tunnel depth of 10 reached")
}

log.Printf("[INFO] establishing ssh connection to '%s'", target);

q := u.Query()

port := u.Port()
if port == "" {
port = defaultSSHPort
} else {
log.Printf("[DEBUG] ssh Port is overridden to: '%s'", port);
}

hostName, err := sshcfg.Get(target, "HostName")
if err == nil {
if hostName == "" {
hostName = target;
} else {
log.Printf("[DEBUG] HostName is overridden to: '%s'", hostName);
}
}

// we must check for knownhosts and verification for each host we connect to.
// the query string values have higher precedence to local configs
knownHostsPath := q.Get("knownhosts")
knownHostsVerify := q.Get("known_hosts_verify")
doVerify := q.Get("no_verify") == ""
skipVerify := q.Has("no_verify")

if knownHostsVerify == "ignore" {
doVerify = false
skipVerify = true
} else {
strictCheck, err := sshcfg.Get(target, "StrictHostKeyChecking")
if err != nil && strictCheck == "yes" {
skipVerify = false
}
}

if knownHostsPath == "" {
knownHostsPath = defaultSSHKnownHostsPath
knownHosts, err := sshcfg.Get(target, "UserKnownHostsFile")
if err == nil && knownHosts != "" {
knownHostsPath = knownHosts
} else {
knownHostsPath = defaultSSHKnownHostsPath
}
}

hostKeyCallback := ssh.InsecureIgnoreHostKey()
if doVerify {
cb, err := knownhosts.New(os.ExpandEnv(knownHostsPath))
hostKeyAlgorithms := []string{ // https://github.com/golang/go/issues/29286
// this can be solved using https://github.com/skeema/knownhosts/tree/main
// there is an open issue requiring attention
ssh.KeyAlgoED25519,
ssh.KeyAlgoRSA,
ssh.KeyAlgoRSASHA256,
ssh.KeyAlgoRSASHA512,
ssh.KeyAlgoSKECDSA256,
ssh.KeyAlgoSKED25519,
ssh.KeyAlgoECDSA256,
ssh.KeyAlgoECDSA384,
ssh.KeyAlgoECDSA521,
}
if !skipVerify {
kh, err := knownhosts.New(os.ExpandEnv(knownHostsPath))
if err != nil {
return nil, fmt.Errorf("failed to read ssh known hosts: %w", err)
}
hostKeyCallback = cb
}
log.Printf("[DEBUG] Using known hosts file '%s' for target '%s'", os.ExpandEnv(knownHostsPath), target)

username := u.User.Username()
if username == "" {
sshu, err := sshcfg.Get(u.Host, "User")
log.Printf("[DEBUG] SSH User: %v", sshu)
if err != nil {
log.Printf("[DEBUG] ssh user: system username")
u, err := user.Current()
hostKeyCallback = func(hostname string, remote net.Addr, key ssh.PublicKey) error {
err := kh(net.JoinHostPort(hostName, port), remote, key)
if err != nil {
return nil, fmt.Errorf("unable to get username: %w", err)
log.Printf("Host key verification failed for host '%s' (%s) %v: %v", hostName, remote, key, err)
}
sshu = u.Username
return err
}
username = sshu

keyAlgs, err := sshcfg.Get(target, "HostKeyAlgorithms")
if err == nil && keyAlgs != "" {
log.Printf("Got host key algorithms '%s'", keyAlgs)
hostKeyAlgorithms = strings.Split(keyAlgs, ",")
}

}

cfg := ssh.ClientConfig{
User: username,
User: u.User.Username(),
HostKeyCallback: hostKeyCallback,
Auth: authMethods,
HostKeyAlgorithms: hostKeyAlgorithms,
Timeout: dialTimeout,
}

port := u.Port()
if port == "" {
port = defaultSSHPort
proxy, err := sshcfg.Get(target, "ProxyCommand")
if err == nil && proxy != "" {
log.Printf("[WARNING] unsupported ssh ProxyCommand '%v'", proxy)
}

sshClient, err := ssh.Dial("tcp", fmt.Sprintf("%s:%s", u.Hostname(), port), &cfg)
if err != nil {
return nil, err
proxy, err = sshcfg.Get(target, "ProxyJump")
var bastion *ssh.Client
if err == nil && proxy != "" {
log.Printf("[DEBUG] found ProxyJump '%v'", proxy)

// this is a proxy jump: we recurse into that proxy
bastion, err = u.dialHost(proxy, sshcfg, depth + 1)
if err != nil {
return nil, fmt.Errorf("failed to connect to bastion host '%v': %w", proxy, err)
}
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the err != nil, it is silently ignored instead of returning. My suggestion: if err != nil, return, then only condition on proxy being non empty to execute the recursion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unclear, can you rephrase please? Did you mean "if the error == nil" ?

Are you perhaps saying there can be a condition in which both bastion and err are nil?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I mean is that proxy, err = sshcfg.Get(target, "ProxyJump") can return error, and in that case you would continue with the flow, instead of returning the function. Yes you would skip dialing the proxy (assuming it returned empty string in addition to the error), but the flow would continue anyway.

Wouldn't it make more sense to return early if proxy, err = sshcfg.Get(target, "ProxyJump") returns an error. And then you execute the code that dials the proxy only testing for proxy != "" ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProxyJump can be absent, in which case Get() (as opposed to GetStrict())returns empty string as per sshconfig docs. This isn't an error per-se as it simply means there's no proxy. This covers case 1 of the conditional on line 250, which is err == nil and proxy == "". In this case, the flow should proceed without proxy.

The second case is if the sshconfig file had a parse error (i.e. err != nil) and we don't care about proxy. In this case, it means that the sshconfig file couldn't be parsed. Expected behaviour in those circumstances (e.g. when using ssh from cli) is to simply honor the command itself and ignore any ssh_config directives altogether, which in this case corresponds to a flow-through without Proxy.

Wouldn't it make more sense to return early if proxy, err = sshcfg.Get(target, "ProxyJump") returns an error. And then you execute the code that dials the proxy only testing for proxy != "" ?

I think there's a case to be made that (maybe) the very first successful call to sshcfg.Get() guarantees that every subsequent call is successful. However, I'd not feel comfortable breaking the abstraction and depending on this behaviour: it would make the code very convoluted and introduce race conditions (i.e. if ssh_config gets touched during this setup).

Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dmacvicar see above for my thoughts on this codepath.


address := q.Get("socket")
if address == "" {
address = defaultUnixSock
if cfg.User == "" {
sshu, err := sshcfg.Get(target, "User")
log.Printf("[DEBUG] SSH User for target '%v' is '%v'", target, sshu)
if err != nil {
log.Printf("[DEBUG] ssh user: using current login")
u, err := user.Current()
if err != nil {
return nil, fmt.Errorf("unable to get username: %w", err)
}
sshu = u.Username
}
cfg.User = sshu
}

c, err := sshClient.Dial("unix", address)
if err != nil {
return nil, fmt.Errorf("failed to connect to libvirt on the remote host: %w", err)
cfg.Auth = u.parseAuthMethods(target, sshcfg)
if len(cfg.Auth) < 1 {
return nil, fmt.Errorf("could not configure SSH authentication methods")
}

return c, nil
if (bastion != nil) {
// if this is a proxied connection, we want to dial through the bastion host
log.Printf("[INFO] SSH connecting to '%v' (%v) through bastion host '%v'", target, hostName, proxy)
// Dial a connection to the service host, from the bastion
conn, err := bastion.Dial("tcp", net.JoinHostPort(hostName, port))
if err != nil {
return nil, fmt.Errorf("failed to connect to remote host '%v': %w", target, err)
}

ncc, chans, reqs, err := ssh.NewClientConn(conn, target, &cfg)
if err != nil {
return nil, fmt.Errorf("failed to connect to remote host '%v': %w", target, err)
}

sClient := ssh.NewClient(ncc, chans, reqs)
return sClient, nil

} else {
// this is a direct connection to the target host
log.Printf("[INFO] SSH connecting to '%v' (%v)", target, hostName)
conn,err := ssh.Dial("tcp", net.JoinHostPort(hostName, port), &cfg)

if err != nil {
return nil, fmt.Errorf("failed to connect to remote host '%v': %w", target, err)
}
return conn, nil
}
}
Loading