feat(cli): Support credential brokering with sshpass (#2191)

This commit is contained in:
Louis Ruch authored and GitHub committed 2022-06-15 09:30:45 -07:00
1 parent 68eb6e2bed
commit 5ef9496e0a
6 files changed
+371 -38

No files matched your search

+6
View File
@@ -13,6 +13,12 @@ Canonical reference for changes, improvements, and bugfixes for Boundary.
credential sources for targets, similar to credential libraries from the
`vault` credential store, and thus can be brokered to users at session
authorization time. [PR](https://github.com/hashicorp/boundary/pull/2174)
* `boundary connect` Credential Brokering Integration: we have extended integration
into the `boundary connect` helpers. A new `sshpass` style has been added to the
`ssh` helper, when used, if the credential contains a username/password and `sshpass`
is installed, the command will automatically pass the credentials to the `ssh` process.
Additionally, the default `ssh` helper will now use the `username` of the brokered credential.
[PR](https://github.com/hashicorp/boundary/pull/2191).
* controller: Improve response time for listing sessions.
This also creates a new periodic job that will delete terminated
sessions after 1 hour.
+16 -1
View File
@@ -492,6 +492,15 @@ func (c *Command) Run(args []string) (retCode int) {
// Credentials are brokered when connecting to the postgres db.
// TODO: Figure out how to handle cases where we don't automatically know how to
// broker the credentials like unrecognized or multiple credentials.
case "ssh":
if c.flagExec == "sshpass" {
// If we can broker ssh credentials using sshpass, skip displaying creds on client side.
// TODO: Figure out how to handle multiple credentials
break
}
// We cannot broker credentials print to user
fallthrough
case "connect":
// "connect" indicates there is no subcommand to the connect function.
// The only way a user will be able to connect to the session is by
@@ -838,7 +847,13 @@ func (c *Command) handleExec(passthroughArgs []string) {
args = append(args, c.rdpFlags.buildArgs(c, port, ip, addr)...)
case "ssh":
args = append(args, c.sshFlags.buildArgs(c, port, ip, addr)...)
sshArgs, sshEnvs, sshErr := c.sshFlags.buildArgs(c, port, ip, addr)
if sshErr != nil {
argsErr = sshErr
break
}
args = append(args, sshArgs...)
envs = append(envs, sshEnvs...)
case "kube":
kubeArgs, err := c.kubeFlags.buildArgs(c, port, ip, addr)
@@ -0,0 +1,61 @@
package connect
import (
"errors"
"github.com/hashicorp/boundary/api/targets"
"github.com/hashicorp/boundary/internal/credential"
"github.com/mitchellh/mapstructure"
)
type usernamePasswordCredentials struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
// TODO: this should support multiple types of credentials and return an interface instead
// of only usernamepassword. Each subcommand can use the returned credentials as it needs.
func parseCredentials(creds []*targets.SessionCredential) ([]usernamePasswordCredentials, error) {
if creds == nil {
return nil, nil
}
var out []usernamePasswordCredentials
for _, cred := range creds {
if cred.CredentialSource == nil {
return nil, errors.New("missing credential source")
}
var c usernamePasswordCredentials
if cred.CredentialSource.CredentialType == string(credential.UsernamePasswordType) {
// Decode attributes from credential struct
if err := mapstructure.Decode(cred.Credential, &c); err != nil {
return nil, err
}
if c.Username != "" && c.Password != "" {
out = append(out, c)
continue
}
}
// Credential type is unspecified, make a best effort attempt to parse both username
// and password from Decoded field if it exists
if cred.Secret == nil || cred.Secret.Decoded == nil {
// No secret data continue to next credential
continue
}
switch cred.CredentialSource.Type {
case "vault", "static":
// Attempt unmarshaling into creds
if err := mapstructure.Decode(cred.Secret.Decoded, &c); err != nil {
return nil, err
}
}
if c.Username != "" && c.Password != "" {
out = append(out, c)
}
}
return out, nil
}
@@ -0,0 +1,231 @@
package connect
import (
"testing"
"github.com/hashicorp/boundary/api/targets"
"github.com/hashicorp/boundary/internal/credential"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_parseUsernamePasswordCredentials(t *testing.T) {
tests := []struct {
name string
creds []*targets.SessionCredential
wantCreds []usernamePasswordCredentials
wantErr bool
}{
{
name: "no-creds",
wantCreds: nil,
wantErr: false,
},
{
name: "no-credential-source",
creds: []*targets.SessionCredential{
{
Credential: map[string]interface{}{
"username": "user",
"password": "pass",
},
},
},
wantCreds: nil,
wantErr: true,
},
{
name: "valid-typed",
creds: []*targets.SessionCredential{
{
CredentialSource: &targets.CredentialSource{
CredentialType: string(credential.UsernamePasswordType),
},
Credential: map[string]interface{}{
"username": "user",
"password": "pass",
},
},
},
wantCreds: []usernamePasswordCredentials{
{
Username: "user",
Password: "pass",
},
},
wantErr: false,
},
{
name: "valid-typed-instead-of-decoded",
creds: []*targets.SessionCredential{
{
CredentialSource: &targets.CredentialSource{
CredentialType: string(credential.UsernamePasswordType),
},
Credential: map[string]interface{}{
"username": "user",
"password": "pass",
},
Secret: &targets.SessionSecret{
Decoded: map[string]interface{}{
"username": "secret-user",
"password": "secret-pass",
},
},
},
},
wantCreds: []usernamePasswordCredentials{
{
Username: "user",
Password: "pass",
},
},
wantErr: false,
},
{
name: "valid-vault-not-typed",
creds: []*targets.SessionCredential{
{
CredentialSource: &targets.CredentialSource{
Type: "vault",
},
Secret: &targets.SessionSecret{
Decoded: map[string]interface{}{
"username": "user",
"password": "pass",
},
},
},
},
wantCreds: []usernamePasswordCredentials{
{
Username: "user",
Password: "pass",
},
},
wantErr: false,
},
{
name: "valid-static-not-typed",
creds: []*targets.SessionCredential{
{
CredentialSource: &targets.CredentialSource{
Type: "static",
},
Secret: &targets.SessionSecret{
Decoded: map[string]interface{}{
"username": "user",
"password": "pass",
},
},
},
},
wantCreds: []usernamePasswordCredentials{
{
Username: "user",
Password: "pass",
},
},
wantErr: false,
},
{
name: "valid-multiple",
creds: []*targets.SessionCredential{
{
CredentialSource: &targets.CredentialSource{
CredentialType: string(credential.UsernamePasswordType),
},
Credential: map[string]interface{}{
"username": "user",
"password": "pass",
},
},
{
CredentialSource: &targets.CredentialSource{
CredentialType: string(credential.UsernamePasswordType),
},
Credential: map[string]interface{}{
"username": "user1",
"password": "pass1",
},
},
},
wantCreds: []usernamePasswordCredentials{
{
Username: "user",
Password: "pass",
},
{
Username: "user1",
Password: "pass1",
},
},
wantErr: false,
},
{
name: "valid-multiple-mixed",
creds: []*targets.SessionCredential{
{
CredentialSource: &targets.CredentialSource{
CredentialType: string(credential.UsernamePasswordType),
},
Credential: map[string]interface{}{
"username": "user",
"password": "pass",
},
},
{
CredentialSource: &targets.CredentialSource{
Type: "vault",
},
Secret: &targets.SessionSecret{
Decoded: map[string]interface{}{
"username": "user1",
"password": "pass1",
},
},
},
{
CredentialSource: &targets.CredentialSource{
Type: "static",
},
Secret: &targets.SessionSecret{
Decoded: map[string]interface{}{
"username": "user2",
"password": "pass2",
},
},
},
},
wantCreds: []usernamePasswordCredentials{
{
Username: "user",
Password: "pass",
},
{
Username: "user1",
Password: "pass1",
},
{
Username: "user2",
Password: "pass2",
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert, require := assert.New(t), require.New(t)
creds, err := parseCredentials(tt.creds)
if tt.wantErr {
require.Error(err)
assert.Nil(creds)
return
}
require.NoError(err)
assert.ElementsMatch(tt.wantCreds, creds)
})
}
}
+14 -29
View File
@@ -7,7 +7,6 @@ import (
"strings"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/mitchellh/mapstructure"
"github.com/posener/complete"
)
@@ -52,32 +51,18 @@ func (p *postgresFlags) defaultExec() string {
return strings.ToLower(p.flagPostgresStyle)
}
type postgresCredentials struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
func (p *postgresFlags) buildArgs(c *Command, port, ip, addr string) (args, envs []string, retErr error) {
var creds postgresCredentials
if c.sessionAuthz != nil && len(c.sessionAuthz.Credentials) > 0 {
for _, cred := range c.sessionAuthz.Credentials {
if cred.Secret == nil || cred.Secret.Decoded == nil {
continue
}
switch cred.CredentialSource.Type {
case "vault":
// Attempt unmarshaling into creds
if err := mapstructure.Decode(cred.Secret.Decoded, &creds); err != nil {
return nil, nil, fmt.Errorf("Error interpreting Vault secret: %w", err)
}
}
if creds.Username != "" && creds.Password != "" {
// In the future we can look for other types if we support other
// authentication mechanisms
break
}
var cred usernamePasswordCredentials
if c.sessionAuthz != nil {
creds, err := parseCredentials(c.sessionAuthz.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("Error interpreting secret: %w", err)
}
if len(creds) > 0 {
// Just use first credentials returned
cred = creds[0]
}
}
switch p.flagPostgresStyle {
@@ -89,13 +74,13 @@ func (p *postgresFlags) buildArgs(c *Command, port, ip, addr string) (args, envs
}
switch {
case creds.Username != "":
args = append(args, "-U", creds.Username)
case cred.Username != "":
args = append(args, "-U", cred.Username)
case c.flagUsername != "":
args = append(args, "-U", c.flagUsername)
}
if creds.Password != "" {
if cred.Password != "" {
passfile, err := ioutil.TempFile("", "*")
if err != nil {
return nil, nil, fmt.Errorf("Error saving postgres password to tmp file: %w", err)
@@ -106,7 +91,7 @@ func (p *postgresFlags) buildArgs(c *Command, port, ip, addr string) (args, envs
}
return nil
})
_, err = passfile.WriteString(fmt.Sprintf("*:*:*:*:%s", creds.Password))
_, err = passfile.WriteString(fmt.Sprintf("*:*:*:*:%s", cred.Password))
if err != nil {
return nil, nil, fmt.Errorf("Error writing password file to %s: %w", passfile.Name(), err)
}
+43 -8
View File
@@ -1,6 +1,7 @@
package connect
import (
"errors"
"fmt"
"strings"
@@ -19,7 +20,7 @@ func sshOptions(c *Command, set *base.FlagSets) {
Name: "style",
Target: &c.flagSshStyle,
EnvVar: "BOUNDARY_CONNECT_SSH_STYLE",
Completion: complete.PredictSet("ssh", "putty"),
Completion: complete.PredictSet("ssh", "putty", "sshpass"),
Default: "ssh",
Usage: `Specifies how the CLI will attempt to invoke an SSH client. This will also set a suitable default for -exec if a value was not specified. Currently-understood values are "ssh" and "putty".`,
})
@@ -41,18 +42,52 @@ func (s *sshFlags) defaultExec() string {
return strings.ToLower(s.flagSshStyle)
}
func (s *sshFlags) buildArgs(c *Command, port, ip, addr string) []string {
// Might want -t for ssh or -tt but seems fine without it for now...
var args []string
switch s.flagSshStyle {
func (s *sshFlags) buildArgs(c *Command, port, ip, addr string) (args, envs []string, retErr error) {
var cred usernamePasswordCredentials
if c.sessionAuthz != nil {
creds, err := parseCredentials(c.sessionAuthz.Credentials)
if err != nil {
return nil, nil, fmt.Errorf("Error interpreting secret: %w", err)
}
if len(creds) > 0 {
// Just use first credentials returned
cred = creds[0]
}
}
switch strings.ToLower(s.flagSshStyle) {
case "ssh":
// Might want -t for ssh or -tt but seems fine without it for now...
args = append(args, "-p", port, ip)
args = append(args, "-o", fmt.Sprintf("HostKeyAlias=%s", c.sessionAuthzData.HostId))
// SSH detects a host key change when localhost proxy port changes, disable localhost
// host key verification to avoid 'WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED...'
args = append(args, "-o", "NoHostAuthenticationForLocalhost=yes")
case "sshpass":
if cred.Password == "" {
return nil, nil, errors.New("Password is required when using sshpass")
}
// sshpass requires that the password is passed as env-var "SSHPASS"
// when the using env-vars.
envs = append(envs, fmt.Sprintf("SSHPASS=%s", cred.Password))
args = append(args, "-e", "ssh")
args = append(args, "-p", port, ip)
// sshpass cannot handle host key checking, disable localhost key verification
// to avoid error: 'SSHPASS detected host authentication prompt. Exiting.'
args = append(args, "-o", "NoHostAuthenticationForLocalhost=yes")
case "putty":
args = append(args, "-P", port, ip)
}
if c.flagUsername != "" {
switch {
case cred.Username != "":
args = append(args, "-l", cred.Username)
case c.flagUsername != "":
args = append(args, "-l", c.flagUsername)
}
return args
return args, envs, nil
}