class Acmesmith::ChallengeResponders::Route53

Public Class Methods

new(aws_access_key: nil, assume_role: nil, hosted_zone_map: {}, restore_to_original_records: false) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 20
def initialize(aws_access_key: nil, assume_role: nil, hosted_zone_map: {}, restore_to_original_records: false)
  aws_options = {region: 'us-east-1'}.tap do |opt| 
    opt[:credentials] = Aws::Credentials.new(aws_access_key['access_key_id'], aws_access_key['secret_access_key'], aws_access_key['session_token']) if aws_access_key
  end

  @route53 = Aws::Route53::Client.new(aws_options.dup.tap do |opt|
    case
    when assume_role
      opt[:credentials] = Aws::AssumeRoleCredentials.new(
        client: Aws::STS::Client.new(aws_options),
        **({role_session_name: "acmesmith-#{$$}"}.merge(assume_role.map{ |k,v| [k.to_sym,v] }.to_h)),
      )
    end
  end)

  @hosted_zone_map = hosted_zone_map
  @hosted_zone_cache = {}

  @restore_to_original_records = restore_to_original_records
  @original_records = {}
end

Public Instance Methods

cap_respond_all?() click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 16
def cap_respond_all?
  true
end
cleanup_all(*domain_and_challenges) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 62
def cleanup_all(*domain_and_challenges)
  challenges_by_hosted_zone = domain_and_challenges.group_by { |(domain, _)| find_hosted_zone(domain) }

  zone_and_batches = challenges_by_hosted_zone.map do |zone_id, dcs|
    [
      zone_id,
      change_batch_for_challenges(
        dcs,
        action: 'DELETE',
        comment: '(cleanup)',
        post_changes: changes_to_restore_original_records(zone_id, *dcs),
      ),
    ]
  end

  request_changing_rrset(zone_and_batches, comment: 'to remove challenge responses')
end
respond_all(*domain_and_challenges) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 42
def respond_all(*domain_and_challenges)
  save_original_records(*domain_and_challenges) if @restore_to_original_records

  challenges_by_hosted_zone = domain_and_challenges.group_by { |(domain, _)| find_hosted_zone(domain) }

  zone_and_batches = challenges_by_hosted_zone.map do |zone_id, dcs|
    [
      zone_id,
      change_batch_for_challenges(
        dcs,
        action: 'UPSERT',
        pre_changes: changes_to_delete_original_cname(zone_id, *dcs),
      ),
    ]
  end

  change_ids = request_changing_rrset(zone_and_batches, comment: 'for challenge response')
  wait_for_sync(change_ids)
end
support?(type) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 11
def support?(type)
  # Acme::Client::Resources::Challenges::DNS01
  type == 'dns-01'
end

Private Instance Methods

canonical_fqdn(domain) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 234
def canonical_fqdn(domain)
  "#{domain}.".sub(/\.+$/, '')
end
change_batch_for_challenges(domain_and_challenges, comment: nil, action: 'UPSERT', pre_changes: [], post_changes: []) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 195
def change_batch_for_challenges(domain_and_challenges, comment: nil, action: 'UPSERT', pre_changes: [], post_changes: [])
  changes = domain_and_challenges
    .map do |d, c|
      rrset_for_challenge(d, c)
    end
    .group_by do |_|
      # Reduce changes by name. ACME server may require multiple challenge responses for the same identifier
      _.fetch(:name) 
    end
    .map do |name, cs| 
      cs.inject { |result, change|
        result.merge(resource_records: result.fetch(:resource_records, []) + change.fetch(:resource_records))
      }
    end
    .map do |change|
      {
        action: action,
        resource_record_set: change,
      }
    end

  {
    comment: "ACME challenge response #{comment}",
    changes: pre_changes + changes + post_changes,
  }
end
changes_to_delete_original_cname(zone_id, *domain_and_challenges) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 98
def changes_to_delete_original_cname(zone_id, *domain_and_challenges)
  @original_records[zone_id] ||= {}
  domain_and_challenges.map do |domain, challenge|
    name = "#{challenge.record_name}.#{domain}."
    original_records = @original_records[zone_id][name]
    next unless original_records
    original_cname = original_records.find{ |_| _.type == 'CNAME' }
    next unless original_cname

    # FIXME: support set_identifier?
    {
      action: 'DELETE',
      resource_record_set: {
        name: original_cname.name,
        ttl: original_cname.ttl,
        type: original_cname.type,
        resource_records: original_cname.resource_records.map(&:to_h),
        alias_target: original_cname.alias_target&.to_h,
      },
    }
  end.compact
end
changes_to_restore_original_records(zone_id, *domain_and_challenges) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 121
def changes_to_restore_original_records(zone_id, *domain_and_challenges)
  @original_records[zone_id] ||= {}
  domain_and_challenges.flat_map do |domain, challenge|
    name = "#{challenge.record_name}.#{domain}."
    original_records = @original_records[zone_id][name]
    next unless original_records

    # FIXME: support set_identifier?
    original_records.map do |original_record|
      next if original_record.type != challenge.record_type && original_record.type != 'CNAME'
      {
        action: 'CREATE',
        resource_record_set: {
          name: original_record.name,
          ttl: original_record.ttl,
          type: original_record.type,
          resource_records: original_record.resource_records.map(&:to_h),
          alias_target: original_record.alias_target&.to_h,
        },
      }
    end
  end.compact
end
find_hosted_zone(domain) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 238
def find_hosted_zone(domain)
  labels = domain.split(?.)
  zones = nil
  0.upto(labels.size-1).each do |i|
    zones = hosted_zone_list["#{labels[i .. -1].join(?.)}."]
    break if zones
  end

  raise HostedZoneNotFound, "hosted zone not found for #{domain.inspect}" unless zones
  raise AmbiguousHostedZones, "multiple hosted zones found for #{domain.inspect}: #{zones.inspect}, set @hosted_zone_map to identify" if zones.size != 1
  zones.first
end
hosted_zone_list() click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 257
def hosted_zone_list
  @hosted_zone_list ||= begin
    @route53.list_hosted_zones.each.flat_map do |page|
      page.hosted_zones
        .reject { |zone| zone.config.private_zone }
        .map {  |zone| [zone.name, zone.id] }
    end.group_by(&:first).map { |domain, kvs| [domain, kvs.map(&:last)] }.to_h.merge(hosted_zone_map)
  end
end
hosted_zone_map() click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 251
def hosted_zone_map
  @hosted_zone_map.map { |domain, zone_id|
    ["#{canonical_fqdn(domain)}.", [zone_id]] # XXX:
  }.to_h
end
list_existing_rrsets(hosted_zone_id, name) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 267
def list_existing_rrsets(hosted_zone_id, name)
  rrsets = []
  start_record_name = name
  start_record_type = nil
  start_record_identifier = nil

  while start_record_name == name
    begin
      tries = 0
      page = @route53.list_resource_record_sets(
        hosted_zone_id: hosted_zone_id,
        start_record_name: start_record_name,
        start_record_type: start_record_type,
        start_record_identifier: start_record_identifier,
        max_items: 10,
      )
      page.resource_record_sets.each do |rrset|
        rrsets << rrset if rrset.name == name
      end

      start_record_name = page.next_record_name
      start_record_type = page.next_record_type
      start_record_identifier = page.next_record_identifier
    rescue Aws::Route53::Errors::Throttling => e
      interval = (2**tries) * 0.1
      $stderr.puts "   ! #{e.class}: Sleeping #{interval} seconds (#{e.message})"
      sleep interval
      tries += 1
      retry
    end
  end
  rrsets
end
request_changing_rrset(zone_and_batches, comment: nil) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 145
def request_changing_rrset(zone_and_batches, comment: nil)
  puts "=> Requesting RRSet change #{comment}"
  puts
  change_ids = zone_and_batches.map do |(zone_id, change_batch)|
    puts " * #{zone_id}:"
    change_batch.fetch(:changes).each do |b|
      rrset = b.fetch(:resource_record_set)
      rrset.fetch(:resource_records).each do |rr|
        puts "   - #{b.fetch(:action)}: #{rrset.fetch(:name)} #{rrset.fetch(:ttl)} #{rrset.fetch(:type)} #{rr.fetch(:value)}"
      end
    end
    print "   ... "

    resp =  @route53.change_resource_record_sets(
      hosted_zone_id: zone_id, # required
      change_batch: change_batch,
    )
    change_id = resp.change_info.id

    puts "[ ok ] #{change_id}"
    puts
    change_id
  end

  change_ids
end
rrset_for_challenge(domain, challenge) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 222
def rrset_for_challenge(domain, challenge)
  domain = canonical_fqdn(domain)
  {
    name: "#{challenge.record_name}.#{domain}",
    type: challenge.record_type,
    ttl: 5,
    resource_records: [
      value: "\"#{challenge.record_content}\"",
    ],
  }
end
save_original_records(*domain_and_challenges) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 82
def save_original_records(*domain_and_challenges)
  domain_and_challenges.each do |domain, challenge|

    hosted_zone_id = find_hosted_zone(domain)
    name = "#{challenge.record_name}.#{domain}."

    rrsets = list_existing_rrsets(hosted_zone_id, name)
    next if rrsets.empty?

    @original_records[hosted_zone_id] ||= {}
    @original_records[hosted_zone_id][name] = rrsets
    puts "   * original_record: #{domain}(#{hosted_zone_id}): #{rrsets.inspect}"

  end
end
wait_for_sync(change_ids) click to toggle source
# File lib/acmesmith/challenge_responders/route53.rb, line 173
def wait_for_sync(change_ids)
  puts "=> Waiting for change to be in sync"
  puts

  all_sync = false
  until all_sync
    sleep 4

    all_sync = true
    change_ids.each do |id|
      change = @route53.get_change(id: id)

      sync = change.change_info.status == 'INSYNC'
      all_sync = false unless sync

      puts " * #{id}: #{change.change_info.status}"
      sleep 0.2
    end
  end
  puts
end