diff --git a/README.md b/README.md index d70b266..2623f97 100644 --- a/README.md +++ b/README.md @@ -166,3 +166,68 @@ An example IAM policy is: ] } ``` + +### Cross-Account handling +You will need this feature, if Route53 is managed through your main account and ELB are provisioned in a separate AWS account. + +Useful documentations: [AWS Examples of Policies for Delegating Access](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_policy-examples.html) and +[AWS Tutorial: Delegate Access Across AWS Accounts Using IAM Roles](https://docs.aws.amazon.com/IAM/latest/UserGuide/tutorial_cross-account-with-roles.html) + +In your account, which includes the ELB, you should create a new policy: +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Action": [ + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:SetLoadBalancerListenerSSLCertificate" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "", + "Effect": "Allow", + "Action": [ + "iam:ListServerCertificates", + "iam:UploadServerCertificate", + "iam:DeleteServerCertificate", + "iam:GetServerCertificate" + ], + "Resource": [ + "*" + ] + } + ] +} +``` +After this, you need to create a new `Role for Cross-Account Access`. +Enter your Account ID from your main account and choose the policy you've just created. +Edit the `Trust Relationships` and replace `arn:aws:iam::yourmainaccountnumber:root` with +`arn:aws:iam::yourmainaccountnumber:user/youruser` + +In your main account, add a new policy to your user: +```json +{ + "Version": "2012-10-17", + "Statement": { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Resource": "arn:aws:iam::yourELBaccountID:role/the-role-you-just-created" + } +} +``` + +Add `.aws/config` to your boto3 installation with the content: +```console +[profile crossaccount-example] +role_arn=arn:aws:iam::yourELBaccountID:role/the-role-you-just-created +source_profile=default +``` + +Then you can simply run it: `python letsencrypt-aws.py update-certificates --cross-profile=crossaccount-example`. + diff --git a/letsencrypt-aws.py b/letsencrypt-aws.py index 283eebb..7d2a5f1 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -120,19 +120,42 @@ def update_certificate(self, logger, hosts, private_key, pem_certificate, class Route53ChallengeCompleter(object): - def __init__(self, route53_client): + def __init__(self, route53_client, route53_cross_client=None): self.route53_client = route53_client + self.route53_cross_client = route53_cross_client + + # return the session where the DNS zone is located + def _find_client_for_domain(self, domain): + if not self.route53_cross_client: + return self.route53_client + + for client in (self.route53_client, self.route53_cross_client): + paginator = client.get_paginator("list_hosted_zones") + for page in paginator.paginate(): + for zone in page["HostedZones"]: + if ( + domain.endswith(zone["Name"]) or + (domain + ".").endswith(zone["Name"]) + ) and not zone["Config"]["PrivateZone"]: + return client + + raise ValueError( + "Unable to find a Route53 hosted zone for {}".format(domain) + ) def _find_zone_id_for_domain(self, domain): - paginator = self.route53_client.get_paginator("list_hosted_zones") zones = [] - for page in paginator.paginate(): - for zone in page["HostedZones"]: - if ( - domain.endswith(zone["Name"]) or - (domain + ".").endswith(zone["Name"]) - ) and not zone["Config"]["PrivateZone"]: - zones.append((zone["Name"], zone["Id"])) + for client in (self.route53_client, self.route53_cross_client): + if not client: + continue + paginator = client.get_paginator("list_hosted_zones") + for page in paginator.paginate(): + for zone in page["HostedZones"]: + if ( + domain.endswith(zone["Name"]) or + (domain + ".").endswith(zone["Name"]) + ) and not zone["Config"]["PrivateZone"]: + zones.append((zone["Name"], zone["Id"])) if not zones: raise ValueError( @@ -147,7 +170,8 @@ def _find_zone_id_for_domain(self, domain): return zones[0][1] def _change_txt_record(self, action, zone_id, domain, value): - response = self.route53_client.change_resource_record_sets( + client = self._find_client_for_domain(domain) + response = client.change_resource_record_sets( HostedZoneId=zone_id, ChangeBatch={ "Changes": [ @@ -188,11 +212,12 @@ def delete_txt_record(self, change_id, host, value): value ) - def wait_for_change(self, change_id): + def wait_for_change(self, change_id, host): _, change_id = change_id + client = self._find_client_for_domain(host) while True: - response = self.route53_client.get_change(Id=change_id) + response = client.get_change(Id=change_id) if response["ChangeInfo"]["Status"] == "INSYNC": return time.sleep(5) @@ -283,7 +308,10 @@ def complete_dns_challenge(logger, acme_client, dns_challenge_completer, "updating-elb.wait-for-route53", elb_name=elb_name, host=authz_record.host ) - dns_challenge_completer.wait_for_change(authz_record.change_id) + dns_challenge_completer.wait_for_change( + authz_record.change_id, + authz_record.host + ) response = authz_record.dns_challenge.response(acme_client.key) @@ -467,7 +495,14 @@ def cli(): "expiration." ) ) -def update_certificates(persistent=False, force_issue=False): +@click.option( + "--cross-profile", help=( + "Specify your profile, if Route53 and ELB are in " + "different accounts located." + ) +) +def update_certificates(persistent=False, force_issue=False, + cross_profile=False): logger = Logger() logger.emit("startup") @@ -476,9 +511,16 @@ def update_certificates(persistent=False, force_issue=False): session = boto3.Session() s3_client = session.client("s3") - elb_client = session.client("elb") route53_client = session.client("route53") - iam_client = session.client("iam") + if cross_profile: + cross_session = boto3.Session(profile_name=cross_profile) + elb_client = cross_session.client("elb") + iam_client = cross_session.client("iam") + route53_cross_client = cross_session.client("route53") + else: + elb_client = session.client("elb") + iam_client = session.client("iam") + route53_cross_client = None config = json.loads(os.environ["LETSENCRYPT_AWS_CONFIG"]) domains = config["domains"] @@ -504,7 +546,7 @@ def update_certificates(persistent=False, force_issue=False): certificate_requests.append(CertificateRequest( cert_location, - Route53ChallengeCompleter(route53_client), + Route53ChallengeCompleter(route53_client, route53_cross_client), domain["hosts"], domain.get("key_type", "rsa"), ))