Skip to content

Commit

Permalink
feat(route53): Vpc endpoint service private dns (#10780)
Browse files Browse the repository at this point in the history
https://aws.amazon.com/premiumsupport/knowledge-center/vpc-private-dns-name-endpoint-service/
https://docs.aws.amazon.com/vpc/latest/userguide/verify-domains.html

AWS added the ability to specify a custom DNS name for an endpoint service earlier this year. It makes it so your clients don't have to create aliases for an InterfaceVpcEndpoint when they connect to your service. This reduces undifferentiated lifting done by clients. This PR creates a construct that will set up the custom DNS.

```ts
stack = new Stack();
vpc = new Vpc(stack, 'VPC');
nlb = new NetworkLoadBalancer(stack, 'NLB', {
  vpc,
});
vpces = new VpcEndpointService(stack, 'VPCES', {
  vpcEndpointServiceLoadBalancers: [nlb],
});
// You must use a public hosted zone so domain ownership can be verified
zone = new PublicHostedZone(stack, 'PHZ', {
  zoneName: 'aws-cdk.dev',
});
new VpcEndpointServiceDomainName(stack, 'EndpointDomain', {
  endpointService: vpces,
  domainName: 'my-stuff.aws-cdk.dev',
  publicZone: zone,
});
```

Original design ticket: #10580

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
flemjame-at-amazon committed Dec 17, 2020
1 parent d10ea63 commit 8f6f9a8
Show file tree
Hide file tree
Showing 9 changed files with 1,614 additions and 1 deletion.
17 changes: 17 additions & 0 deletions packages/@aws-cdk/aws-ec2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,23 @@ new VpcEndpointService(this, 'EndpointService', {
});
```

Endpoint services support private DNS, which makes it easier for clients to connect to your service by automatically setting up DNS in their VPC.
You can enable private DNS on an endpoint service like so:

```ts
import { VpcEndpointServiceDomainName } from '@aws-cdk/aws-route53';

new VpcEndpointServiceDomainName(stack, 'EndpointDomain', {
endpointService: vpces,
domainName: 'my-stuff.aws-cdk.dev',
publicHostedZone: zone,
});
```

Note: The domain name must be owned (registered through Route53) by the account the endpoint service is in, or delegated to the account.
The VpcEndpointServiceDomainName will handle the AWS side of domain verification, the process for which can be found
[here](https://docs.aws.amazon.com/vpc/latest/userguide/endpoint-services-dns-validation.html)

## Instances

You can use the `Instance` class to start up a single EC2 instance. For production setups, we recommend
Expand Down
8 changes: 8 additions & 0 deletions packages/@aws-cdk/aws-ec2/lib/vpc-endpoint-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ export interface IVpcEndpointService extends IResource {
* @attribute
*/
readonly vpcEndpointServiceName: string;

/**
* The id of the VPC Endpoint Service that clients use to connect to,
* like vpce-svc-xxxxxxxxxxxxxxxx
*
* @attribute
*/
readonly vpcEndpointServiceId: string;
}

/**
Expand Down
48 changes: 48 additions & 0 deletions packages/@aws-cdk/aws-route53/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,51 @@ const zone = HostedZone.fromHostedZoneId(this, 'MyZone', {
hostedZoneId: 'ZOJJZC49E0EPZ',
});
```

## VPC Endpoint Service Private DNS

When you create a VPC endpoint service, AWS generates endpoint-specific DNS hostnames that consumers use to communicate with the service.
For example, vpce-1234-abcdev-us-east-1.vpce-svc-123345.us-east-1.vpce.amazonaws.com.
By default, your consumers access the service with that DNS name.
This can cause problems with HTTPS traffic because the DNS will not match the backend certificate:

```console
curl: (60) SSL: no alternative certificate subject name matches target host name 'vpce-abcdefghijklmnopq-rstuvwx.vpce-svc-abcdefghijklmnopq.us-east-1.vpce.amazonaws.com'
```

Effectively, the endpoint appears untrustworthy. To mitigate this, clients have to create an alias for this DNS name in Route53.

Private DNS for an endpoint service lets you configure a private DNS name so consumers can
access the service using an existing DNS name without creating this Route53 DNS alias
This DNS name can also be guaranteed to match up with the backend certificate.

Before consumers can use the private DNS name, you must verify that you have control of the domain/subdomain.

Assuming your account has ownership of the particlar domain/subdomain,
this construct sets up the private DNS configuration on the endpoint service,
creates all the necessary Route53 entries, and verifies domain ownership.

```ts
import { Stack } from '@aws-cdk/core';
import { Vpc, VpcEndpointService } from '@aws-cdk/aws-ec2';
import { NetworkLoadBalancer } from '@aws-cdk/aws-elasticloadbalancingv2';
import { PublicHostedZone } from '@aws-cdk/aws-route53';

stack = new Stack();
vpc = new Vpc(stack, 'VPC');
nlb = new NetworkLoadBalancer(stack, 'NLB', {
vpc,
});
vpces = new VpcEndpointService(stack, 'VPCES', {
vpcEndpointServiceLoadBalancers: [nlb],
});
// You must use a public hosted zone so domain ownership can be verified
zone = new PublicHostedZone(stack, 'PHZ', {
zoneName: 'aws-cdk.dev',
});
new VpcEndpointServiceDomainName(stack, 'EndpointDomain', {
endpointService: vpces,
domainName: 'my-stuff.aws-cdk.dev',
publicHostedZone: zone,
});
```
3 changes: 2 additions & 1 deletion packages/@aws-cdk/aws-route53/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
export * from './alias-record-target';
export * from './hosted-zone';
export * from './hosted-zone-provider';
export * from './hosted-zone-ref';
export * from './record-set';
export * from './alias-record-target';
export * from './vpc-endpoint-service-domain-name';

// AWS::Route53 CloudFormation Resources:
export * from './route53.generated';
230 changes: 230 additions & 0 deletions packages/@aws-cdk/aws-route53/lib/vpc-endpoint-service-domain-name.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import * as crypto from 'crypto';
import { IVpcEndpointService } from '@aws-cdk/aws-ec2';
import { Fn, Names, Stack } from '@aws-cdk/core';
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from '@aws-cdk/custom-resources';
import { Construct } from 'constructs';
import { IPublicHostedZone, TxtRecord } from '../lib';

// v2 - keep this import as a separate section to reduce merge conflict when forward merging with the v2 branch.
// eslint-disable-next-line
import { Construct as CoreConstruct } from '@aws-cdk/core';

/**
* Properties to configure a VPC Endpoint Service domain name
*/
export interface VpcEndpointServiceDomainNameProps {

/**
* The VPC Endpoint Service to configure Private DNS for
*/
readonly endpointService: IVpcEndpointService;

/**
* The domain name to use.
*
* This domain name must be owned by this account (registered through Route53),
* or delegated to this account. Domain ownership will be verified by AWS before
* private DNS can be used.
* @see https://docs.aws.amazon.com/vpc/latest/userguide/endpoint-services-dns-validation.html
*/
readonly domainName: string;

/**
* The public hosted zone to use for the domain.
*/
readonly publicHostedZone: IPublicHostedZone;
}

/**
* A Private DNS configuration for a VPC endpoint service.
*/
export class VpcEndpointServiceDomainName extends CoreConstruct {

// Track all domain names created, so someone doesn't accidentally associate two domains with a single service
private static readonly endpointServices: IVpcEndpointService[] = [];

// Track all domain names created, so someone doesn't accidentally associate two domains with a single service
private static readonly endpointServicesMap: { [endpointService: string]: string} = {};

// The way this class works is by using three custom resources and a TxtRecord in conjunction
// The first custom resource tells the VPC endpoint service to use the given DNS name
// The VPC endpoint service will then say:
// "ok, create a TXT record using these two values to prove you own the domain"
// The second custom resource retrieves these two values from the service
// The TxtRecord is created from these two values
// The third custom resource tells the VPC Endpoint Service to verify the domain ownership
constructor(scope: Construct, id: string, props: VpcEndpointServiceDomainNameProps) {
super(scope, id);

const serviceUniqueId = Names.nodeUniqueId(props.endpointService.node);
const serviceId = props.endpointService.vpcEndpointServiceId;
const privateDnsName = props.domainName;

// Make sure a user doesn't accidentally add multiple domains
this.validateProps(props);

VpcEndpointServiceDomainName.endpointServicesMap[serviceUniqueId] = privateDnsName;
VpcEndpointServiceDomainName.endpointServices.push(props.endpointService);

// Enable Private DNS on the endpoint service and retrieve the AWS-generated configuration
const privateDnsConfiguration = this.getPrivateDnsConfiguration(serviceUniqueId, serviceId, privateDnsName);

// Tell AWS to verify that this account owns the domain attached to the service
this.verifyPrivateDnsConfiguration(privateDnsConfiguration, props.publicHostedZone);

// Finally, don't do any of the above before the endpoint service is created
this.node.addDependency(props.endpointService);
}

private validateProps(props: VpcEndpointServiceDomainNameProps): void {
const serviceUniqueId = Names.nodeUniqueId(props.endpointService.node);
if (serviceUniqueId in VpcEndpointServiceDomainName.endpointServicesMap) {
const endpoint = VpcEndpointServiceDomainName.endpointServicesMap[serviceUniqueId];
throw new Error(
`Cannot create a VpcEndpointServiceDomainName for service ${serviceUniqueId}, another VpcEndpointServiceDomainName (${endpoint}) is already associated with it`);
}
}

/**
* Sets up Custom Resources to make AWS calls to set up Private DNS on an endpoint service,
* returning the values to use in a TxtRecord, which AWS uses to verify domain ownership.
*/
private getPrivateDnsConfiguration(serviceUniqueId: string, serviceId: string, privateDnsName: string): PrivateDnsConfiguration {

// The custom resource which tells AWS to enable Private DNS on the given service, using the given domain name
// AWS will generate a name/value pair for use in a TxtRecord, which is used to verify domain ownership.
const enablePrivateDnsAction = {
service: 'EC2',
action: 'modifyVpcEndpointServiceConfiguration',
parameters: {
ServiceId: serviceId,
PrivateDnsName: privateDnsName,
},
physicalResourceId: PhysicalResourceId.of(serviceUniqueId),
};
const removePrivateDnsAction = {
service: 'EC2',
action: 'modifyVpcEndpointServiceConfiguration',
parameters: {
ServiceId: serviceId,
RemovePrivateDnsName: true,
},
};
const enable = new AwsCustomResource(this, 'EnableDns', {
onCreate: enablePrivateDnsAction,
onUpdate: enablePrivateDnsAction,
onDelete: removePrivateDnsAction,
policy: AwsCustomResourcePolicy.fromSdkCalls({
resources: [
Fn.join(':', [
'arn',
Stack.of(this).partition,
'ec2',
Stack.of(this).region,
Stack.of(this).account,
Fn.join('/', [
'vpc-endpoint-service',
serviceId,
]),
]),
],
}),
});

// Look up the name/value pair if the domain changes, or the service changes,
// which would cause the values to be different. If the unique ID changes,
// the resource may be entirely recreated, so we will need to look it up again.
const lookup = hashcode(Names.uniqueId(this) + serviceUniqueId + privateDnsName);

// Create the custom resource to look up the name/value pair generated by AWS
// after the previous API call
const retriveNameValuePairAction = {
service: 'EC2',
action: 'describeVpcEndpointServiceConfigurations',
parameters: {
ServiceIds: [serviceId],
},
physicalResourceId: PhysicalResourceId.of(lookup),
};
const getNames = new AwsCustomResource(this, 'GetNames', {
onCreate: retriveNameValuePairAction,
onUpdate: retriveNameValuePairAction,
// describeVpcEndpointServiceConfigurations can't take an ARN for granular permissions
policy: AwsCustomResourcePolicy.fromSdkCalls({
resources: AwsCustomResourcePolicy.ANY_RESOURCE,
}),
});

// We only want to call and get the name/value pair after we've told AWS to enable Private DNS
// If we call before then, we'll get an empty pair of values.
getNames.node.addDependency(enable);

// Get the references to the name/value pair associated with the endpoint service
const name = getNames.getResponseField('ServiceConfigurations.0.PrivateDnsNameConfiguration.Name');
const value = getNames.getResponseField('ServiceConfigurations.0.PrivateDnsNameConfiguration.Value');

return { name, value, serviceId };
}

/**
* Creates a Route53 entry and a Custom Resource which explicitly tells AWS to verify ownership
* of the domain name attached to an endpoint service.
*/
private verifyPrivateDnsConfiguration(config: PrivateDnsConfiguration, publicHostedZone: IPublicHostedZone) {
// Create the TXT record in the provided hosted zone
const verificationRecord = new TxtRecord(this, 'DnsVerificationRecord', {
recordName: config.name,
values: [config.value],
zone: publicHostedZone,
});

// Tell the endpoint service to verify the domain ownership
const startVerificationAction = {
service: 'EC2',
action: 'startVpcEndpointServicePrivateDnsVerification',
parameters: {
ServiceId: config.serviceId,
},
physicalResourceId: PhysicalResourceId.of(Fn.join(':', [config.name, config.value])),
};
const startVerification = new AwsCustomResource(this, 'StartVerification', {
onCreate: startVerificationAction,
onUpdate: startVerificationAction,
policy: AwsCustomResourcePolicy.fromSdkCalls({
resources: [
Fn.join(':', [
'arn',
Stack.of(this).partition,
'ec2',
Stack.of(this).region,
Stack.of(this).account,
Fn.join('/', [
'vpc-endpoint-service',
config.serviceId,
]),
]),
],
}),
});
// Only verify after the record has been created
startVerification.node.addDependency(verificationRecord);
}
}

/**
* Represent the name/value pair associated with a Private DNS enabled endpoint service
*/
interface PrivateDnsConfiguration {
readonly name: string;
readonly value: string;
readonly serviceId: string;
}

/**
* Hash a string
*/
function hashcode(s: string): string {
const hash = crypto.createHash('md5');
hash.update(s);
return hash.digest('hex');
};
3 changes: 3 additions & 0 deletions packages/@aws-cdk/aws-route53/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"cdk-build-tools": "0.0.0",
"cdk-integ-tools": "0.0.0",
"cfn2ts": "0.0.0",
"jest": "^26.6.0",
"nodeunit": "^0.11.3",
"pkglint": "0.0.0"
},
Expand All @@ -86,6 +87,7 @@
"@aws-cdk/aws-logs": "0.0.0",
"@aws-cdk/core": "0.0.0",
"@aws-cdk/cloud-assembly-schema": "0.0.0",
"@aws-cdk/custom-resources": "0.0.0",
"constructs": "^3.2.0"
},
"homepage": "https://github.com/aws/aws-cdk",
Expand All @@ -94,6 +96,7 @@
"@aws-cdk/aws-logs": "0.0.0",
"@aws-cdk/core": "0.0.0",
"@aws-cdk/cloud-assembly-schema": "0.0.0",
"@aws-cdk/custom-resources": "0.0.0",
"constructs": "^3.2.0"
},
"engines": {
Expand Down
Loading

0 comments on commit 8f6f9a8

Please sign in to comment.