Skip to content

Instantly share code, notes, and snippets.

@skorfmann
Last active November 27, 2023 00:39
Show Gist options
  • Save skorfmann/6941326b2dd75f52cb67e1853c5f8601 to your computer and use it in GitHub Desktop.
Save skorfmann/6941326b2dd75f52cb67e1853c5f8601 to your computer and use it in GitHub Desktop.
Private Api Gateway in CDK.

Private API Gateway with the AWS CDK

  • Lambda
  • Private Api Gateway
  • VPC Endpoint

NB: In order to access the Api Gateway through the public DNS of the VPC endpoint, a curl request has to have the api id as header. See also here

curl -i -H "x-apigw-api-id: <api-id>" https://vpce-<vpce-id>.execute-api.<region>.vpce.amazonaws.com/
{
"name": "private-api",
"version": "0.1.0",
"bin": {
"private-api": "bin/private-api.js"
},
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"test": "jest",
"cdk": "cdk"
},
"devDependencies": {
"@aws-cdk/assert": "^1.21.1",
"@types/jest": "^24.0.22",
"@types/node": "10.17.5",
"jest": "^24.9.0",
"ts-jest": "^24.1.0",
"aws-cdk": "^1.21.1",
"ts-node": "^8.1.0",
"typescript": "~3.7.2"
},
"dependencies": {
"@aws-cdk/aws-apigateway": "^1.21.1",
"@aws-cdk/aws-ec2": "^1.21.1",
"@aws-cdk/aws-iam": "^1.21.1",
"@aws-cdk/aws-lambda": "^1.21.1",
"@aws-cdk/core": "^1.21.1",
"source-map-support": "^0.5.16"
}
}
import * as cdk from '@aws-cdk/core';
import * as apigateway from '@aws-cdk/aws-apigateway'
import { InterfaceVpcEndpoint, Vpc, Subnet, SecurityGroup, Peer, Port } from '@aws-cdk/aws-ec2'
import * as lambda from '@aws-cdk/aws-lambda'
import * as iam from '@aws-cdk/aws-iam'
import path = require('path');
export class PrivateApiGatewayStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const vpc = Vpc.fromLookup(this, 'PrimaryVPC', {
vpcName: '<vpcid>'
})
const a = Subnet.fromSubnetAttributes(this, 'ASubnet', {
availabilityZone: 'eu-central-1a',
subnetId: 'subnet-<id>'
})
const b = Subnet.fromSubnetAttributes(this, 'BSubnet', {
availabilityZone: 'eu-central-1b',
subnetId: 'subnet-<id>'
})
const sg = new SecurityGroup(this, 'SecurityGroup', {
vpc,
allowAllOutbound: true,
securityGroupName: 'VpcEndpoint'
});
sg.addIngressRule(Peer.ipv4("<CIDR>"), Port.tcp(443))
const vpcEndpoint = new InterfaceVpcEndpoint(this, 'ApiVpcEndpoint', {
vpc,
service: {
name: 'com.amazonaws.eu-central-1.execute-api',
port: 443
},
subnets: {
subnets: [a, b]
},
privateDnsEnabled: true,
securityGroups: [sg]
})
const fn = new lambda.Function(this, 'PrivateLambda', {
runtime: lambda.Runtime.NODEJS_10_X,
handler: 'index.handler',
code: lambda.Code.fromAsset(path.join(__dirname, 'lambda')),
});
new apigateway.LambdaRestApi(this, 'PrivateLambdaRestApi', {
endpointTypes: [apigateway.EndpointType.PRIVATE],
handler: fn,
policy: new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
principals: [new iam.AnyPrincipal],
actions: ['execute-api:Invoke'],
resources: ['execute-api:/*'],
effect: iam.Effect.DENY,
conditions: {
StringNotEquals: {
"aws:SourceVpce": vpcEndpoint.vpcEndpointId
}
}
}),
new iam.PolicyStatement({
principals: [new iam.AnyPrincipal],
actions: ['execute-api:Invoke'],
resources: ['execute-api:/*'],
effect: iam.Effect.ALLOW
})
]
})
})
}
}
@YarGnawh
Copy link

YarGnawh commented Jun 5, 2022

Can you also show how to attach a custom domain (in a private hosted zone) and SSL certificate to the API gateway? Trying to follow the steps here: https://github.com/aws-samples/serverless-samples/tree/main/apigw-private-custom-domain-name, but can't seem to get it to work.

@akshay-nm
Copy link

Correct me if I am wrong but,
@YarGnawh if it's a private API GW, you do not need a custom domain on it. It's sole purpose is to only be accessible from other services in your stack (not by other people).

@rufinoNL
Copy link

@akshay-nm I did read that you can add a custom domain on a private hosted zone...

"If you want, you can set your own DNS name to the endpoint with Amazon Route53 private hosted zones when you enable “Enable DNS name” option. With this option enabled, any request to Lambda from your public subnet does not go through the Internet Gateway. All requests to Lambda go through the VPC endpoints."
Source: https://aws.amazon.com/blogs/aws/new-use-aws-privatelink-to-access-aws-lambda-over-private-aws-network/

@arash-cid
Copy link

@YarGnawh, you cannot.

First, you need to introduce another hop( an NLB or ALB) in front of the flow and then put DNS records on that one (ridicolous)
then this: https://repost.aws/knowledge-center/invoke-private-api-gateway

@YarGnawh
Copy link

@arash-cid Thanks. I did eventually get it to work with the "extra" hop (NLB). Here's a snippet of my setup, hopefully it's be helpful to someone.

    // get existing certificate ARN
    const certificate = acm.Certificate.fromCertificateArn(this, 'Certificate', `${process.env.API_SSL_CERTIFICATE_ARN}`);

    const networkLoadBalancer = new elbv2.NetworkLoadBalancer(this, 'PrivateApiNetworkLoadBalancer', {
      vpc: vpc,
      internetFacing: false,
      vpcSubnets: vpc.selectSubnets({ subnetType: SubnetType.PRIVATE_WITH_NAT }),
    });

    const networkLoadBalancerListener = networkLoadBalancer.addListener('PrivateApiNetworkLoadBalancerListener', {
      port: 443,
      protocol: elbv2.Protocol.TLS,
      certificates: [elbv2.ListenerCertificate.fromCertificateManager(certificate)],
    });

    const ip1 = eni.getResponseField('NetworkInterfaces.0.PrivateIpAddress');
    const ip2 = eni.getResponseField('NetworkInterfaces.1.PrivateIpAddress');

    networkLoadBalancerListener.addTargets('Target', {
      port: 443,
      targets: [new albTargets.IpTarget(ip1), new albTargets.IpTarget(ip2)],
    });

    new CfnOutput(this, 'PrivateApiNetworkLoadBalancerArn', { value: networkLoadBalancer.loadBalancerArn });
        const { PRIVATE_HOSTED_ZONE_NAME = '', PRIVATE_HOSTED_ZONE_ID = '', API_HOST_RECORD = '', VPCEP_SG } = process.env;
        const FULL_DNS_RECORD_NAME = `${API_HOST_RECORD}.${PRIVATE_HOSTED_ZONE_NAME}`;

        const privateHostedZone = route53.PrivateHostedZone.fromHostedZoneAttributes(this, 'PrivateHostedZone', {
            zoneName: PRIVATE_HOSTED_ZONE_NAME,
            hostedZoneId: PRIVATE_HOSTED_ZONE_ID,
        });

        // get existing certificate ARN
        const certificate = acm.Certificate.fromCertificateArn(this, 'Certificate', `${process.env.CERTIFICATE_ARN}`);

        const restApiVpcEndpoint = ec2.InterfaceVpcEndpoint.fromInterfaceVpcEndpointAttributes(this, 'RestApiVpcEndpoint', {
            port: 443,
            vpcEndpointId: `${process.env.VPC_ENDPOINT_ID}`,
            securityGroups: [ec2.SecurityGroup.fromSecurityGroupId(this, 'VpcEndpointSecurityGroup', VPCEP_SG)],
        });

        // api gateway for main app
        const api = new apigateway.LambdaRestApi(this, 'ApiGateway', {
            handler: apiLambda,
            proxy: false,
            binaryMediaTypes: ['text/html', 'application/*'],
            endpointConfiguration: {
                types: [apigateway.EndpointType.PRIVATE],
                vpcEndpoints: [restApiVpcEndpoint],
            },
            domainName: {
                domainName: FULL_DNS_RECORD_NAME,
                certificate: certificate,
            },
            policy: new iam.PolicyDocument({
                statements: [
                    new iam.PolicyStatement({
                        principals: [new iam.AnyPrincipal()],
                        actions: ['execute-api:Invoke'],
                        resources: ['execute-api:/*'],
                        effect: iam.Effect.DENY,
                        conditions: {
                            StringNotEquals: {
                                'aws:SourceVpce': restApiVpcEndpoint.vpcEndpointId,
                            },
                        },
                    }),
                    new iam.PolicyStatement({
                        principals: [new iam.AnyPrincipal()],
                        actions: ['execute-api:Invoke'],
                        resources: ['execute-api:/*'],
                        effect: iam.Effect.ALLOW,
                    }),
                ],
            }),
            deployOptions: {
                dataTraceEnabled: true,
                tracingEnabled: true,
            },
        });

        const networkLoadBalancer = elbv2.NetworkLoadBalancer.fromLookup(this, 'NetworkLoadBalancer', {
            loadBalancerArn: `${process.env.PRIVATE_API_LOAD_BALANCER}`,
        });

        new route53.ARecord(this, 'CustomDomainAliasRecord', {
            zone: privateHostedZone,
            recordName: API_HOST_RECORD,
            target: route53.RecordTarget.fromAlias(new targets.LoadBalancerTarget(networkLoadBalancer)),
        });

        const getWidgetsIntegration = new apigateway.LambdaIntegration(apiLambda, {
            ...
        });

        api.root.addMethod('ANY', getWidgetsIntegration);
        api.root.addResource('{proxy+}').addMethod('ANY', getWidgetsIntegration);

        // add outputs
        new CfnOutput(this, 'ApiUrl', { value: `https://${FULL_DNS_RECORD_NAME}/` });


@arash-cid
Copy link

Yep, I got it working too last week. To get the IP dynamically, you can use this getEniIpsByEniIds function like this:

// inside the construct 
// ...
        const vpcEndpoint = new InterfaceVpcEndpoint(this, 'ApiVpcEndpoint', {
          vpc,
          service: {
            name: 'com.amazonaws.ap-southeast-2.execute-api',
            port: 443,
          },
          subnets: {
            subnets: [
              vpcEndpointSubnetA,
              vpcEndpointSubnetB,
              vpcEndpointSubnetC,
            ],
          },
          privateDnsEnabled: false,
          open: true,
        });
        const vpcEndpointIps = getEniIpsByEniIds(
          this,
          vpcEndpoint.vpcEndpointNetworkInterfaceIds,
        );
        const vpcEndpointTargets = _.map(
          vpcEndpointIps,
          (ip) => new IpTarget(ip, 443),
        );

        const nlb = new NetworkLoadBalancer(this, 'Nlb', {
          loadBalancerName: `${apiGwName}-prv`,
          vpc,
          vpcSubnets: {
            subnets: [
              vpcEndpointSubnetA,
              vpcEndpointSubnetB,
              vpcEndpointSubnetC,
            ],
          },
          crossZoneEnabled: true,
          internetFacing: false,
        });
        const nlbListener = nlb.addListener(`https`, {
          port: 443,
          certificates: [certificate],
        });
        const nlbTargetGroup = nlbListener.addTargets('IpTargets', {
          targets: vpcEndpointTargets,
          port: 443,
          deregistrationDelay: Duration.seconds(30),
        });
//...

// outside construct 
export function getEniIpsByEniIds(
  scope: Construct,
  eniIds: string[],
): string[] {
  const outputPaths = eniIds.map(
    (_, index) => `NetworkInterfaces.${index}.PrivateIpAddress`,
  );
  const response = new AwsCustomResource(scope, `describe-enis`, {
    onUpdate: {
      service: 'EC2',
      action: 'describeNetworkInterfaces',
      outputPaths,
      parameters: { NetworkInterfaceIds: eniIds },
      physicalResourceId: PhysicalResourceId.of(Math.random().toString()),
    },
    policy: {
      statements: [
        new PolicyStatement({
          actions: ['ec2:DescribeNetworkInterfaces'],
          resources: ['*'],
        }),
      ],
    },
  });
  return outputPaths.map((outputPath) => response.getResponseField(outputPath));
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment