November 27, 2015

Automated cross account DNS management through CFN and API access through API gateway

It’s a general best practice to manage all DNS entries in Route53 in a centralized AWS account. In that case it is difficult to automate the DNS record creation/deletion based on resources created in another AWS account using CloudFormation. CloudFormation doesn’t yet have the capability to create resources in a different AWS account.

Combining IAM role delegation, AWS Lambda & CFN Custom resources provides us a solution. I have extended the same solution by exposing the Lambda function through API gateway which provides a powerful mechanism. The illustration give below explains the solution.

Route53

Solution 1: Automated cross account DNS management through CFN.

Create an IAM role (LambdaUpdateRoute53Role) in the external AWS account where CFN will create the resources which require access to manage the DNS entries in the master AWS account. This role should have normal lambda permissions and permission to assume role. Sample IAM policy that can be used for this role is below.

Sample IAM Policy

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "LoggingAccess",
            "Effect": "Allow",
            "Action": [
                "logs:*"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        },
        {
            "Sid": "STSPolicy",
            "Effect": "Allow",
            "Action": [
                "sts:*"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

In the master AWS account where all the DNS entries are maintained, create an IAM role (CrossAccountR53Role) which has permissions to manage the record sets of the required Route53 hosted zone. This role should have a trust relationship defined to enable IAM roles of 3rd party account to assume this role.

Permissions Policy

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ResourceRecordAccess",
            "Effect": "Allow",
            "Action": [
                "route53:ChangeResourceRecordSets",
                "route53:ListResourceRecordSets"
            ],
            "Resource": [
                "arn:aws:route53:::hostedzone/Z8L2Y45GOT7LQT"
            ]
        },
        {
            "Sid": "ChangePermissions",
            "Effect": "Allow",
            "Action": [
                "route53:GetChange"
            ],
            "Resource": "arn:aws:route53:::change/*"
        },
        {
            "Sid": "HealthCheckPermissions",
            "Effect": "Allow",
            "Action": [
                "route53:CreateHealthCheck",
                "route53:DeleteHealthCheck",
                "route53:GetHealthCheck",
                "route53:GetHealthCheckCount",
                "route53:ListHealthChecks",
                "route53:UpdateHealthCheck"
            ],
            "Resource": [
                "arn:aws:route53:::healthcheck/*"
            ]
        }
    ]
}

Trust Relationships

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "GrantAssumeRole",
      "Effect": "Allow",
      "Principal": {
        "AWS": [
          "arn:aws:iam::123456789012:role/LambdaUpdateRoute53",
          "arn:aws:iam::987654321098:role/LambdaUpdateRoute53"
        ]
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

A quick reminder:The IAM role which needs to be added to the trust relationships should be created before it’s added to the trust relationships.

Lambda function accepts the following parameters that are required to create a record set.

  • RoleArn - ARN of the CrossAccountR53Role in Master AWS account which will be assumed to create the record set.
  • HostedZoneId - ID of the hosted zone in Route53 were this record needs to be created.
  • Name - DNS name that needs to be created.
  • Type - DNS record type that needs to be created. (CNAME / A)
  • Alias - Whether it’s an Alias record type. (true/false).
  • DNSName - DNS name of the resource to which the record needs to be mapped in case of CNAME record type or alias of ‘A’record type.
  • IP - IP address to which the record needs to be mapped in case of ‘A’ record.
  • ResourceHostedZoneId - Hosted Zone ID of the AWS resource (ELB, CloudFront, R53 or S3) to which the alias ‘A’ record needs to be created.

This function creates the proper input parameter for the API request based on the record type, then assumes the role of the CrossAccountR53Role and invokes the API to create record type based on the input parameters.

Lambda Function

/**
*
* Handler called by Lambda function.
* @param {object} event - event parameter gets the attributes from CFN trigger.
* @param {object} context - context parameter used to log details to CloudWatch log stream.
*
*/
exports.handler = function(event, context) {

  console.log('REQUEST RECEIVED:n', JSON.stringify(event));
  console.log('Assume Role: ' + event.ResourceProperties.RoleArn);
  var responseStatus = 'FAILED';
  var resourceaction = 'UPSERT';
  var shzid = event.ResourceProperties.HostedZoneId;
  var rtype = event.ResourceProperties.Type;
  var ralias = event.ResourceProperties.Alias;
  var rhzid = event.ResourceProperties.ResourceHostedZoneId;
  var rdns = event.ResourceProperties.DNSName;
  var rip = event.ResourceProperties.IP;
  var rname = event.ResourceProperties.Name;

  if (event.RequestType === 'Delete') {
   resourceaction = 'DELETE';
  }

  var params = {
    RoleArn: event.ResourceProperties.RoleArn,
    RoleSessionName: 'CrossAccountRoute53Role',
    DurationSeconds: 900
  };

  var aws = require('aws-sdk');
  var sts = new aws.STS();

  console.log('Going to assume role. ');
  sts.assumeRole(params, function(err, data) {
    if (err) {
      responseData = {Error: 'Failed to assume role'};
      console.log(responseData.Error + ':n', err);
    }
    else {
      var accessparams = {
        accessKeyId: data.Credentials.AccessKeyId,
        secretAccessKey: data.Credentials.SecretAccessKey,
        sessionToken: data.Credentials.SessionToken
      };
      var route53 = new aws.Route53(accessparams);
      if (rtype === 'A' && ralias === 'true') {
        r53params = {
          HostedZoneId: shzid,
          ChangeBatch: {
            Changes: [
              {
                Action: resourceaction,
                ResourceRecordSet: {
                  Name: rname,
                  Type: rtype,
                  AliasTarget: {
                    DNSName: rdns,
                    EvaluateTargetHealth: false,
                    HostedZoneId: rhzid
                  }
                }
              }
            ]
          }
        };
      }
      else if (rtype === 'CNAME') {
        r53params = {
          HostedZoneId: shzid,
          ChangeBatch: {
            Changes: [
              {
                Action: resourceaction,
                ResourceRecordSet: {
                  Name: rname,
                  Type: rtype,
                  ResourceRecords: [{ Value: rdns }],
                  TTL: '300',
                }
              }
            ]
          }
        };
      }
      else if (rtype === 'A' && ralias === 'false') {
        r53params = {
          HostedZoneId: shzid,
          ChangeBatch: {
            Changes: [
              {
                Action: resourceaction,
                ResourceRecordSet: {
                  Name: rname,
                  Type: rtype,
                  TTL: '300',
                  ResourceRecords: [{ Value: rip }]
                }
              }
            ]
          }
        };
      }
      else {
        responseData = {Error: 'Unsupported DNS Type' + rtype};
        console.log(responseData.Error);
        console.log('Currently supports only A record & CNAME record.');
        sendResponse(event, context, responseStatus, responseData);
      }
      console.log('Using the following parameters for Route53.');
      console.log(JSON.stringify(r53params));

      route53.changeResourceRecordSets(r53params, function(err, r53data) {
        if (err) {
          responseData = {Error: 'Failed to configure DNS record'};
          console.log(responseData.Error + ':n', err);
        }
        else {
          responseStatus = 'SUCCESS';
          responseData = {Success: 'State of DNS record ' + r53data.Status};
          responseData.URL = rname;
          console.log(r53data);           // successful response
        }
        sendResponse(event, context, responseStatus, responseData);
      });
    }
  });
};

// Sends a response to the pre-signed S3 URL
var sendResponse = function(event, context, responseStatus, responseData) {
  var responseBody = JSON.stringify({
    Status: responseStatus,
    Reason: 'See the details in CloudWatch Log Stream: ' + context.logStreamName,
    PhysicalResourceId: context.logStreamName,
    StackId: event.StackId,
    RequestId: event.RequestId,
    LogicalResourceId: event.LogicalResourceId,
    Data: responseData
  });

  console.log('RESPONSE BODY:n', responseBody);

  var https = require('https');
  var url = require('url');
  var parsedUrl = url.parse(event.ResponseURL);
  var options = {
    hostname: parsedUrl.hostname,
    port: 443,
    path: parsedUrl.path,
    method: 'PUT',
    headers: {
      'Content-Type': '',
      'Content-Length': responseBody.length
    }
  };

  var req = https.request(options, function(res) {
    console.log('STATUS:', res.statusCode);
    console.log('HEADERS:', JSON.stringify(res.headers));
    context.succeed('Successfully sent stack response!');
  });

  req.on('error', function(err) {
    console.log('sendResponse Error:n', err);
    context.fail(err);
  });

  req.write(responseBody);
  req.end();
};

In CFN invoke this Lambda function with appropriate parameters for different scenarios.

Alias record for ELB

{
  "ElasticLoadBalancer": {
    "Type": "AWS::ElasticLoadBalancing::LoadBalancer",
    "Properties": {
    }
  },
  "ManageDNS": {
    "Type": "Custom::ManageDNS",
    "Properties": {
      "ServiceToken": {"Ref": "Route53Function"},
      "RoleArn": {"Ref": "Route53Role"},
      "Type": "CNAME",
      "Alias": true,
      "Name": "cloudenlightened.wordpress.com",
      "DNSName": {"Fn::GetAtt": ["ElasticLoadBalancer", "DNSName"]},
      "HostedZoneId": {"Ref": "HostedZoneId"},
      "ResourceHostedZoneId": {"Fn::GetAtt": ["ElasticLoadBalancer", "CanonicalHostedZoneNameID"]}
    }
  }
}

CNAME record for RDS endpoint

{
  "DBInstance": 
  {
    "Type": "AWS::RDS::DBInstance",
    "Properties": {
    }
  },
  "ManageDNS": {
    "Type": "Custom::ManageDNS",
    "Properties": {
      "ServiceToken": {"Ref": "Route53Function"},
      "RoleArn": {"Ref": "Route53Role"},
      "Type": "CNAME",
      "Alias": "false",
      "Name": "db.cloudenlightened.wordpress.com",
      "DNSName": {"Fn::GetAtt": ["DBInstance", "Endpoint.Address"]},
      "HostedZoneId": {"Ref": "HostedZoneId"}
  }
}

A record for Elastic IP

{
  "EIP": {
    "Type": "AWS::EC2::EIP",
    "Properties": {}
  },
  "ManageDNS": {
    "Type": "Custom::ManageDNS",
    "Properties": {
      "ServiceToken": {"Ref": "Route53Function"},
      "RoleArn": {"Ref": "Route53Role"},
      "Type": "A",
      "Alias": "false",
      "Name": "eip.cloudenlightened.wordpress.com",
      "IP": {"Ref": "EIP"},
      "HostedZoneId": {"Ref": "HostedZoneId"}
    }
  }
}

This function also handles the delete operation. When the stack gets deleted, it will automatically delete the corresponding DNS entries from Route53.

Solution 2: Manage DNS entries from anywhere using API gateway.

Lambda & API gateway is a brilliant combination. We will just reuse the above script and remove the assume role functionality (as shown below) since this Lambda function can be executed from the same AWS.

Lambda Function to support API Gateway

/**
*
* Handler called by Lambda function.
* @param {object} event - event parameter gets the attributes from API gateway POST request.
* @param {object} context - context parameter used to log details to CloudWatch log stream.
*
*/
exports.handler = function(event, context) {

  console.log('REQUEST RECEIVED:n', JSON.stringify(event));

  var responseStatus = 'FAILED';
  var resourceaction = 'UPSERT';
  var shzid = event.ResourceProperties.HostedZoneId;
  var rtype = event.ResourceProperties.Type;
  var ralias = event.ResourceProperties.Alias;
  var rhzid = event.ResourceProperties.ResourceHostedZoneId;
  var rdns = event.ResourceProperties.DNSName;
  var rip = event.ResourceProperties.IP;
  var rname = event.ResourceProperties.Name;

  if (event.RequestType.toUpperCase() == 'DELETE') {
      resourceaction = 'DELETE';
  }
  var paramsstr = '';

  var aws = require('aws-sdk');

  var route53 = new aws.Route53({});
  if (rtype == 'A' && ralias == 'true') {
    r53params = {
      HostedZoneId: shzid,
      ChangeBatch: {
        Changes: [
          {
            Action: resourceaction,
            ResourceRecordSet: {
              Name: rname,
              Type: rtype,
              AliasTarget: {
                DNSName: rdns,
                EvaluateTargetHealth: false,
                HostedZoneId: rhzid
              }
            }
          }
        ]
      }
    };
  }
  else if (rtype == 'CNAME') {
    r53params = {
      HostedZoneId: shzid,
      ChangeBatch: {
        Changes: [
          {
            Action: resourceaction,
            ResourceRecordSet: {
              Name: rname,
              Type: rtype,
              ResourceRecords: [{ Value: rdns }],
              TTL: '300'
            }
          }
        ]
      }
    };
  }
  else if (rtype == 'A' && ralias == 'false') {
    r53params = {
      HostedZoneId: shzid,
      ChangeBatch: {
        Changes: [
          {
            Action: resourceaction,
            ResourceRecordSet: {
              Name: rname,
              Type: rtype,
              TTL: '300',
              ResourceRecords: [{ Value: rip }]
            }
          }
        ]
      }
    };
  }
  else {
      console.log('Currently supports only A record & CNAME record.');
      context.fail();
  }

  console.log('Using the following parameters for Route53.');
  console.log(JSON.stringify(r53params));

  route53.changeResourceRecordSets(r53params, function(err, r53data) {
    if (err) {
      console.log(err);
    }
    else {
      console.log(r53data);           // successful response
    }
    context.done();
  });
};

Create a specific IAM role for this Lambda function which allows access to CloudWatch logs & updating the resource record sets of specific hosted zone id.

IAM role for Lambda Function

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Route53Access",
            "Effect": "Allow",
            "Action": [
                "route53:ChangeResourceRecordSets",
                "route53:ListResourceRecordSets"
            ],
            "Resource": [
                "arn:aws:route53:::hostedzone/Z2WRLBI0FSKGOT",
                "arn:aws:route53:::change/*"
            ]
        },
        {
            "Sid": "CloudWatchAccess",
            "Effect": "Allow",
            "Action": [
                "logs:*"
            ],
            "Resource": "arn:aws:logs:*:*:*"
        }
    ]
}

Once the Lambda function has been created API gateway endpoint can be added directly from the Lambda screen as shown below. Provide a name for the API, select Method as POST and provide a deployment name. As you can see, I have used “Open” access for security just for the ease of demo. Never allow open access unless you know the impact of it.

API Gateway

Once submitted, it will create the API endpoint and provide the URL.

API Gateway Endpoint

With just a simple curl, we can do a POST request to this endpoint and create/delete the API endpoint as shown below.

Create Record:

{
    "ResourceRecordSets": []
}
prakash:~$
prakash:~$
prakash:~$ curl -w "%{http_code}" -X POST -d '{"RequestType": "create", "ResourceProperties": {"Type": "A", "Alias": "false", "HostedZoneId": "Z2WRLBI0FSKGOT", "Name": "blog.cloudenlightened.com", "IP": "10.20.30.40"}}' https://bzcxd72mya.execute-api.eu-west-1.amazonaws.com/demo/Route53APIGW --header "Content-Type:application/json"

null200
prakash:~$
prakash:~$
prakash:~$aws route53 list-resource-record-sets --cli-input-json file:///tmp/rrfilter.json
{
    "ResourceRecordSets": [
        {
            "TTL": 300,
            "ResourceRecords": [
                {
                    "Value": "10.20.30.40"
                }
            ],
            "Type": "A",
            "Name": "blog.cloudenlightened.com."
        }
    ]
}
prakash:~$</pre>

Delete Record:

prakash:~$aws route53 list-resource-record-sets --cli-input-json file:///tmp/rrfilter.json
{
    "ResourceRecordSets": [
        {
            "TTL": 300,
            "ResourceRecords": [
                {
                    "Value": "10.20.30.40"
                }
            ],
            "Type": "A",
            "Name": "blog.cloudenlightened.com."
        }
    ]
}
prakash:~$
prakash:~$
prakash:~$ curl -w "%{http_code}" -X POST -d '{"RequestType": "delete", "ResourceProperties": {"Type": "A", "Alias": "false", "HostedZoneId": "Z2WRLBI0FSKGOT", "Name": "blog.cloudenlightened.com", "IP": "10.20.30.40"}}' https://bzcxd72mya.execute-api.eu-west-1.amazonaws.com/demo/Route53APIGW --header "Content-Type:application/json"
null200
prakash:~$
prakash:~$
prakash:~$aws route53 list-resource-record-sets --cli-input-json file:///tmp/rrfilter.json

{
    "ResourceRecordSets": []
}
prakash:~$</pre>

Things to improve: A lot can be improved on the script to support DNS health checks and failover. API gateway can be used in a better way and that in itself is a beast.

© Prakash P 2015 - 2023

Powered by Hugo & Kiss.