Add support for /ssh/bastion method.

This commit is contained in:
Mariano Cano 2019-11-14 18:24:58 -08:00 committed by max furman
parent a6edcd0a3d
commit 8bf3bf701e
8 changed files with 197 additions and 2 deletions

View file

@ -261,6 +261,7 @@ func (h *caHandler) Route(r Router) {
r.MethodFunc("POST", "/ssh/config/{type}", h.SSHConfig)
r.MethodFunc("POST", "/ssh/check-host", h.SSHCheckHost)
r.MethodFunc("GET", "/ssh/get-hosts", h.SSHGetHosts)
r.MethodFunc("POST", "/ssh/bastion", h.SSHBastion)
// For compatibility with old code:
r.MethodFunc("POST", "/re-sign", h.Renew)

View file

@ -557,6 +557,7 @@ type mockAuthority struct {
getSSHFederation func() (*authority.SSHKeys, error)
getSSHConfig func(typ string, data map[string]string) ([]templates.Output, error)
checkSSHHost func(principal string) (bool, error)
getSSHBastion func(user string, hostname string) (*authority.Bastion, error)
}
// TODO: remove once Authorize is deprecated.
@ -711,6 +712,13 @@ func (m *mockAuthority) CheckSSHHost(principal string) (bool, error) {
return m.ret1.(bool), m.err
}
func (m *mockAuthority) GetSSHBastion(user string, hostname string) (*authority.Bastion, error) {
if m.getSSHBastion != nil {
return m.getSSHBastion(user, hostname)
}
return m.ret1.(*authority.Bastion), m.err
}
func Test_caHandler_Route(t *testing.T) {
type fields struct {
Authority Authority

View file

@ -24,6 +24,7 @@ type SSHAuthority interface {
GetSSHConfig(typ string, data map[string]string) ([]templates.Output, error)
CheckSSHHost(principal string) (bool, error)
GetSSHHosts() ([]string, error)
GetSSHBastion(user string, hostname string) (*authority.Bastion, error)
}
// SSHSignRequest is the request body of an SSH certificate request.
@ -207,6 +208,28 @@ type SSHCheckPrincipalResponse struct {
Exists bool `json:"exists"`
}
// SSHBastionRequest is the request body used to get the bastion for a given
// host.
type SSHBastionRequest struct {
User string `json:"user"`
Hostname string `json:"hostname"`
}
// Validate checks the values of the SSHBastionRequest.
func (r *SSHBastionRequest) Validate() error {
if r.Hostname == "" {
return errors.New("missing or empty hostname")
}
return nil
}
// SSHBastionResponse is the response body used to return the bastion for a
// given host.
type SSHBastionResponse struct {
Hostname string `json:"hostname"`
Bastion *authority.Bastion `json:"bastion,omitempty"`
}
// SSHSign is an HTTP handler that reads an SignSSHRequest with a one-time-token
// (ott) from the body and creates a new SSH certificate with the information in
// the request.
@ -392,3 +415,27 @@ func (h *caHandler) SSHGetHosts(w http.ResponseWriter, r *http.Request) {
Hosts: hosts,
})
}
// SSHBastion provides returns the bastion configured if any.
func (h *caHandler) SSHBastion(w http.ResponseWriter, r *http.Request) {
var body SSHBastionRequest
if err := ReadJSON(r.Body, &body); err != nil {
WriteError(w, BadRequest(errors.Wrap(err, "error reading request body")))
return
}
if err := body.Validate(); err != nil {
WriteError(w, BadRequest(err))
return
}
bastion, err := h.Authority.GetSSHBastion(body.User, body.Hostname)
if err != nil {
WriteError(w, InternalServerError(err))
return
}
JSON(w, &SSHBastionResponse{
Hostname: body.Hostname,
Bastion: bastion,
})
}

View file

@ -537,6 +537,61 @@ func Test_caHandler_SSHCheckHost(t *testing.T) {
}
}
func Test_caHandler_SSHBastion(t *testing.T) {
bastion := &authority.Bastion{
Hostname: "bastion.local",
}
bastionPort := &authority.Bastion{
Hostname: "bastion.local",
Port: "2222",
}
tests := []struct {
name string
bastion *authority.Bastion
bastionErr error
req []byte
body []byte
statusCode int
}{
{"ok", bastion, nil, []byte(`{"hostname":"host.local"}`), []byte(`{"hostname":"host.local","bastion":{"hostname":"bastion.local"}}`), http.StatusOK},
{"ok", bastionPort, nil, []byte(`{"hostname":"host.local","user":"user"}`), []byte(`{"hostname":"host.local","bastion":{"hostname":"bastion.local","port":"2222"}}`), http.StatusOK},
{"empty", nil, nil, []byte(`{"hostname":"host.local"}`), []byte(`{"hostname":"host.local"}`), http.StatusOK},
{"bad json", bastion, nil, []byte(`bad json`), nil, http.StatusBadRequest},
{"bad request", bastion, nil, []byte(`{"hostname": ""}`), nil, http.StatusBadRequest},
{"error", nil, fmt.Errorf("an error"), []byte(`{"hostname":"host.local"}`), nil, http.StatusInternalServerError},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := New(&mockAuthority{
getSSHBastion: func(user, hostname string) (*authority.Bastion, error) {
return tt.bastion, tt.bastionErr
},
}).(*caHandler)
req := httptest.NewRequest("POST", "http://example.com/ssh/bastion", bytes.NewReader(tt.req))
w := httptest.NewRecorder()
h.SSHBastion(logging.NewResponseLogger(w), req)
res := w.Result()
if res.StatusCode != tt.statusCode {
t.Errorf("caHandler.SSHBastion StatusCode = %d, wants %d", res.StatusCode, tt.statusCode)
}
body, err := ioutil.ReadAll(res.Body)
res.Body.Close()
if err != nil {
t.Errorf("caHandler.SSHBastion unexpected error = %v", err)
}
if tt.statusCode < http.StatusBadRequest {
if !bytes.Equal(bytes.TrimSpace(body), tt.body) {
t.Errorf("caHandler.SSHBastion Body = %s, wants %s", body, tt.body)
}
}
})
}
}
func TestSSHPublicKey_MarshalJSON(t *testing.T) {
key, err := ssh.NewPublicKey(sshUserKey.Public())
assert.FatalError(t, err)

View file

@ -39,6 +39,8 @@ type Authority struct {
db db.AuthDB
// Do not re-initialize
initOnce bool
// Custom functions
sshBastionFunc func(user, hostname string) (*Bastion, error)
}
// New creates and initiates a new Authority type.

View file

@ -14,3 +14,11 @@ func WithDatabase(db db.AuthDB) Option {
a.db = db
}
}
// WithSSHBastionFunc defines sets a custom function to get the bastion for a
// given user-host pair.
func WithSSHBastionFunc(fn func(user, host string) (*Bastion, error)) Option {
return func(a *Authority) {
a.sshBastionFunc = fn
}
}

View file

@ -34,8 +34,18 @@ type SSHConfig struct {
HostKey string `json:"hostKey"`
UserKey string `json:"userKey"`
Keys []*SSHPublicKey `json:"keys,omitempty"`
AddUserPrincipal string `json:"addUserPrincipal"`
AddUserCommand string `json:"addUserCommand"`
AddUserPrincipal string `json:"addUserPrincipal,omitempty"`
AddUserCommand string `json:"addUserCommand,omitempty"`
Bastion *Bastion `json:"bastion,omitempty"`
}
// Bastion contains the custom properties used on bastion.
type Bastion struct {
Hostname string `json:"hostname"`
User string `json:"user,omitempty"`
Port string `json:"port,omitempty"`
Command string `json:"cmd,omitempty"`
Flags string `json:"flags,omitempty"`
}
// Validate checks the fields in SSHConfig.
@ -157,6 +167,24 @@ func (a *Authority) GetSSHConfig(typ string, data map[string]string) ([]template
return output, nil
}
// GetSSHBastion returns the bastion configuration, for the given pair user,
// hostname.
func (a *Authority) GetSSHBastion(user string, hostname string) (*Bastion, error) {
if a.sshBastionFunc != nil {
return a.sshBastionFunc(user, hostname)
}
if a.config.SSH != nil {
if a.config.SSH.Bastion != nil && a.config.SSH.Bastion.Hostname != "" {
return a.config.SSH.Bastion, nil
}
return nil, nil
}
return nil, &apiError{
err: errors.New("getSSHBastion: ssh is not configured"),
code: http.StatusNotFound,
}
}
// authorizeSSHSign loads the provisioner from the token, checks that it has not
// been used again and calls the provisioner AuthorizeSSHSign method. Returns a
// list of methods to apply to the signing flow.

View file

@ -10,6 +10,7 @@ import (
"testing"
"time"
"github.com/pkg/errors"
"github.com/smallstep/assert"
"github.com/smallstep/certificates/authority/provisioner"
"github.com/smallstep/certificates/db"
@ -598,3 +599,48 @@ func TestSSHPublicKey_PublicKey(t *testing.T) {
})
}
}
func TestAuthority_GetSSHBastion(t *testing.T) {
bastion := &Bastion{
Hostname: "bastion.local",
Port: "2222",
}
type fields struct {
config *Config
sshBastionFunc func(user, hostname string) (*Bastion, error)
}
type args struct {
user string
hostname string
}
tests := []struct {
name string
fields fields
args args
want *Bastion
wantErr bool
}{
{"config", fields{&Config{SSH: &SSHConfig{Bastion: bastion}}, nil}, args{"user", "host.local"}, bastion, false},
{"nil", fields{&Config{SSH: &SSHConfig{Bastion: nil}}, nil}, args{"user", "host.local"}, nil, false},
{"empty", fields{&Config{SSH: &SSHConfig{Bastion: &Bastion{}}}, nil}, args{"user", "host.local"}, nil, false},
{"func", fields{&Config{}, func(_, _ string) (*Bastion, error) { return bastion, nil }}, args{"user", "host.local"}, bastion, false},
{"func err", fields{&Config{}, func(_, _ string) (*Bastion, error) { return nil, errors.New("foo") }}, args{"user", "host.local"}, nil, true},
{"error", fields{&Config{SSH: nil}, nil}, args{"user", "host.local"}, nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
a := &Authority{
config: tt.fields.config,
sshBastionFunc: tt.fields.sshBastionFunc,
}
got, err := a.GetSSHBastion(tt.args.user, tt.args.hostname)
if (err != nil) != tt.wantErr {
t.Errorf("Authority.GetSSHBastion() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("Authority.GetSSHBastion() = %v, want %v", got, tt.want)
}
})
}
}