class Terrafying::Components::LetsEncrypt

Attributes

name[R]
source[R]

Public Class Methods

create(name, bucket, options = {}) click to toggle source
# File lib/terrafying/components/letsencrypt.rb, line 13
def self.create(name, bucket, options = {})
  LetsEncrypt.new.create name, bucket, options
end
find(name, bucket, options = {}) click to toggle source
# File lib/terrafying/components/letsencrypt.rb, line 16
def self.find(name, bucket, options = {})
  LetsEncrypt.new.find name, bucket, options
end
new() click to toggle source
Calls superclass method
# File lib/terrafying/components/letsencrypt.rb, line 20
def initialize
  super
  @acme_providers = setup_providers
  @zones = []
end

Public Instance Methods

create(name, bucket, options = {}) click to toggle source
# File lib/terrafying/components/letsencrypt.rb, line 41
def create(name, bucket, options = {})
  options = {
    prefix: '',
    provider: :staging,
    email_address: 'cloud@uswitch.com',
    public_certificate: false,
    curve: 'P384',
    rsa_bits: '3072',
    use_external_dns: false,
    renewing: false,
    renew_alert_options: {
      protocol: nil,
      endpoint: nil,
      endpoint_auto_confirms: false,
      confirmation_timeout_in_minutes: 1,
      raw_message_delivery: false,
      filter_policy: nil,
      delivery_policy: nil
    }
  }.merge(options)

  @name = name
  @bucket = bucket
  @prefix = options[:prefix]
  @acme_provider = @acme_providers[options[:provider]]
  @use_external_dns = options[:use_external_dns]
  @renewing = options[:renewing]
  @renew_alert_options = options[:renew_alert_options]
  @prefix_path = [@prefix, @name].reject(&:empty?).join("/")

  renew() if @renewing
  renew_alert() if @renew_alert_options[:endpoint] != nil

  provider :tls, {}

  resource :tls_private_key, "#{@name}-account",
            algorithm: "RSA",
            rsa_bits: options[:rsa_bits]

  resource :acme_registration, "#{@name}-reg",
           provider: @acme_provider[:ref],
           account_key_pem: output_of(:tls_private_key, "#{@name}-account", 'private_key_pem'),
           email_address: options[:email_address]

  @account_key = output_of(:acme_registration, "#{@name}-reg", 'account_key_pem')

  resource :aws_s3_bucket_object, "#{@name}-account",
           bucket: @bucket,
           key: File.join('', @prefix, @name, 'account.key'),
           content: @account_key

  resource :aws_s3_bucket_object, "#{@name}-config", {
    bucket: @bucket,
    key: File.join('', @prefix, @name, "config.json"),
    content: {
      id: output_of(:acme_registration, "#{@name}-reg", "id"),
      url: @acme_provider[:url],
      email_address: options[:email_address],
    }.to_json,
  }

  @ca_cert_acl = options[:public_certificate] ? 'public-read' : 'private'

  open(@acme_provider[:ca_cert], 'rb') do |cert|
    @ca_cert = cert.read
  end

  resource :aws_s3_bucket_object, object_name(@name, :cert),
           bucket: @bucket,
           key: object_key(@name, :cert),
           content: @ca_cert,
           acl: @ca_cert_acl

  @source = object_url(@name, :cert)

  resource :aws_s3_bucket_object, "#{@name}-metadata",
           bucket: @bucket,
           key: File.join('', @prefix, @name, '.metadata'),
           content: {
             provider: options[:provider].to_s,
             public_certificate: options[:public_certificate],
             use_external_dns: options[:use_external_dns],
           }.to_json

  self
end
create_keypair_in(ctx, name, options = {}) click to toggle source
# File lib/terrafying/components/letsencrypt.rb, line 156
def create_keypair_in(ctx, name, options = {})
  options = {
    common_name: name,
    organization: "uSwitch Limited",
    dns_names: [],
    ip_addresses: [],
    curve: "P384"
  }.merge(options)

  @zones << options[:zone] if options[:zone]

  key_ident = "#{@name}-#{tf_safe(name)}"

  ctx.resource :tls_private_key, key_ident,
               algorithm: 'ECDSA',
               ecdsa_curve: options[:curve]

  ctx.resource :tls_cert_request, key_ident,
               key_algorithm: 'ECDSA',
               private_key_pem: output_of(:tls_private_key, key_ident, :private_key_pem),
               subject: {
                 common_name: options[:common_name],
                 organization: options[:organization]
               },
               dns_names: options[:dns_names],
               ip_addresses: options[:ip_addresses]

  cert_options = {}
  cert_options[:recursive_nameservers] = ['1.1.1.1:53', '8.8.8.8:53', '8.8.4.4:53'] if @use_external_dns

  @renewing ? min_days_remaining = -1 : min_days_remaining = 21
  # we don't want Terraform to renew certs if the certbot lambda is provisioned
  ctx.resource :acme_certificate, key_ident, {
               provider: @acme_provider[:ref],
               account_key_pem: @account_key,
               min_days_remaining: min_days_remaining,
               dns_challenge: {
                 provider: 'route53'
               },
               certificate_request_pem: output_of(:tls_cert_request, key_ident, :cert_request_pem)
             }.merge(cert_options)

  csr_version = "${sha256(tls_cert_request.#{key_ident}.cert_request_pem)}"

  ctx.resource :aws_s3_bucket_object, "#{key_ident}-csr",
               bucket: @bucket,
               key: object_key(name, :csr, csr_version),
               content: output_of(:tls_cert_request, key_ident, :cert_request_pem)

  ctx.resource :aws_s3_bucket_object, "#{key_ident}-csr-latest",
               bucket: @bucket,
               key: object_key(name, :csr, 'latest'),
               content: csr_version

  key_version = "${sha256(tls_private_key.#{key_ident}.private_key_pem)}"

  ctx.resource :aws_s3_bucket_object, "#{key_ident}-key",
               bucket: @bucket,
               key: object_key(name, :key, key_version),
               content: output_of(:tls_private_key, key_ident, :private_key_pem)

  ctx.resource :aws_s3_bucket_object, "#{key_ident}-key-latest",
               bucket: @bucket,
               key: object_key(name, :key, 'latest'),
               content: key_version

  cert_version = "${sha256(acme_certificate.#{key_ident}.certificate_pem)}"

  cert_config = {
               bucket: @bucket,
               key: object_key(name, :cert, cert_version),
               content: output_of(:acme_certificate, key_ident, :certificate_pem).to_s + @ca_cert,
  }
  cert_config[:lifecycle] = { ignore_changes: [ "content" ] } if @renewing

  ctx.resource :aws_s3_bucket_object, "#{key_ident}-cert", cert_config

  ctx.resource :aws_s3_bucket_object, "#{key_ident}-cert-latest",
               bucket: @bucket,
               key: object_key(name, :cert, 'latest'),
               content: cert_version

  reference_keypair(ctx, name, key_version: key_version, cert_version: cert_version)
end
find(name, bucket, prefix: "") click to toggle source
# File lib/terrafying/components/letsencrypt.rb, line 128
def find(name, bucket, prefix: "")
  @name = name
  @bucket = bucket
  @prefix = prefix

  # load the rest of the config from an s3 metadata file
  metadata_obj = aws.s3_object(@bucket, [@prefix, @name, '.metadata'].compact.reject(&:empty?).join('/'))
  metadata = JSON.parse(metadata_obj, symbolize_names: true)

  @acme_provider = @acme_providers[metadata[:provider].to_sym]
  @use_external_dns = metadata[:use_external_dns]
  @ca_cert_acl = metadata[:public_certificate] ? 'public-read' : 'private'

  account_key_obj = data :aws_s3_bucket_object, "#{@name}-account",
                         bucket: @bucket,
                         key: File.join('', @prefix, @name, 'account.key')

  @account_key = account_key_obj["body"]

  open(@acme_provider[:ca_cert], 'rb') do |cert|
    @ca_cert = cert.read
  end

  @source = object_url(@name, :cert)

  self
end
generate_alpha_num() click to toggle source
# File lib/terrafying/components/letsencrypt.rb, line 423
def generate_alpha_num()
  result = @name.split("").each do |ch|
    alpha_num = ch.upcase.ord - 'A'.ord
    return alpha_num.abs if (alpha_num.abs < 24)
  end
  result.is_a?(Integer) ? result : 6
end
output_with_children() click to toggle source
Calls superclass method
# File lib/terrafying/components/letsencrypt.rb, line 241
def output_with_children
  iam_policy = {}
  if @renewing
    iam_policy = resource :aws_iam_policy, "#{@name}_lambda_execution_policy", {
    name: "#{@name}_lambda_execution_policy",
    description: "A policy for the #{@name}_lambda function to access S3 and R53",
    policy: JSON.pretty_generate(
          {
            Version: "2012-10-17",
            Statement: [
              {
                Action: [
                  "s3:Put*",
                  "s3:Get*",
                  "s3:DeleteObject"
                ],
                Resource: [
                  "arn:aws:s3:::#{@bucket}/#{@prefix_path}/*"
                ],
                Effect: "Allow"
              },
              {
                Action: [
                  "s3:ListBucket"
                ],
                Resource: [
                  "arn:aws:s3:::#{@bucket}"
                ],
                Effect: "Allow"
              },
              {
                Action: [
                  "logs:CreateLogGroup",
                  "logs:CreateLogStream",
                  "logs:PutLogEvents"
                ],
                Resource: [
                  "arn:aws:logs:*:*:*"
                ],
                Effect: "Allow"
              },
              {
                Action: [
                  "route53:ListHostedZones",
                ],
                Resource: [
                  "*"
                ],
                Effect: "Allow"
              },
              {
                Action: [
                  "route53:GetChange",
                ],
                Resource: [
                  "arn:aws:route53:::change/*"
                ],
                Effect: "Allow"
              },
              {
                Action: [
                  "route53:ChangeResourceRecordSets",
                ],
                Resource:
                  @zones.compact.map { | zone |
                    "arn:aws:route53:::#{zone.id[1..-1]}"
                  },
                Effect: "Allow"
              }
            ]
          }
        )
      }
    end
  super
end
renew() click to toggle source
# File lib/terrafying/components/letsencrypt.rb, line 318
def renew
  execution_role = resource :aws_iam_role, "#{@name}_lambda_execution", {
    name: "#{@name}_lambda_execution",
    assume_role_policy: JSON.pretty_generate(
          {
            Version: "2012-10-17",
            Statement: [
              {
                Action: "sts:AssumeRole",
                Principal: {
                  Service: "lambda.amazonaws.com"
                  },
                Effect: "Allow",
                Sid: ""
              }
            ]
          }
        )
      }

  lambda_function = resource :aws_lambda_function, "#{@name}_lambda", {
    function_name: "#{@name}_lambda",
    s3_bucket: "uswitch-certbot-lambda",
    s3_key: "certbot-lambda.zip",
    handler: "main.handler",
    runtime: "python3.7",
    timeout: "900",
    role: execution_role["arn"],
    environment:{
      variables: {
        CA_BUCKET: @bucket,
        CA_PREFIX: @prefix_path
      }
    }
  }

  resource :aws_iam_role_policy_attachment, "#{@name}_lambda_policy_attachment", {
    role: execution_role["name"],
    policy_arn: "${aws_iam_policy.#{@name}_lambda_execution_policy.arn}"
  }

  alpha_num = generate_alpha_num().to_s

  event_rule = resource :aws_cloudwatch_event_rule, "once_per_day", {
    name: "once-per-day",
    description: "Fires once per day",
    schedule_expression: "cron(0 #{alpha_num} * * ? *)"
  }

  resource :aws_cloudwatch_event_target, "#{@name}_lambda_event_target", {
    rule: event_rule["name"],
    target_id: lambda_function["id"],
    arn: lambda_function["arn"]
  }

  resource :aws_lambda_permission, "allow_cloudwatch_to_invoke_#{@name}_lambda", {
    statement_id: "AllowExecutionFromCloudWatch",
    action: "lambda:InvokeFunction",
    function_name: lambda_function["function_name"],
    principal: "events.amazonaws.com",
    source_arn: event_rule["arn"]
  }
  self
end
renew_alert() click to toggle source
# File lib/terrafying/components/letsencrypt.rb, line 383
def renew_alert
  topic = resource :aws_sns_topic, "#{@name}_lambda_cloudwatch_topic", {
           name: "#{@name}_lambda_cloudwatch_topic"
  }

  alarm = resource :aws_cloudwatch_metric_alarm, "#{@name}_lambda_failure_alarm", {
           alarm_name: "#{@name}-lambda-failure-alarm",
           comparison_operator: "GreaterThanOrEqualToThreshold",
           evaluation_periods: "1",
           period: "300",
           metric_name: "Errors",
           namespace: "AWS/Lambda",
           threshold: 1,
           statistic: "Maximum",
           alarm_description: "Alert generated if the #{@name} certbot lambda fails execution",
           actions_enabled: true,
           dimensions: {
                     FunctionName: "${aws_lambda_function.#{@name}_lambda.function_name}"
                   },
           alarm_actions: [
                     "${aws_sns_topic.#{@name}_lambda_cloudwatch_topic.arn}"
                   ],
           ok_actions: [
                    "${aws_sns_topic.#{@name}_lambda_cloudwatch_topic.arn}"
                  ]
  }

  subscription = resource :aws_sns_topic_subscription, "#{@name}_lambda_cloudwatch_subscription", {
           topic_arn: "${aws_sns_topic.#{@name}_lambda_cloudwatch_topic.arn}",
           protocol: @renew_alert_options[:protocol],
           endpoint: @renew_alert_options[:endpoint],
           endpoint_auto_confirms: @renew_alert_options[:endpoint_auto_confirms],
           confirmation_timeout_in_minutes: @renew_alert_options[:confirmation_timeout_in_minutes],
           raw_message_delivery: @renew_alert_options[:raw_message_delivery],
           filter_policy: @renew_alert_options[:filter_policy],
           delivery_policy: @renew_alert_options[:delivery_policy]
  }
  self
end
setup_providers() click to toggle source
# File lib/terrafying/components/letsencrypt.rb, line 26
def setup_providers
  {
    staging: {
      url: 'https://acme-staging-v02.api.letsencrypt.org/directory',
      ref: provider(:acme, alias: :staging, server_url: 'https://acme-staging-v02.api.letsencrypt.org/directory'),
      ca_cert: 'https://letsencrypt.org/certs/fakeleintermediatex1.pem'
    },
    live: {
      url: 'https://acme-v02.api.letsencrypt.org/directory',
      ref: provider(:acme, alias: :live, server_url: 'https://acme-v02.api.letsencrypt.org/directory'),
      ca_cert: 'https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem.txt'
    }
  }
end