From 51947d81a074ae11976e8aeb93b036377fed250c Mon Sep 17 00:00:00 2001 From: Sebastian Wieseler Date: Wed, 11 May 2016 21:43:42 +0800 Subject: [PATCH 1/5] added --cross-profile to be able to handle Route53 and ELBs in different accounts --- README.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++ letsencrypt-aws.py | 14 +++++++--- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d70b266..37201f7 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. + +[AWS Examples of Policies for Delegating Access](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_policy-examples.html) +[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 `"AWS": "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..7872418 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -467,7 +467,10 @@ def cli(): "expiration." ) ) -def update_certificates(persistent=False, force_issue=False): +@click.option( + "--cross-profile", help="Specify your profile for ELB and IAM modifications." +) +def update_certificates(persistent=False, force_issue=False, cross_profile=False): logger = Logger() logger.emit("startup") @@ -476,9 +479,14 @@ 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") + else: + elb_client = session.client("elb") + iam_client = session.client("iam") config = json.loads(os.environ["LETSENCRYPT_AWS_CONFIG"]) domains = config["domains"] From 3d1a67e2c7effc72c2d5566dac3cbf4aea6d0dae Mon Sep 17 00:00:00 2001 From: Sebastian Wieseler Date: Wed, 11 May 2016 21:52:57 +0800 Subject: [PATCH 2/5] minor fixed in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 37201f7..2623f97 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ 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. -[AWS Examples of Policies for Delegating Access](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_policy-examples.html) +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: @@ -207,7 +207,7 @@ In your account, which includes the ELB, you should create a new policy: ``` 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 `"AWS": "arn:aws:iam::yourmainaccountnumber:root"` with +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: From dc129a5d5f1b61c79e2b3365ca67284cdf0e4db6 Mon Sep 17 00:00:00 2001 From: Sebastian Wieseler Date: Wed, 11 May 2016 22:02:41 +0800 Subject: [PATCH 3/5] handles the case where Route53 zones are in different accounts --- letsencrypt-aws.py | 56 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/letsencrypt-aws.py b/letsencrypt-aws.py index 7872418..a6fc1b8 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -120,19 +120,43 @@ 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 + + zones = [] + 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 +171,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 +213,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 +309,7 @@ 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) @@ -484,9 +510,11 @@ def update_certificates(persistent=False, force_issue=False, cross_profile=False 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"] @@ -512,7 +540,7 @@ def update_certificates(persistent=False, force_issue=False, cross_profile=False certificate_requests.append(CertificateRequest( cert_location, - Route53ChallengeCompleter(route53_client), + Route53ChallengeCompleter(route53_client, route53_cross_client), domain["hosts"], domain.get("key_type", "rsa"), )) From 0681ff8cfde7c4fc2152c6bb3769068f834aa439 Mon Sep 17 00:00:00 2001 From: Sebastian Wieseler Date: Thu, 12 May 2016 11:16:45 +0800 Subject: [PATCH 4/5] fixed errors reported by travis --- letsencrypt-aws.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/letsencrypt-aws.py b/letsencrypt-aws.py index a6fc1b8..b5ac8d1 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -129,7 +129,6 @@ def _find_client_for_domain(self, domain): if not self.route53_cross_client: return self.route53_client - zones = [] for client in (self.route53_client, self.route53_cross_client): paginator = client.get_paginator("list_hosted_zones") for page in paginator.paginate(): @@ -138,7 +137,7 @@ def _find_client_for_domain(self, domain): domain.endswith(zone["Name"]) or (domain + ".").endswith(zone["Name"]) ) and not zone["Config"]["PrivateZone"]: - return client + return client raise ValueError( "Unable to find a Route53 hosted zone for {}".format(domain) @@ -156,7 +155,7 @@ def _find_zone_id_for_domain(self, domain): domain.endswith(zone["Name"]) or (domain + ".").endswith(zone["Name"]) ) and not zone["Config"]["PrivateZone"]: - zones.append((zone["Name"], zone["Id"])) + zones.append((zone["Name"], zone["Id"])) if not zones: raise ValueError( @@ -309,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,authz_record.host) + dns_challenge_completer.wait_for_change( + authz_record.change_id, + authz_record.host + ) response = authz_record.dns_challenge.response(acme_client.key) @@ -494,9 +496,13 @@ def cli(): ) ) @click.option( - "--cross-profile", help="Specify your profile for ELB and IAM modifications." + "--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): +def update_certificates(persistent=False, force_issue=False, + cross_profile=False): logger = Logger() logger.emit("startup") From 30ce1c91e24212fc2ccd610a246299e187c807d1 Mon Sep 17 00:00:00 2001 From: Sebastian Wieseler Date: Thu, 12 May 2016 11:26:24 +0800 Subject: [PATCH 5/5] fixed errors reported by travis(2) --- letsencrypt-aws.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt-aws.py b/letsencrypt-aws.py index b5ac8d1..7d2a5f1 100644 --- a/letsencrypt-aws.py +++ b/letsencrypt-aws.py @@ -309,7 +309,7 @@ def complete_dns_challenge(logger, acme_client, dns_challenge_completer, elb_name=elb_name, host=authz_record.host ) dns_challenge_completer.wait_for_change( - authz_record.change_id, + authz_record.change_id, authz_record.host )