Blog/Developer/Using API Gateway to deliver egress IPs with AWS and Terraform

Airkit IP-Ranges

Here at Airkit, we provide an HTTP(s) endpoint (ip-ranges.airkit.com) that produces a JSON list of all possible egress IP addresses that traffic could use when establishing a connection between Airkit and any other external service.

Hopefully one day the internet as a whole can move past deny/allow lists to full zero-trust security models. Until that day comes, we built this to help provide both granularity and dynamism to our customers, as they use this information to help in managing their own firewall deny/allow rules. As a self-service endpoint, our customers can access this always-up-to-date information whenever they want.With the continued growth of our services, the incorporation of new geographical locations, and use of modern high-availability upgrade procedures, the chances of this list remaining unchanged becomes smaller.  Where in years past this information would have been kept up to date by hand, modern firewalls are often capable of directly using this information to implement changes.

deny/allow lists

Technically speaking, the API we created is derived from a hello-world API, that we then wrapped with a few extra bonus features (caching, http-redirect, etc). Below we will step through the design and implementation, with the understanding that the code snippets provided are primarily the deviations from an otherwise-standard hello-world example.

Top-Level Design

To do this in AWS, we built an API that has a CDN in front and is backed by a Lambda function. In diagram form, the service we created looks like:

  1. Public internet traffic hits the DNS A record for ip-ranges.airkit.com (Route53)
  2. The A Record points to the CloudFront distribution (ex: a1b2c3d4e5f6g7.cloudfront.net)
  3. The CloudFront distribution is responsible for:
    1. Redirecting HTTP traffic to HTTPS
    2. Edge caching
    3. Forwarding traffic to the API Gateway endpoint
      (ex: https://z1y2x3w4v5.execute-api.us-west-2.amazonaws.com/ip_ranges)
  4. The API Gateway then executes the egress-ip-ranges-api Lambda Function
  5. The Lambda function does the heavy-lifting:
    1. Uses a limited IAM Role specifically for describing our NAT Gateways
    2. Loops through all Airkit deployment regions
    3. Coalesces and returns NAT Gateway IP addresses from every region

API G(ateway) + Lambda

The CDN & DNS are definitely usability adds; However, the true functionality is provided by this powerhouse APIG + Lambda combination.

Take a look at the following NodeJS that we use in our Lambda:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
var AWS = require('aws-sdk');
var EC2 = new AWS.EC2({"region": "us-west-2"});
var params = {Filter: [{Name: "tag:Name", Values:["*proxy*"]}]};
var regions = ["us-west-2", "eu-central-1", "ap-southeast-2"];

module.exports.handler = async (event) => {
  var results = {"egress": new Array()};
  var statusCode = 200;
  await Promise.all(regions.map(async (region) => {
    await new AWS.EC2({"region": region})
      .describeNatGateways(params, function(error, data) {
      if (error) {
        console.error(error); // an error occurred
        statusCode = 500;
      } else {
        data.NatGateways.forEach(NatGateway => {
          NatGateway.NatGatewayAddresses.forEach(Interface => {
            var unique = new Set(results.egress).add(Interface.PublicIp+"/32");
            results.egress = [...unique];
          });
        });
      }
    }).promise();
  }));
  
  if (statusCode != 200) {
    delete results.egress;
  }

  return {
    statusCode: statusCode,
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(results)
  };
};
  1. The EC2 Service Interface is configured to connect to specific regions. This makes the individual requests in parallel, as they eventually populate the egress array in results.
  2. The contents of params includes a Filter; Specifically filtering by AWS tags containing proxy. This narrows the returned NAT Gateway list to only contain the specific NAT Gateways for our egress HTTP proxy. NOTE: Filtering by tag requires the tags to have been setup previously.
  3. Errors are logged if they occur, and otherwise the data object is looped over, using a Set()+Array() to provide a unique list of the filtered NAT Gateways IP addresses
  4. We tie this all together with a HTTP-specific return, with statusCode, headers, and body set to return the result object we created that contains the list of addresses.

IAM Role: Describe NAT Gateways

Let’s start by giving the above Lambda function permissions to carry out the AWS API functions we set it up for; namely describing NAT Gateways:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
data "aws_iam_policy_document" "assume_role" {
  statement {
    effect = "Allow"
    actions = [ "sts:AssumeRole" ]
    principals {
      type = "Service"
      identifiers = [ "lambda.amazonaws.com" ]
    }
  }
}

resource "aws_iam_role" "lambda_exec" {
  name = "ip_ranges_api_serverless_lambda"
  assume_role_policy = data.aws_iam_policy_document.assume_role.json
}

data "aws_iam_policy_document" "access" {
  statement {
    effect = "Allow"
    actions = [ "ec2:describeNatGateways" ]
    resources = [ "*" ]
  }

  statement {
    effect = "Allow"
    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]
    resources = [ "*" ]
  }
}

resource "aws_iam_role_policy" "access" {
  name = "ip_ranges_api_access_policy"
  role = aws_iam_role.lambda_exec.id
  policy = data.aws_iam_policy_document.access.json
}

Breaking down the above terraform, we have:

  1. An assume_role policy document that is actually a Trust Policy defined to allow Lambda to use the Role permissions we are about to define. This is done by giving the lambda.amazonaws.com Service Principal permission to call the sts:AssumeRole action.
  2. The assume_role policy document must then be attached as the assume_role_policy for the IAM Role that our Lambda application will use to execute with
  3. An access policy document that is where we define 2 statements to be used by the core functionality of our Lambda application code:
    1. Allowing the ec2:describeNatGateways action to be run against all accounts/regions
    2. Allowing 3 different Logs actions to let us log our console output to CloudWatch
  4. This access policy document must then also be attached as an additional policy to the same IAM Role

HTTPS: Certificate Validation

In order to share the same certificate between our Lambda (running in us-west-2) and CloudFront (running in us-east-1) we need to make sure the certificate exists in both locations in ACM.

To do this, we run the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
resource "aws_acm_certificate" "us_west_2" {
  provider    = aws.us_west_2
  domain_name = var.domain_name
  subject_alternative_names = var.subject_alternative_names
  validation_method="DNS"
  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_acm_certificate" "us_east_1" {
  provider    = aws.us_east_1
  domain_name = var.domain_name
  subject_alternative_names = var.subject_alternative_names
  validation_method="DNS"
  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "certificate_validation" {
  for_each = merge(
    { for dvo in aws_acm_certificate.us_east_1.domain_validation_options : dvo.domain_name => {
        name    = dvo.resource_record_name
        record  = dvo.resource_record_value
        type    = dvo.resource_record_type
        zone_id = data.aws_route53_zone.domain.zone_id
      }
    },
    { for dvo in aws_acm_certificate.us_west_2.domain_validation_options : dvo.domain_name => {
        name    = dvo.resource_record_name
        record  = dvo.resource_record_value
        type    = dvo.resource_record_type
        zone_id = data.aws_route53_zone.domain.zone_id
      }
    }
  )

  allow_overwrite = true
  name = each.value.name
  records = [each.value.record]
  ttl = 30
  type = each.value.type
  zone_id = each.value.zone_id
}

resource "aws_acm_certificate_validation" "us_west_2" {
  provider = aws.us_west_2
  certificate_arn = aws_acm_certificate.us_west_2.arn
  validation_record_fqdns = [ for record in aws_route53_record.certificate_validation : record.fqdn ]
  timeouts {
    create = "10m"
  }
}

resource "aws_acm_certificate_validation" "us_east_1" {
  provider = aws.us_east_1
  certificate_arn = aws_acm_certificate.us_east_1.arn
  validation_record_fqdns = [ for record in aws_route53_record.certificate_validation : record.fqdn ]
  timeouts {
    create = "10m"
  }
}

resource "aws_apigatewayv2_domain_name" "ip_ranges" {
  for_each    = toset(flatten([var.domain_name, var.subject_alternative_names]))
  domain_name = each.value
  domain_name_configuration {
    certificate_arn = aws_acm_certificate.us_west_2.arn
    endpoint_type   = "REGIONAL"
    security_policy = "TLS_1_2"
  }
}

The important takeaways from the above:

  1. We create the 2 different (but identical) certificates because AWS services are dependent on having regionally-colocated certificates.
  2. By using DNS validation, we can auto-generate the required Route53 records by looping over the combined domain_validation_options from the certificates.
  3. We use a certificate validation resource (aka: aws_acm_certificate_validation) specifically to wait for the DNS validation to succeed, and then use the validated certificate inside our aws_apigatewayv2_domain_name (the API Gateway will use the certificate deployed in us-west-2 while the CloudFront Distribution will use the certificated deployed in us-east-1)

API Gateway: HTTP API

With AWS API Gateway (v2 because we are creating an HTTP API) one would typically define some endpoint route_keys that would execute specific APIs. However in the Airkit case, we want to access the API without needing an API route.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
resource "aws_apigatewayv2_stage" "lambda" {
  api_id      = aws_apigatewayv2_api.lambda.id
  name        = "ip_ranges"
  auto_deploy = true
  default_route_settings {
    throttling_burst_limit = 1
    throttling_rate_limit  = 1
  }
}

resource "aws_apigatewayv2_route" "ip_ranges" {
  api_id = aws_apigatewayv2_api.lambda.id
  route_key = "$default"
  target    = "integrations/${aws_apigatewayv2_integration.ip_ranges.id}"
} 

A few key callouts from the above:

  1. We specifically set the default route settings (aka: throttling_burst_limit and throttling_rate_limit) to a low value to prevent our API from potentially causing runaway Lambda costs.
  2. Because we created an HTTP API (API Gateway v2) we use the special $default route key that is available to us. Using this allows the base URL to be hit as the endpoint for our API.

What we should have defined at this point is a functioning API Gateway endpoint URL that we can access in order to call our Lambda.

CloudFront: Cache & HTTP Redirect

AWS CloudFront is typically deployed as an edge-cache to be backed by some form of S3 bucket. In our case however, we want the backend to be the API Gateway we just created.

Additionally, because we can perform HTTP-to-HTTPS redirecting in AWS CloudFront, we will use that feature to compensate for the missing HTTP endpoint in the API Gateway.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
resource "aws_cloudfront_distribution" "http_redirect" {
  provider = aws.us_east_1
  enabled = true

  origin {
    domain_name = regex("[htps]*://(.*.com)(.*)", aws_apigatewayv2_stage.lambda.invoke_url)[0]
    origin_path = regex("[htps]*://(.*.com)(.*)", aws_apigatewayv2_stage.lambda.invoke_url)[1]
    origin_id   = replace(var.domain_name, "/[^a-zA-Z0-9]/", "_")
    custom_origin_config {
      http_port              = "80"
      https_port             = "443"
      origin_protocol_policy = "https-only"
      origin_ssl_protocols   = ["TLSv1.2"]
    }
  }

  default_cache_behavior {
    allowed_methods  = ["GET", "HEAD"]
    cached_methods   = ["GET", "HEAD"]
    target_origin_id = replace(var.domain_name, "/[^a-zA-Z0-9]/", "_")
    viewer_protocol_policy = "redirect-to-https"
    forwarded_values {
      query_string = true
      cookies {
        forward = "none"
      }
    }
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    acm_certificate_arn = aws_acm_certificate.us_east_1.arn
    ssl_support_method = "sni-only"
    minimum_protocol_version = "TLSv1.2_2021"
  }

  aliases = flatten([var.domain_name, var.subject_alternative_names])
}

Breaking down the http_redirect distribution, there are a few key elements that make this work:

  1. The us-east-1 provider is because CloudFront in conjunction with ACM is locked to that region
  2. Regex is used to parse out the domain_name and the origin_path from the URL endpoint provided by API Gateway. (You can also view this “Invoke URL” in the AWS Console)
  3. The custom_origin_config is required to prevent CloudFront from assuming an S3 backend
  4. We pass in the var.domain_name (ex: prod.airkit.com) and var.subject_alternative_names (ex: Automate Digital Customer Experiences Faster ) as variables for use in both attaining SSL/TLS certificates from ACM, but also in naming our origin_id (likewise the target_origin_id later in the file), and providing CloudFront with distribution aliases
  5. The viewer_protocol_policy set to redirect-to-https fixes our previously-missing handling of HTTP requests

Route53: Alias for CloudFront

The only step we are missing from our original top-level design is our DNS alias that would point ip-ranges.airkit.com and ip-ranges.prod.airkit.com to the same CloudFront endpoint.

1
2
3
4
5
6
7
8
9
10
11
12
13
resource "aws_route53_record" "ip_ranges" {
  for_each = toset(flatten([var.domain_name, var.subject_alternative_names]))
  
  name     = aws_apigatewayv2_domain_name.ip_ranges[each.value].domain_name
  type     = "A"
  zone_id  = data.aws_route53_zone.domain.zone_id

  alias {
    name                   = aws_cloudfront_distribution.http_redirect.domain_name
    zone_id                = aws_cloudfront_distribution.http_redirect.hosted_zone_id
    evaluate_target_health = false
  }
}

Again breaking it down:

  1. We loop over all Domains and SAN (Subject Alternative Names), creating an alias for each within the same Route53 HostedZone, namely domain (In Airkit’s particular case the ZoneID is always the same for any given set of Domain + SAN)
  2. We point the alias destination to the CloudFront distribution we just set up. Completing both the HTTPS and HTTP paths from our URL to our Lambda function.

Summary

Airkit’s entire list of egress IP addresses is available now at ip-ranges.airkit.com. This is not a substitute for authentication, proper access controls and AI-driven attack mitigation. However, it can provide an additional level of granularity to an existing security stature, hopefully driving an experience that both we and our customers can love.

After all, empowering our customers to build experiences that people love, is what we do.

See what Airkit has to offer.

Sign up for a free account!

Start free