Skip to content

Commit

Permalink
EC2 Instance Connect Endpoint: add aws metadata to Nodes (#29316) (#2…
Browse files Browse the repository at this point in the history
…9407)

* EC2 Instance Connect Endpoint: add aws metadata to Nodes

This PR adds AWS metadata to the Server resource.
This metadata is required for servers of subkind OpenSSHEphemeralKey.

* improve error messages and docs

* improve error messages and tests

* be less strict on error checking in tests

* remove availability zone because it is not required

* rename subkind to openssh-ec2-ice
  • Loading branch information
marcoandredinis authored Jul 21, 2023
1 parent ba8e3a5 commit 2bcf200
Show file tree
Hide file tree
Showing 6 changed files with 1,823 additions and 1,422 deletions.
8 changes: 8 additions & 0 deletions api/proto/teleport/legacy/types/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -808,6 +808,14 @@ message AWSInfo {
string AccountID = 1 [(gogoproto.jsontag) = "account_id"];
// InstanceID is an EC2 instance ID.
string InstanceID = 2 [(gogoproto.jsontag) = "instance_id"];
// Region is the AWS EC2 Instance Region.
string Region = 3 [(gogoproto.jsontag) = "region,omitempty"];
// VPCID is the AWS VPC ID where the Instance is running.
string VPCID = 4 [(gogoproto.jsontag) = "vpc_id,omitempty"];
// Integration is the integration name that added this Node.
// When connecting to it, it will use this integration to issue AWS API calls in order to set up the connection.
// This includes sending an SSH Key and then opening a tunnel (EC2 Instance Connect Endpoint) so Teleport can connect to it.
string Integration = 5 [(gogoproto.jsontag) = "integration,omitempty"];
}

// CloudMetadata contains info about the cloud instance a server is running
Expand Down
10 changes: 10 additions & 0 deletions api/types/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ const (
// SubKindOpenSSHNode is a registered OpenSSH (agentless) node.
SubKindOpenSSHNode = "openssh"

// SubKindOpenSSHEC2InstanceConnectEndpointNode is a registered OpenSSH (agentless) node that doesn't require trust in Teleport CA.
// For each session an SSH Key is created and uploaded to the target host using a side-channel.
//
// For Amazon EC2 Instances, it uploads the key using:
// https://docs.aws.amazon.com/ec2-instance-connect/latest/APIReference/API_SendSSHPublicKey.html
// This Key is valid for 60 seconds.
//
// It uses the private key created above to SSH into the host.
SubKindOpenSSHEC2InstanceConnectEndpointNode = "openssh-ec2-ice"

// KindAppServer is an application server resource.
KindAppServer = "app_server"

Expand Down
96 changes: 84 additions & 12 deletions api/types/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ type Server interface {
GetCloudMetadata() *CloudMetadata
// SetCloudMetadata sets the server's cloud metadata.
SetCloudMetadata(meta *CloudMetadata)

// IsOpenSSHNode returns whether the connection to this Server must use OpenSSH.
// This returns true for SubKindOpenSSHNode and SubKindOpenSSHEC2InstanceConnectEndpointNode.
IsOpenSSHNode() bool
}

// NewServer creates an instance of Server.
Expand All @@ -119,6 +123,23 @@ func NewServerWithLabels(name, kind string, spec ServerSpecV2, labels map[string
return server, nil
}

// NewNode is a convenience method to create a Server of Kind Node.
func NewNode(name, subKind string, spec ServerSpecV2, labels map[string]string) (Server, error) {
server := &ServerV2{
Kind: KindNode,
SubKind: subKind,
Metadata: Metadata{
Name: name,
Labels: labels,
},
Spec: spec,
}
if err := server.CheckAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
}
return server, nil
}

// GetVersion returns resource version
func (s *ServerV2) GetVersion() string {
return s.Version
Expand Down Expand Up @@ -397,6 +418,62 @@ func (s *ServerV2) setStaticFields() {
s.Version = V2
}

// IsOpenSSHNode returns whether the connection to this Server must use OpenSSH.
// This returns true for SubKindOpenSSHNode and SubKindOpenSSHEC2InstanceConnectEndpointNode.
func (s *ServerV2) IsOpenSSHNode() bool {
return s.SubKind == SubKindOpenSSHNode || s.SubKind == SubKindOpenSSHEC2InstanceConnectEndpointNode
}

// openSSHNodeCheckAndSetDefaults are common validations for OpenSSH nodes.
// They include SubKindOpenSSHNode and SubKindOpenSSHEC2InstanceConnectEndpointNode.
func (s *ServerV2) openSSHNodeCheckAndSetDefaults() error {
if s.Spec.Addr == "" {
return trace.BadParameter(`addr must be set when server SubKind is "openssh"`)
}
if len(s.GetPublicAddrs()) != 0 {
return trace.BadParameter(`publicAddrs must not be set when server SubKind is "openssh"`)
}
if s.Spec.Hostname == "" {
return trace.BadParameter(`hostname must be set when server SubKind is "openssh"`)
}

_, _, err := net.SplitHostPort(s.Spec.Addr)
if err != nil {
return trace.BadParameter("invalid Addr %q: %v", s.Spec.Addr, err)
}
return nil
}

// openSSHEC2InstanceConnectEndpointNodeCheckAndSetDefaults are validations for SubKindOpenSSHEC2InstanceConnectEndpointNode.
func (s *ServerV2) openSSHEC2InstanceConnectEndpointNodeCheckAndSetDefaults() error {
if err := s.openSSHNodeCheckAndSetDefaults(); err != nil {
return trace.Wrap(err)
}

// AWS fields are required for SubKindOpenSSHEC2InstanceConnectEndpointNode.
switch {
case s.Spec.CloudMetadata == nil || s.Spec.CloudMetadata.AWS == nil:
return trace.BadParameter("missing AWS CloudMetadata (required for %q SubKind)", s.SubKind)

case s.Spec.CloudMetadata.AWS.AccountID == "":
return trace.BadParameter("missing AWS Account ID (required for %q SubKind)", s.SubKind)

case s.Spec.CloudMetadata.AWS.Region == "":
return trace.BadParameter("missing AWS Region (required for %q SubKind)", s.SubKind)

case s.Spec.CloudMetadata.AWS.Integration == "":
return trace.BadParameter("missing AWS OIDC Integration (required for %q SubKind)", s.SubKind)

case s.Spec.CloudMetadata.AWS.InstanceID == "":
return trace.BadParameter("missing AWS InstanceID (required for %q SubKind)", s.SubKind)

case s.Spec.CloudMetadata.AWS.VPCID == "":
return trace.BadParameter("missing AWS VPC ID (required for %q SubKind)", s.SubKind)
}

return nil
}

// CheckAndSetDefaults checks and set default values for any missing fields.
func (s *ServerV2) CheckAndSetDefaults() error {
// TODO(awly): default s.Metadata.Expiry if not set (use
Expand All @@ -405,7 +482,7 @@ func (s *ServerV2) CheckAndSetDefaults() error {

// if the server is a registered OpenSSH node, allow the name to be
// randomly generated
if s.SubKind == SubKindOpenSSHNode && s.Metadata.Name == "" {
if s.Metadata.Name == "" && s.IsOpenSSHNode() {
s.Metadata.Name = uuid.New().String()
}

Expand All @@ -424,20 +501,15 @@ func (s *ServerV2) CheckAndSetDefaults() error {
case "", SubKindTeleportNode:
// allow but do nothing
case SubKindOpenSSHNode:
if s.Spec.Addr == "" {
return trace.BadParameter(`Addr must be set when server SubKind is "openssh"`)
}
if len(s.GetPublicAddrs()) != 0 {
return trace.BadParameter(`PublicAddrs must not be set when server SubKind is "openssh"`)
}
if s.Spec.Hostname == "" {
return trace.BadParameter(`Hostname must be set when server SubKind is "openssh"`)
if err := s.openSSHNodeCheckAndSetDefaults(); err != nil {
return trace.Wrap(err)
}

_, _, err := net.SplitHostPort(s.Spec.Addr)
if err != nil {
return trace.BadParameter("invalid Addr %q: %v", s.Spec.Addr, err)
case SubKindOpenSSHEC2InstanceConnectEndpointNode:
if err := s.openSSHEC2InstanceConnectEndpointNodeCheckAndSetDefaults(); err != nil {
return trace.Wrap(err)
}

default:
return trace.BadParameter("invalid SubKind %q", s.SubKind)
}
Expand Down
170 changes: 167 additions & 3 deletions api/types/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"testing"

"github.com/gravitational/trace"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/gravitational/teleport/api/defaults"
Expand Down Expand Up @@ -105,6 +106,35 @@ func TestServerSorter(t *testing.T) {
func TestServerCheckAndSetDefaults(t *testing.T) {
t.Parallel()

makeOpenSSHEC2InstanceConnectEndpointNode := func(fn func(s *ServerV2)) *ServerV2 {
s := &ServerV2{
Kind: KindNode,
SubKind: SubKindOpenSSHEC2InstanceConnectEndpointNode,
Version: V2,
Metadata: Metadata{
Name: "5da56852-2adb-4540-a37c-80790203f6a9",
Namespace: defaults.Namespace,
},
Spec: ServerSpecV2{
Addr: "example:22",
Hostname: "openssh-node",
CloudMetadata: &CloudMetadata{
AWS: &AWSInfo{
AccountID: "123456789012",
InstanceID: "i-123456789012",
Region: "us-east-1",
VPCID: "vpc-abcd",
Integration: "teleportdev",
},
},
},
}
if fn != nil {
fn(s)
}
return s
}

tests := []struct {
name string
server *ServerV2
Expand Down Expand Up @@ -176,6 +206,7 @@ func TestServerCheckAndSetDefaults(t *testing.T) {
},
}
require.Equal(t, expectedServer, s)
require.False(t, s.IsOpenSSHNode(), "IsOpenSSHNode must be false for this node")
},
},
{
Expand Down Expand Up @@ -242,6 +273,7 @@ func TestServerCheckAndSetDefaults(t *testing.T) {
},
}
require.Equal(t, expectedServer, s)
require.True(t, s.IsOpenSSHNode(), "IsOpenSSHNode must be true for this node")
},
},
{
Expand All @@ -258,6 +290,7 @@ func TestServerCheckAndSetDefaults(t *testing.T) {
assertion: func(t *testing.T, s *ServerV2, err error) {
require.NoError(t, err)
require.NotEmpty(t, s.Metadata.Name)
require.True(t, s.IsOpenSSHNode(), "IsOpenSSHNode must be true for this node")
},
},
{
Expand All @@ -275,7 +308,7 @@ func TestServerCheckAndSetDefaults(t *testing.T) {
},
},
assertion: func(t *testing.T, s *ServerV2, err error) {
require.EqualError(t, err, `Addr must be set when server SubKind is "openssh"`)
require.ErrorContains(t, err, "addr must be set")
},
},
{
Expand All @@ -293,7 +326,7 @@ func TestServerCheckAndSetDefaults(t *testing.T) {
},
},
assertion: func(t *testing.T, s *ServerV2, err error) {
require.EqualError(t, err, `Hostname must be set when server SubKind is "openssh"`)
require.ErrorContains(t, err, "hostname must be set")
},
},
{
Expand All @@ -313,7 +346,7 @@ func TestServerCheckAndSetDefaults(t *testing.T) {
},
},
assertion: func(t *testing.T, s *ServerV2, err error) {
require.EqualError(t, err, `PublicAddrs must not be set when server SubKind is "openssh"`)
require.ErrorContains(t, err, "publicAddrs must not be set")
},
},
{
Expand Down Expand Up @@ -354,6 +387,137 @@ func TestServerCheckAndSetDefaults(t *testing.T) {
require.EqualError(t, err, `invalid SubKind "invalid-subkind"`)
},
},
{
name: "OpenSSHEC2InstanceConnectEndpoint node without cloud metadata",
server: makeOpenSSHEC2InstanceConnectEndpointNode(func(s *ServerV2) {
s.Spec.CloudMetadata = nil
}),
assertion: func(t *testing.T, s *ServerV2, err error) {
require.ErrorContains(t, err, "missing AWS CloudMetadata")
},
},
{
name: "OpenSSHEC2InstanceConnectEndpoint node with cloud metadata but missing aws info",
server: makeOpenSSHEC2InstanceConnectEndpointNode(func(s *ServerV2) {
s.Spec.CloudMetadata.AWS = nil
}),
assertion: func(t *testing.T, s *ServerV2, err error) {
require.ErrorContains(t, err, "missing AWS CloudMetadata")
},
},
{
name: "OpenSSHEC2InstanceConnectEndpoint node with aws cloud metadata but missing accountid",
server: makeOpenSSHEC2InstanceConnectEndpointNode(func(s *ServerV2) {
s.Spec.CloudMetadata.AWS.AccountID = ""
}),
assertion: func(t *testing.T, s *ServerV2, err error) {
require.ErrorContains(t, err, "missing AWS Account ID")
},
},
{
name: "OpenSSHEC2InstanceConnectEndpoint node with aws cloud metadata but missing instanceid",
server: makeOpenSSHEC2InstanceConnectEndpointNode(func(s *ServerV2) {
s.Spec.CloudMetadata.AWS.InstanceID = ""
}),
assertion: func(t *testing.T, s *ServerV2, err error) {
require.ErrorContains(t, err, "missing AWS InstanceID")
},
},
{
name: "OpenSSHEC2InstanceConnectEndpoint node with aws cloud metadata but missing region",
server: makeOpenSSHEC2InstanceConnectEndpointNode(func(s *ServerV2) {
s.Spec.CloudMetadata.AWS.Region = ""
}),
assertion: func(t *testing.T, s *ServerV2, err error) {
require.ErrorContains(t, err, "missing AWS Region")
},
},
{
name: "OpenSSHEC2InstanceConnectEndpoint node with aws cloud metadata but missing vpc id",
server: &ServerV2{
Kind: KindNode,
SubKind: SubKindOpenSSHEC2InstanceConnectEndpointNode,
Version: V2,
Metadata: Metadata{
Name: "5da56852-2adb-4540-a37c-80790203f6a9",
Namespace: defaults.Namespace,
},
Spec: ServerSpecV2{
Addr: "example:22",
Hostname: "openssh-node",
CloudMetadata: &CloudMetadata{
AWS: &AWSInfo{
AccountID: "123456789012",
InstanceID: "i-123456789012",
Region: "us-east-1",
Integration: "teleportdev",
},
},
},
},
assertion: func(t *testing.T, s *ServerV2, err error) {
require.ErrorContains(t, err, "missing AWS VPC ID")
},
},
{
name: "OpenSSHEC2InstanceConnectEndpoint node with aws cloud metadata but missing integration",
server: &ServerV2{
Kind: KindNode,
SubKind: SubKindOpenSSHEC2InstanceConnectEndpointNode,
Version: V2,
Metadata: Metadata{
Name: "5da56852-2adb-4540-a37c-80790203f6a9",
Namespace: defaults.Namespace,
},
Spec: ServerSpecV2{
Addr: "example:22",
Hostname: "openssh-node",
CloudMetadata: &CloudMetadata{
AWS: &AWSInfo{
AccountID: "123456789012",
InstanceID: "i-123456789012",
Region: "us-east-1",
VPCID: "vpc-abcd",
},
},
},
},
assertion: func(t *testing.T, s *ServerV2, err error) {
require.ErrorContains(t, err, "missing AWS OIDC Integration")
},
},
{
name: "valid OpenSSHEC2InstanceConnectEndpoint node",
server: makeOpenSSHEC2InstanceConnectEndpointNode(nil),
assertion: func(t *testing.T, s *ServerV2, err error) {
require.NoError(t, err)
expectedServer := &ServerV2{
Kind: KindNode,
SubKind: SubKindOpenSSHEC2InstanceConnectEndpointNode,
Version: V2,
Metadata: Metadata{
Name: "5da56852-2adb-4540-a37c-80790203f6a9",
Namespace: defaults.Namespace,
},
Spec: ServerSpecV2{
Addr: "example:22",
Hostname: "openssh-node",
CloudMetadata: &CloudMetadata{
AWS: &AWSInfo{
AccountID: "123456789012",
InstanceID: "i-123456789012",
Region: "us-east-1",
VPCID: "vpc-abcd",
Integration: "teleportdev",
},
},
},
}
assert.Equal(t, expectedServer, s)

require.True(t, s.IsOpenSSHNode(), "IsOpenSSHNode must be true for this node")
},
},
}

for _, tt := range tests {
Expand Down
Loading

0 comments on commit 2bcf200

Please sign in to comment.