Skip to content
12 changes: 7 additions & 5 deletions cmd/topo/setup_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ var setupKeysCmd = &cobra.Command{
}

dest := ssh.NewDestination(targetArg)
user, err := ssh.IsDestinationAlreadyConfiguredWithAnotherUser(dest)
if err != nil {
return fmt.Errorf("%w; note: a per user SSH config entry should be created when setting up keys", err)
}

dest.User = user
targetSlug := dest.Slugify()
if privateKeyPath == "" {
privateKeyPath, err = setupkeys.GetDefaultPrivateKeyPath(targetSlug)
Expand All @@ -46,11 +52,6 @@ var setupKeysCmd = &cobra.Command{
return err
}

err = ssh.IsDestinationAlreadyConfiguredWithAnotherUser(dest)
if err != nil {
return fmt.Errorf("%w; note: a per user SSH config entry should be created when setting up keys", err)
}

seq, err := setupkeys.NewKeySetup(dest, privateKeyPath, parsedKeyType)
if err != nil {
return err
Expand All @@ -64,6 +65,7 @@ var setupKeysCmd = &cobra.Command{
modifiers := []ssh.ConfigDirectiveModifier{
ssh.NewEnsureConfigDirectivePath("IdentityFile", privateKeyPath),
ssh.NewEnsureConfigDirective("IdentitiesOnly", "yes"),
ssh.NewEnsureConfigDirective("User", dest.User),
}

return ssh.CreateOrModifyConfigFile(dest, modifiers)
Expand Down
43 changes: 24 additions & 19 deletions internal/ssh/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,31 +41,36 @@ func NewConfigFromBytes(data []byte) Config {
return config
}

func IsDestinationAlreadyConfiguredWithAnotherUser(dest Destination) error {
hostConfig, err := LookupExplicitHostConfig(dest.Host, dest.Port)
func IsDestinationAlreadyConfiguredWithAnotherUser(dest Destination) (string, error) {
output, err := readConfig(Destination{Host: dest.Host, Port: dest.Port})
if err != nil {
return err
return "", err
}

if dest.User != "" && hostConfig.User != dest.User {
return fmt.Errorf("ssh host/alias %q is already associated with user %q", dest.Host, hostConfig.User)
}
return nil
return resolveConfiguredUser(dest, output)
}

// LookupExplicitHostConfig returns configuration, only if there's an explicit entry for the given host/port
func LookupExplicitHostConfig(host, port string) (Config, error) {
dest := Destination{Host: host, Port: port}
output, err := readConfig(dest)
if err != nil {
return Config{}, err
}

if !IsExplicitHostConfig(host, output) {
return Config{}, fmt.Errorf("no explicit host config found for %s", host)
func resolveConfiguredUser(dest Destination, configOutput []byte) (string, error) {
hostConfig := NewConfigFromBytes(configOutput)
isExplicitHostConfig := IsExplicitHostConfig(dest.Host, configOutput)

if isExplicitHostConfig {
if dest.User != "" && hostConfig.User != dest.User {
return "", fmt.Errorf(
"ssh host/alias %q is already associated with user %q",
dest.Host,
hostConfig.User,
)
}
} else if !isExplicitHostConfig && !dest.IsIPLiteral() {
return "", fmt.Errorf("no explicit host config found for %s", dest.Host)
} else if !isExplicitHostConfig && dest.IsIPLiteral() {
if dest.User != "" {
return dest.User, nil
}
return hostConfig.User, nil
}

return NewConfigFromBytes(output), nil
return hostConfig.User, nil
}

func IsExplicitHostConfig(host string, config []byte) bool {
Expand Down
101 changes: 101 additions & 0 deletions internal/ssh/config_resolve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package ssh

import (
"testing"

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

func TestResolveConfiguredUser(t *testing.T) {
t.Run("IP literal with no explicit config returns dest user", func(t *testing.T) {
config := []byte(`debug1: /etc/ssh/ssh_config line 57: Applying options for *
user lukpar01
hostname 10.2.2.26
`)
dest := Destination{User: "root", Host: "10.2.2.26"}

got, err := resolveConfiguredUser(dest, config)

assert.NoError(t, err)
assert.Equal(t, "root", got)
})

t.Run("IP literal with no explicit config and no dest user returns config user", func(t *testing.T) {
config := []byte(`debug1: /etc/ssh/ssh_config line 57: Applying options for *
user lukpar01
hostname 10.2.2.26
`)
dest := Destination{Host: "10.2.2.26"}

got, err := resolveConfiguredUser(dest, config)

assert.NoError(t, err)
assert.Equal(t, "lukpar01", got)
})

t.Run("non-IP alias with no explicit config returns error", func(t *testing.T) {
config := []byte(`debug1: /etc/ssh/ssh_config line 57: Applying options for *
user lukpar01
hostname board-alias
`)
dest := Destination{User: "root", Host: "board-alias"}

_, err := resolveConfiguredUser(dest, config)

assert.ErrorContains(t, err, "no explicit host config found for board-alias")
})

t.Run("explicit config with no user conflict returns config user", func(t *testing.T) {
config := []byte(`debug1: /tmp/.ssh/topo_config line 1: Applying options for board-alias
user root
hostname 10.2.2.26
debug1: /etc/ssh/ssh_config line 57: Applying options for *
`)
dest := Destination{User: "root", Host: "board-alias"}

got, err := resolveConfiguredUser(dest, config)

assert.NoError(t, err)
assert.Equal(t, "root", got)
})

t.Run("explicit config with different user returns error", func(t *testing.T) {
config := []byte(`debug1: /tmp/.ssh/topo_config line 1: Applying options for board-alias
user root
hostname 10.2.2.26
debug1: /etc/ssh/ssh_config line 57: Applying options for *
`)
dest := Destination{User: "admin", Host: "board-alias"}

_, err := resolveConfiguredUser(dest, config)

assert.ErrorContains(t, err, `ssh host/alias "board-alias" is already associated with user "root"`)
})

t.Run("explicit config with no dest user returns config user", func(t *testing.T) {
config := []byte(`debug1: /tmp/.ssh/topo_config line 1: Applying options for board-alias
user root
hostname 10.2.2.26
debug1: /etc/ssh/ssh_config line 57: Applying options for *
`)
dest := Destination{Host: "board-alias"}

got, err := resolveConfiguredUser(dest, config)

assert.NoError(t, err)
assert.Equal(t, "root", got)
})

t.Run("IP literal with explicit config and different user returns error", func(t *testing.T) {
config := []byte(`debug1: /tmp/.ssh/topo_config line 1: Applying options for 10.2.2.26
user root
hostname 10.2.2.26
debug1: /etc/ssh/ssh_config line 57: Applying options for *
`)
dest := Destination{User: "admin", Host: "10.2.2.26"}

_, err := resolveConfiguredUser(dest, config)

assert.ErrorContains(t, err, `ssh host/alias "10.2.2.26" is already associated with user "root"`)
})
}
4 changes: 4 additions & 0 deletions internal/ssh/destination.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ func (d Destination) IsLocalhost() bool {
return strings.EqualFold(d.Host, "localhost") || d.Host == "127.0.0.1"
}

func (d Destination) IsIPLiteral() bool {
return net.ParseIP(d.Host) != nil
}

func (d Destination) AsURI() string {
return d.String()
}
Expand Down
6 changes: 6 additions & 0 deletions internal/ssh/destination_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ func TestDestination(t *testing.T) {
})
})

t.Run("IsIPLiteral", func(t *testing.T) {
assert.True(t, ssh.Destination{User: "root", Host: "10.1.197.67", Port: "22"}.IsIPLiteral())
assert.True(t, ssh.Destination{User: "darth", Host: "2001:db8::1", Port: "80"}.IsIPLiteral())
assert.False(t, ssh.Destination{User: "vader", Host: "board", Port: "300"}.IsIPLiteral())
})

t.Run("Slugify", func(t *testing.T) {
t.Run("slugifies the uri", func(t *testing.T) {
d := ssh.Destination{
Expand Down
Loading