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

@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