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

WIP: feat(ec2): add instance resource #3697

Merged
merged 17 commits into from
Aug 23, 2019
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
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
94 changes: 94 additions & 0 deletions packages/@aws-cdk/aws-ec2/lib/bastion-host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { PolicyStatement } from "@aws-cdk/aws-iam";
import { CfnOutput, Construct } from "@aws-cdk/core";
import { AmazonLinuxGeneration, AmazonLinuxImage, InstanceClass, InstanceSize, InstanceType } from ".";
import { Instance } from "./instance";
import { ISecurityGroup } from "./security-group";
import { IVpc, SubnetType } from "./vpc";

rix0rrr marked this conversation as resolved.
Show resolved Hide resolved
/**
* Properties of the bastion host
rix0rrr marked this conversation as resolved.
Show resolved Hide resolved
*
* @experimental
*/
export interface BastionHostLinuxProps {

/**
* In which AZ to place the instance within the VPC
*
* @default - Random zone.
*/
readonly availabilityZone?: string;

/**
* VPC to launch the instance in.
*/
readonly vpc: IVpc;

/**
* The name of the instance
*
* @default 'BastionHost'
*/
readonly instanceName?: string;

/**
* Use a public subnet instead of a private one.
* Set this to 'true' if you need to connect to this instance via the internet and cannot use SSM.
* You have to allow port 22 manually by using the connections field
*
* @default - false
*/
readonly publicSubnets?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

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

Shouldn't this be a subnetSelection?

And shouldn't public subnets be the default? What use is a Bastion host in a private subnet?

EDIT: Oh I see, it probably has to do with whether you intend to access the bastion directly using SSH or using Session Manager. Could you at least make that clear in the documentation text? Or better yet, maybe it should be a property to select between the two?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think there are three scenarios:

  1. Public access using SSH -> public subnet
  2. Access via SSM -> private subnet
  3. Access using SSH from on-prem -> private subnet via VPN/DC

So public subnets should not be used imho...

Copy link
Contributor

Choose a reason for hiding this comment

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

Re use cases, we use all of these and also have use case where we have had to have a baston host, (jump box) in all 3 zones.

When conducting penetration tests we will place a (bastion) host in each zone (public, private, ..), sometime the only way to test a zone get to jump through bastions in each zone using proxy chains.
So in my imho this construct should be able to live in any zone, just default to public.

Copy link
Contributor

Choose a reason for hiding this comment

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

Also you should be ideally able to place this in an explicit subnet.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, so I still feel we should expose the subnetSelection (and have it default to something sane, and I think PRIVATE is the best sane default we can have). It would immediately benefit from future extensions we would add to the subnetSelection mechanism (for example, selecting individual subnets such as @slipdexic requested). And it should have explicit documentation on there saying you need to select public subnets if you want to access it from the internet.

Would it make sense to have a top-level helper method for a common use case, bastion.allowSshAccessFrom(peer, peer, ...) or something? The only thing it would do is encapsulate port 22, but it might be just a little nicer.

Also, @hoegertn, could you add a section to the README about the 2 Bastion Host use cases (SSM access and SSH access)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good. I will change it accordingly.


/**
* Security Group to assign to this instance
*
* @default - create new security group with no inbound and all outbound traffic allowed
*/
readonly securityGroup?: ISecurityGroup;

/**
* Type of instance to launch
* @default 't3.nano'
*/
readonly instanceType?: InstanceType;

}

/**
* This creates a linux bastion host you can use to connect to other instances or services in your VPC.
* The recommended way to connect to the bastion host is by using AWS Systems Manager Session Manager.
*
* The operating system is Amazon Linux 2 with the latest SSM agent installed
*
* You can also configure this bastion host to allow connections via SSH
*
* @experimental
*/
export class BastionHostLinux extends Instance {
rix0rrr marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry for not seeing this before: I also agree with Elad that this host should probably encapsulate rather than extend Instance.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Can you elaborate on why? I still think that a bastion host is a special instance and should derive from it, so users have all the options they have with an instance but with some defaults regarding the use case. I thought OOP is one of the benefits of CDK.

Copy link
Contributor

Choose a reason for hiding this comment

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

We achieve OOP/polymorphism by implementing interfaces. Interfaces declare "a (this) can do the same thing as a (that)", regardless of the implementation. So if you want a Bastion Host to be-an Instance, then declare it like so:

class BastionHostLinux implements IInstance 

Inheriting from a class brings both the "(this) can do the same as (that)" relationship, but it also couples one implementation to the other, and can have unintended implications for the future extensibility of the classes involved (either a change in the implementation of Instance has an unintended side effect in the implementation of BastionHost, or we now face the limitation that BastionHost can only extend one class).

In some situations implementation sharing is a perfectly valid choice (which would usually be made to make it quicker to implement a certain class since you don't have to type so much), but polymorphism through interface implementation is generally a safer choice in the long term, when it's hard to predict future changes.

In this case, I'd rather be safe than sorry later.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, I will change it and see what methods and fields I want to expose from the instance class.

constructor(scope: Construct, id: string, props: BastionHostLinuxProps) {
super(scope, id, {
vpc: props.vpc,
availabilityZone: props.availabilityZone,
securityGroup: props.securityGroup,
instanceName: props.instanceName || 'BastionHost',
instanceType: props.instanceType || InstanceType.of(InstanceClass.T3, InstanceSize.NANO),
machineImage: new AmazonLinuxImage({ generation: AmazonLinuxGeneration.AMAZON_LINUX_2 }),
vpcSubnets: props.publicSubnets ? { subnetType: SubnetType.PUBLIC } : { subnetType: SubnetType.PRIVATE },
});
this.addToRolePolicy(new PolicyStatement({
actions: [
'ssmmessages:*',
'ssm:UpdateInstanceInformation',
'ec2messages:*'
],
resources: ['*'],
}));
this.addUserData('yum install -y https://s3.amazonaws.com/ec2-downloads-windows/SSMAgent/latest/linux_amd64/amazon-ssm-agent.rpm');

new CfnOutput(this, 'BastionHostId', {
description: 'Instance ID of the bastion host. Use this to connect via SSM Session Manager',
value: this.instanceId,
});
}
}
2 changes: 2 additions & 0 deletions packages/@aws-cdk/aws-ec2/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export * from './bastion-host';
export * from './connections';
export * from './instance-types';
export * from './instance';
export * from './machine-image';
export * from './port';
export * from './security-group';
Expand Down
278 changes: 278 additions & 0 deletions packages/@aws-cdk/aws-ec2/lib/instance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,278 @@
import iam = require('@aws-cdk/aws-iam');

import { Construct, Duration, Fn, Lazy, Resource, Tag } from '@aws-cdk/core';
import { Connections, IConnectable } from './connections';
import { CfnInstance } from './ec2.generated';
import { InstanceType } from './instance-types';
import { IMachineImage, OperatingSystemType } from './machine-image';
import { ISecurityGroup, SecurityGroup } from './security-group';
import { UserData } from './user-data';
import { IVpc, SubnetSelection } from './vpc';

/**
* Name tag constant
*/
const NAME_TAG: string = 'Name';

/**
* Properties of an EC2 Instance
*/
export interface InstanceProps {

/**
* Name of SSH keypair to grant access to instance
*
* @default - No SSH access will be possible.
*/
readonly keyName?: string;

/**
* Where to place the instance within the VPC
*
* @default - Private subnets.
*/
readonly vpcSubnets?: SubnetSelection;

/**
* In which AZ to place the instance within the VPC
*
* @default - Random zone.
*/
readonly availabilityZone?: string;

/**
* Whether the instance could initiate connections to anywhere by default.
* This property is only used when you do not provide a security group.
*
* @default true
*/
readonly allowAllOutbound?: boolean;

/**
* The length of time to wait for the resourceSignalCount
*
* The maximum value is 43200 (12 hours).
*
* @default Duration.minutes(5)
*/
readonly resourceSignalTimeout?: Duration;

/**
* VPC to launch the instance in.
*/
readonly vpc: IVpc;

/**
* Security Group to assign to this instance
*
* @default - create new security group
*/
readonly securityGroup?: ISecurityGroup;

/**
* Type of instance to launch
*/
readonly instanceType: InstanceType;

/**
* AMI to launch
*/
readonly machineImage: IMachineImage;

/**
* Specific UserData to use
*
* The UserData may still be mutated after creation.
*
* @default - A UserData object appropriate for the MachineImage's
* Operating System is created.
*/
readonly userData?: UserData;

/**
* An IAM role to associate with the instance profile assigned to this Auto Scaling Group.
*
* The role must be assumable by the service principal `ec2.amazonaws.com`:
*
* @example
*
* const role = new iam.Role(this, 'MyRole', {
* assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com')
* });
*
* @default - A role will automatically be created, it can be accessed via the `role` property
*/
readonly role?: iam.IRole;

/**
* The name of the instance
*
* @default - CDK generated name
*/
readonly instanceName?: string;

}

/**
* This represents a single EC2 instance
*/
export class Instance extends Resource implements IConnectable {

/**
* The type of OS the instance is running.
*/
public readonly osType: OperatingSystemType;

/**
* Allows specify security group connections for the instance.
*/
public readonly connections: Connections;

/**
* The IAM role assumed by the instance.
*/
public readonly role: iam.IRole;

/**
* UserData for the instance
*/
public readonly userData: UserData;

/**
* the underlying instance resource
* @attribute
*/
public readonly instance: CfnInstance;
/**
* @attribute
*/
public readonly instanceId: string;
/**
* @attribute
*/
public readonly instanceAvailabilityZone: string;
/**
* @attribute
*/
public readonly instancePrivateDnsName: string;
/**
* @attribute
*/
public readonly instancePrivateIp: string;
/**
* @attribute
*/
public readonly instancePublicDnsName: string;
/**
* @attribute
*/
public readonly instancePublicIp: string;

private readonly securityGroup: ISecurityGroup;
private readonly securityGroups: ISecurityGroup[] = [];

constructor(scope: Construct, id: string, props: InstanceProps) {
super(scope, id);

if (props.securityGroup) {
this.securityGroup = props.securityGroup;
} else {
this.securityGroup = new SecurityGroup(this, 'InstanceSecurityGroup', {
vpc: props.vpc,
allowAllOutbound: props.allowAllOutbound !== false
});
}
this.connections = new Connections({ securityGroups: [this.securityGroup] });
this.securityGroups.push(this.securityGroup);
Tag.add(this, NAME_TAG, props.instanceName || this.node.path);

this.role = props.role || new iam.Role(this, 'InstanceRole', {
assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com')
});

const iamProfile = new iam.CfnInstanceProfile(this, 'InstanceProfile', {
roles: [this.role.roleName]
});

// use delayed evaluation
const imageConfig = props.machineImage.getImage(this);
this.userData = props.userData || imageConfig.userData || UserData.forOperatingSystem(imageConfig.osType);
const userDataToken = Lazy.stringValue({ produce: () => Fn.base64(this.userData.render()) });
const securityGroupsToken = Lazy.listValue({ produce: () => this.securityGroups.map(sg => sg.securityGroupId) });

const { subnets } = props.vpc.selectSubnets(props.vpcSubnets);
let subnet;
if (props.availabilityZone) {
const selected = subnets.filter(sn => sn.availabilityZone === props.availabilityZone);
if (selected.length === 1) {
subnet = selected[0];
} else {
throw new Error('When specifying AZ there has to be exactly on subnet of the given type in this az');
}
} else {
subnet = subnets[0];
}

this.instance = new CfnInstance(this, 'Instance', {
imageId: imageConfig.imageId,
keyName: props.keyName,
instanceType: props.instanceType.toString(),
securityGroupIds: securityGroupsToken,
iamInstanceProfile: iamProfile.ref,
userData: userDataToken,
subnetId: subnet.subnetId,
availabilityZone: subnet.availabilityZone,
});
this.instance.node.addDependency(this.role);

this.osType = imageConfig.osType;
this.node.defaultChild = this.instance;

this.instanceId = this.instance.ref;
this.instanceAvailabilityZone = this.instance.attrAvailabilityZone;
this.instancePrivateDnsName = this.instance.attrPrivateDnsName;
this.instancePrivateIp = this.instance.attrPrivateIp;
this.instancePublicDnsName = this.instance.attrPublicDnsName;
this.instancePublicIp = this.instance.attrPublicIp;

this.applyUpdatePolicies(props);
}

/**
* Add the security group to the instance.
*
* @param securityGroup: The security group to add
*/
public addSecurityGroup(securityGroup: ISecurityGroup): void {
this.securityGroups.push(securityGroup);
}

/**
* Add command to the startup script of the instance.
* The command must be in the scripting language supported by the instance's OS (i.e. Linux/Windows).
*/
public addUserData(...commands: string[]) {
this.userData.addCommands(...commands);
}

/**
* Adds a statement to the IAM role assumed by the instance.
*/
public addToRolePolicy(statement: iam.PolicyStatement) {
this.role.addToPolicy(statement);
}

/**
* Apply CloudFormation update policies for the instance
*/
private applyUpdatePolicies(props: InstanceProps) {
if (props.resourceSignalTimeout !== undefined) {
this.instance.cfnOptions.creationPolicy = {
...this.instance.cfnOptions.creationPolicy,
resourceSignal: {
timeout: props.resourceSignalTimeout && props.resourceSignalTimeout.toISOString(),
}
};
}
}
}
2 changes: 1 addition & 1 deletion packages/@aws-cdk/aws-ec2/lib/vpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export interface SubnetSelection {
/**
* If true, return at most one subnet per AZ
*
* @defautl false
* @default false
*/
readonly onePerAz?: boolean;
}
Expand Down
Loading