Skip to content

Instantly share code, notes, and snippets.

@joshgarnett
Last active March 17, 2021 09:01
Show Gist options
  • Save joshgarnett/02920846fea35f738d3370fd991bb0e0 to your computer and use it in GitHub Desktop.
Save joshgarnett/02920846fea35f738d3370fd991bb0e0 to your computer and use it in GitHub Desktop.
Ruby script to use as a hook for the letsencrypt.sh client
#!/usr/bin/env ruby
require 'aws-sdk'
#
# This script requires you to have the following environment variables set:
# AWS_REGION="us-west-2"
# AWS_ACCESS_KEY_ID="<YOUR_KEY>"
# AWS_SECRET_ACCESS_KEY="<YOUR_SECRET_KEY>"
#
# Based on https://gist.github.com/asimihsan/d8d8f0f10bdc85fc6f8a
#
def find_hosted_zone(route53, domain)
route53 = Aws::Route53::Client.new
hosted_zones = route53.list_hosted_zones_by_name.hosted_zones
index = hosted_zones.index { |zone| domain.end_with?(zone.name.chop) }
if index.nil?
puts 'Unable to find matching zone.'
exit 1
end
hosted_zones[index]
end
def wait_for_change(route53, change_id)
status = ''
until status == 'INSYNC'
resp = route53.get_change(id: change_id)
status = resp.change_info.status
if status != 'INSYNC'
puts 'Waiting for dns change to complete'
sleep 5
end
end
end
def setup_dns(domain, txt_challenge)
route53 = Aws::Route53::Client.new
hosted_zone = find_hosted_zone(route53, domain)
changes = []
changes << {
action: 'UPSERT',
resource_record_set: {
name: "_acme-challenge.#{domain}.",
type: 'TXT',
ttl: 60,
resource_records: [
value: "\"#{txt_challenge}\""
]
}
}
resp = route53.change_resource_record_sets(
hosted_zone_id: hosted_zone.id,
change_batch: {
changes: changes
}
)
wait_for_change(route53, resp.change_info.id)
end
def delete_dns(domain, txt_challenge)
route53 = Aws::Route53::Client.new
hosted_zone = find_hosted_zone(route53, domain)
changes = []
changes << {
action: 'DELETE',
resource_record_set: {
name: "_acme-challenge.#{domain}.",
type: 'TXT',
ttl: 60,
resource_records: [
value: "\"#{txt_challenge}\""
]
}
}
resp = route53.change_resource_record_sets(
hosted_zone_id: hosted_zone.id,
change_batch: {
changes: changes
}
)
wait_for_change(route53, resp.change_info.id)
end
if __FILE__ == $PROGRAM_NAME
hook_stage = ARGV[0]
domain = ARGV[1]
txt_challenge = ARGV[3]
puts "stage: #{hook_stage} domain: #{domain} txt_challenge: #{txt_challenge}"
if hook_stage == 'deploy_challenge'
setup_dns(domain, txt_challenge)
elsif hook_stage == 'clean_challenge'
delete_dns(domain, txt_challenge)
end
end
@alagesann
Copy link

alagesann commented Oct 23, 2016

http://stackoverflow.com/questions/40202669/rrset-with-dns-name-acme-challenge-mybar-org-is-not-permitted-in-zone-bar-org

as per the above question i posted, find_hosted_zone malfunctions when there are more than one Route53 zones both ends with same name like "bar.org" and "mybar.org" and this function returns index of "bar.org" when i am trying to generate certificate for the domain "mybar.org". Hope this helps someone.

@5290charlie
Copy link

AWS::Route53 methods list_hosted_zones and list_hosted_zones_by_name paginate zone results with a max of 100.

AWS::Route53 list_hosted_zones* method documentation

So the find_hosted_zone function can fail if you have 100+ zones and the one you're looking for is not in the first page of results. I added another function to recursively build a list of all zones, and use that as the hosted_zones list within the find_hosted_zone function.

Here is an example of the function I'm using:

def get_hosted_zones(options={})
  route53 = Aws::Route53::Client.new

  response = route53.list_hosted_zones(options)

  result = response.hosted_zones

  if response.is_truncated
    result.concat(get_hosted_zones({ marker: response.next_marker }))
  end

  return result
end

def find_hosted_zone(route53, domain)
  hosted_zones = get_hosted_zones
  
  # . . .

end

Disclaimer: I'm still new to Ruby so there might be a better way to achieve this, but it works :)

@oveaurs
Copy link

oveaurs commented Feb 3, 2017

How I fixed it for me:
index = hosted_zones.rindex { |zone| domain.end_with?(zone.name.chop) }
This works because list_hosted_zones_by_name result is ordered like this:
com.example.
com.example.test.
com.example.testing.
so hosted_zones.index will give you example.com and hosted_zones.rindex will give test.example.com for name "cert.test.example.com"
(edit: not sure if this will work with > 100 zones though)

@cbergmann
Copy link

it seems that this does not work with wildcard certificates I tried to create a cert containing 'test.example.com' and '.test.example.com'. That does not work. If I use 'test2.example.com' and '.test.example.com' it works. The ACMEv2 Documentation states that there should be two TXT secrets in test.example.com when validating. One for the wildcard and one for the normal validation. I suspect that the script overrides the first with the second and then fails to validate.
Could that be?

@oveaurs
Copy link

oveaurs commented May 28, 2018

@reinhard-brandstaedter
Copy link

I found that this script has problems when you have sub domain zones in AWS. Eg.

  • example.com
  • sub.example.com

index = hosted_zones.index { |zone| domain.end_with?(zone.name.chop) }

This will cause the script to create the challenge TXT record in the example.com zone but a DNS query will try to find it in the subdomain zone. To work around this I changed the comparison to the other way around. Instead of checking if the domain ends with the zone name I check if the zone name ends with the domain latter part:

index = hosted_zones.index { |zone| zone.name.chop.end_with?(domain.split(".",2)[1]) }

Something to consider!

@oveaurs
Copy link

oveaurs commented Mar 17, 2021

@reinhard-brandstaedter what I did to fix the same issue:
index = hosted_zones.rindex { |zone| domain.end_with?(zone.name.chop) }
It will then use the "earliest match"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment