├── .gitignore ├── .rspec ├── .rubocop.yml ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── certman.gemspec ├── exe └── certman ├── lib ├── certman.rb └── certman │ ├── cli.rb │ ├── client.rb │ ├── log.rb │ ├── resource │ ├── acm.rb │ ├── route53.rb │ ├── s3.rb │ ├── ses.rb │ └── sts.rb │ └── version.rb └── spec ├── certman_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.2 3 | 4 | Style/FrozenStringLiteralComment: 5 | Enabled: false 6 | 7 | Metrics/AbcSize: 8 | Max: 160 9 | 10 | Metrics/ClassLength: 11 | Max: 310 12 | 13 | Metrics/CyclomaticComplexity: 14 | Max: 10 15 | 16 | Metrics/MethodLength: 17 | Max: 240 18 | 19 | Metrics/PerceivedComplexity: 20 | Max: 10 21 | 22 | Metrics/BlockLength: 23 | Max: 50 24 | 25 | Metrics/LineLength: 26 | Max: 120 27 | 28 | Style/Documentation: 29 | Enabled: false 30 | 31 | Layout/IndentHeredoc: 32 | Enabled: false 33 | 34 | Style/MutableConstant: 35 | Enabled: false 36 | 37 | Style/PercentLiteralDelimiters: 38 | Enabled: false 39 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.4.2 5 | - 2.3.5 6 | - 2.2.8 7 | before_install: 8 | - gem install bundler -v 1.16 9 | script: 10 | - bundle exec rake 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in certman.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 k1LoW 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Certman [![Gem](https://img.shields.io/gem/v/certman.svg)](https://rubygems.org/gems/certman) [![Travis](https://img.shields.io/travis/k1LoW/certman.svg)](https://travis-ci.org/k1LoW/certman) 2 | 3 | CLI tool for AWS Certificate Manager. 4 | 5 | ## Installation 6 | 7 | Add this line to your application's Gemfile: 8 | 9 | ```ruby 10 | gem 'certman' 11 | ``` 12 | 13 | And then execute: 14 | 15 | ```sh 16 | $ bundle 17 | ``` 18 | 19 | Or install it yourself as: 20 | 21 | ```sh 22 | $ gem install certman 23 | ``` 24 | 25 | ## Usage 26 | 27 | ### Request ACM Certificate with only AWS managed services 28 | 29 | ```sh 30 | $ certman request blog.example.com 31 | NOTICE! Your selected region is *ap-northeast-1*. Certman will create a certificate on *ap-northeast-1*. OK? Yes 32 | NOTICE! Certman has chosen *us-east-1* for S3/SES resources. OK? Yes 33 | NOTICE! When requesting, Certman appends a Receipt Rule to the current Active Receipt Rule Set. OK? Yes 34 | [✔] [ACM] Check Certificate (us-east-1) (successful) 35 | [✔] [Route53] Check Hosted Zone (us-east-1) (successful) 36 | [✔] [Route53] Check TXT Record (us-east-1) (successful) 37 | [✔] [Route53] Check MX Record (us-east-1) (successful) 38 | [✔] [SES] Check Active Rule Set (us-east-1) (successful) 39 | [✔] [S3] Create Bucket for SES inbound (us-east-1) (successful) 40 | [✔] [SES] Create Domain Identity (us-east-1) (successful) 41 | [✔] [Route53] Create TXT Record Set to verify Domain Identity (us-east-1) (successful) 42 | [✔] [SES] Check Domain Identity Status *verified* (us-east-1) (successful) 43 | [✔] [Route53] Create MX Record Set (us-east-1) (successful) 44 | [✔] [SES] Create and Active Receipt Rule Set (us-east-1) (successful) 45 | [✔] [SES] Create Receipt Rule (us-east-1) (successful) 46 | [✔] [ACM] Request Certificate (us-east-1) (successful) 47 | [✔] [S3] Check approval mail (will take about 30 min) (us-east-1) (successful) 48 | [✔] [SES] Delete Receipt Rule (us-east-1) (successful) 49 | [✔] [SES] Delete Receipt Rule Set (us-east-1) (successful) 50 | [✔] [Route53] Delete MX Record Set (us-east-1) (successful) 51 | [✔] [Route53] Delete TXT Record Set (us-east-1) (successful) 52 | [✔] [SES] Delete Verified Domain Identiry (us-east-1) (successful) 53 | [✔] [S3] Delete Bucket (us-east-1) (successful) 54 | Done. 55 | 56 | certificate_arn: arn:aws:acm:ap-northeast-1:0123456789:certificate/123abcd4-5e67-8f90-123a-4567bc89d01 57 | ``` 58 | 59 | OR 60 | 61 | ```sh 62 | NOTICE! Your selected region is *us-east-1*. Certman will create a certificate on *us-east-1*. 63 | NOTICE! Certman has chosen *us-east-1* for S3/SES resources. 64 | NOTICE! When requesting, Certman appends a Receipt Rule to the current Active Receipt Rule Set. 65 | [✖] [ACM] Check Certificate (us-east-1) (error) 66 | 67 | Certificate already exists! 68 | 69 | certificate_arn: arn:aws:acm:us-east-1:0123456789:certificate/123abcd4-5e67-8f90-123a-4567bc89d01 70 | ``` 71 | 72 | #### Flags 73 | 74 | ##### `--remain-resources` 75 | Skips deleting resources after a certificate has been successfully generated. This is necessary if you cannot use automatic validation (i.e., if your site is not accessible to the public internet via HTTPS). See [How Manual Domain Validation Works](http://docs.aws.amazon.com/acm/latest/userguide/how-domain-validation-works.html) for more information. 76 | 77 | ##### `--non-interactive` 78 | Suppresses prompts from Certman (i.e, if using with a CI system, such as Travis or Jenkins). 79 | 80 | ##### `--subject-alternative-names=www.test.example.com cert.test.example.com` 81 | Other domain names (separated by spaces) to associate with the requested certificate. Note that only the primary domain name is used for identification purposes and that AWS initially limits each certifcate to 10 SANs. 82 | 83 | ##### `--hosted-zone=test.example.com` 84 | Specify the name (not the ID) of the Route53 Hosted Zone where the DNS record sets Certman uses will be located. By default, Certman will use the apex domain (i.e. "test.example.com" will have a default hosted-zone of "example.com"). 85 | 86 | ### Restore Resources 87 | 88 | If you want to restore resources generated for an ACM certificate (i.e., in order to receive approval mail again, use `certman restore-resources`. This supports the `--non-interactive` and `--hosted-zone` flags from `certman request`. 89 | 90 | ```sh 91 | $ certman restore-resources blog.example.com 92 | ``` 93 | 94 | ### Delete Certificate 95 | 96 | ```sh 97 | $ certman delete blog.example.com 98 | [✔] [ACM] Delete Certificate (successful) 99 | Done. 100 | 101 | ``` 102 | 103 | ## License 104 | 105 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 106 | 107 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec' 3 | require 'rspec/core' 4 | require 'rspec/core/rake_task' 5 | require 'octorelease' 6 | require 'rubocop/rake_task' 7 | RSpec::Core::RakeTask.new(:spec) 8 | RuboCop::RakeTask.new 9 | 10 | task default: %i(spec rubocop) 11 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'certman' 5 | 6 | require 'pry' 7 | Pry.start 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /certman.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | lib = File.expand_path('../lib', __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'certman/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'certman' 9 | spec.version = Certman::VERSION 10 | spec.authors = ['k1LoW'] 11 | spec.email = ['k1lowxb@gmail.com'] 12 | 13 | spec.summary = 'CLI tool for AWS Certificate Manager.' 14 | spec.description = 'CLI tool for AWS Certificate Manager.' 15 | spec.homepage = 'https://github.com/k1LoW/certman' 16 | spec.license = 'MIT' 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 19 | spec.bindir = 'exe' 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ['lib'] 22 | 23 | spec.required_ruby_version = '>= 2.2' 24 | spec.add_runtime_dependency 'aws-sdk', '< 2.9' 25 | spec.add_runtime_dependency 'awsecrets', '~> 1.8' 26 | spec.add_runtime_dependency 'thor' 27 | spec.add_runtime_dependency 'public_suffix' 28 | spec.add_runtime_dependency 'oga' 29 | spec.add_runtime_dependency 'tty-prompt' 30 | spec.add_runtime_dependency 'tty-spinner' 31 | spec.add_runtime_dependency 'pastel' 32 | spec.add_development_dependency 'bundler', '~> 1.12' 33 | spec.add_development_dependency 'rake', '~> 10.0' 34 | spec.add_development_dependency 'rspec', '~> 3.0' 35 | spec.add_development_dependency 'rubocop', '~> 0.49.0' 36 | spec.add_development_dependency 'octorelease' 37 | spec.add_development_dependency 'pry' 38 | end 39 | -------------------------------------------------------------------------------- /exe/certman: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'certman' 4 | 5 | Awsecrets.load 6 | 7 | Certman::CLI.start 8 | -------------------------------------------------------------------------------- /lib/certman.rb: -------------------------------------------------------------------------------- 1 | require 'awsecrets' 2 | require 'thor' 3 | require 'public_suffix' 4 | require 'oga' 5 | require 'net/http' 6 | require 'uri' 7 | require 'open-uri' 8 | require 'digest/sha1' 9 | require 'tty-prompt' 10 | require 'tty-spinner' 11 | require 'pastel' 12 | require 'certman/resource/sts' 13 | require 'certman/resource/s3' 14 | require 'certman/resource/ses' 15 | require 'certman/resource/route53' 16 | require 'certman/resource/acm' 17 | require 'certman/client' 18 | require 'certman/cli' 19 | require 'certman/log' 20 | require 'certman/version' 21 | 22 | module Certman 23 | end 24 | -------------------------------------------------------------------------------- /lib/certman/cli.rb: -------------------------------------------------------------------------------- 1 | module Certman 2 | class CLI < Thor 3 | desc 'request [DOMAIN]', 'Requests an ACM Certificate with only AWS managed services' 4 | option :remain_resources, type: :boolean, default: false 5 | option :non_interactive, type: :boolean, default: false 6 | option :subject_alternative_names, type: :array, banner: 'alt_domain_1 alt_domain_2...' 7 | option :hosted_zone, type: :string, banner: '' 8 | def request(domain) 9 | prompt = TTY::Prompt.new 10 | pastel = Pastel.new 11 | client = Certman::Client.new(domain, options) 12 | prompt_or_notify(client, prompt, pastel) 13 | rollback_on_interrupt(client, pastel) 14 | cert_arn = client.request 15 | 16 | puts 'Done.' 17 | puts '' 18 | puts "certificate_arn: #{pastel.cyan(cert_arn)}" 19 | puts '' 20 | end 21 | 22 | desc 'restore-resources [DOMAIN]', 'Restore resources to receive approval mail' 23 | option :non_interactive, type: :boolean, default: false 24 | option :hosted_zone, type: :string, banner: '' 25 | def restore_resources(domain) 26 | prompt = TTY::Prompt.new 27 | pastel = Pastel.new 28 | client = Certman::Client.new(domain, options) 29 | prompt_or_notify(client, prompt, pastel) 30 | rollback_on_interrupt(client, pastel) 31 | client.restore_resources 32 | 33 | puts 'Done.' 34 | puts '' 35 | end 36 | 37 | desc 'delete [DOMAIN]', 'Delete ACM Certificate' 38 | def delete(domain) 39 | Certman::Client.new(domain, options).delete 40 | 41 | puts 'Done.' 42 | puts '' 43 | end 44 | 45 | private 46 | 47 | def prompt_or_notify(client, prompt, pastel) 48 | notices = [ 49 | "NOTICE! Your selected region is *#{Aws.config[:region]}*. " \ 50 | "Certman will create a certificate on *#{Aws.config[:region]}*.", 51 | "NOTICE! Certman has chosen *#{client.region_by_hash}* for S3/SES resources.", 52 | 'NOTICE! When requesting, Certman appends a Receipt Rule to the current Active Receipt Rule Set.' 53 | ] 54 | 55 | notices.each do |message| 56 | if options[:non_interactive] 57 | puts pastel.red(message) 58 | else 59 | exit unless prompt.yes?(pastel.red(message << ' OK?')) 60 | end 61 | end 62 | end 63 | 64 | def rollback_on_interrupt(client, pastel) 65 | Signal.trap(:INT) do 66 | puts '' 67 | puts pastel.red('Rollback start.') 68 | client.rollback 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/certman/client.rb: -------------------------------------------------------------------------------- 1 | module Certman 2 | class Client 3 | include Certman::Resource::STS 4 | include Certman::Resource::S3 5 | include Certman::Resource::SES 6 | include Certman::Resource::Route53 7 | include Certman::Resource::ACM 8 | 9 | def initialize(domain, options) 10 | @do_rollback = false 11 | @cname_exists = false 12 | @domain = domain 13 | @subject_alternative_names = options[:subject_alternative_names] 14 | @cert_arn = nil 15 | @savepoint = [] 16 | @remain_resources = options[:remain_resources] 17 | @hosted_zone_domain = options[:hosted_zone] 18 | @hosted_zone_domain.sub(/\.\z/, '') if @hosted_zone_domain 19 | end 20 | 21 | def request 22 | check_resource 23 | 24 | enforce_region_by_hash do 25 | step('[S3] Create Bucket for SES inbound', :s3_bucket) do 26 | create_bucket 27 | end 28 | step('[SES] Create Domain Identity', :ses_domain_identity) do 29 | create_domain_identity 30 | end 31 | end 32 | 33 | step('[Route53] Create TXT Record Set to verify Domain Identity', :route53_txt) do 34 | create_txt_rset 35 | end 36 | 37 | enforce_region_by_hash do 38 | step('[SES] Check Domain Identity Status *verified*', nil) do 39 | check_domain_identity_verified 40 | end 41 | 42 | step('[Route53] Create MX Record Set', :route53_mx) do 43 | create_mx_rset 44 | end 45 | 46 | unless active_rule_set_exist? 47 | step('[SES] Create and Active Receipt Rule Set', :ses_rule_set) do 48 | create_and_active_rule_set 49 | end 50 | end 51 | 52 | step('[SES] Create Receipt Rule', :ses_rule) do 53 | create_rule 54 | end 55 | end 56 | 57 | step('[ACM] Request Certificate', :acm_certificate) do 58 | request_certificate 59 | end 60 | 61 | enforce_region_by_hash do 62 | step('[S3] Check for approval mail (can take up to 30 min)', nil) do 63 | check_approval_mail 64 | end 65 | end 66 | 67 | cleanup_resources if !@remain_resources || @do_rollback 68 | 69 | @cert_arn 70 | end 71 | 72 | def restore_resources 73 | check_resource(check_acm: false) 74 | 75 | enforce_region_by_hash do 76 | step('[S3] Create Bucket for SES inbound', :s3_bucket) do 77 | create_bucket 78 | end 79 | step('[SES] Create Domain Identity', :ses_domain_identity) do 80 | create_domain_identity 81 | end 82 | end 83 | 84 | step('[Route53] Create TXT Record Set to verify Domain Identity', :route53_txt) do 85 | create_txt_rset 86 | end 87 | 88 | enforce_region_by_hash do 89 | step('[SES] Check Domain Identity Status *verified*', nil) do 90 | check_domain_identity_verified 91 | end 92 | 93 | step('[Route53] Create MX Record Set', :route53_mx) do 94 | create_mx_rset 95 | end 96 | 97 | unless active_rule_set_exist? 98 | step('[SES] Create and Active Receipt Rule Set', :ses_rule_set) do 99 | create_and_active_rule_set 100 | end 101 | end 102 | 103 | step('[SES] Create Receipt Rule', :ses_rule) do 104 | create_rule 105 | end 106 | end 107 | 108 | cleanup_resources if @do_rollback 109 | end 110 | 111 | def delete 112 | s = spinner('[ACM] Delete Certificate') 113 | unless certificate_exist? 114 | s.error 115 | puts pastel.yellow("\nNo certificate to delete!\n") 116 | exit 117 | end 118 | delete_certificate 119 | s.success 120 | end 121 | 122 | def check_resource(check_acm: true) 123 | pastel = Pastel.new 124 | 125 | if check_acm 126 | s = spinner('[ACM] Check Certificate') 127 | if certificate_exist? 128 | s.error 129 | puts pastel.yellow("\nCertificate already exists!\n") 130 | puts "certificate_arn: #{pastel.cyan(@cert_arn)}" 131 | exit 132 | end 133 | s.success 134 | end 135 | 136 | s = spinner('[Route53] Check Hosted Zone') 137 | unless hosted_zone_exist? 138 | s.error 139 | puts pastel.red("\nHosted Zone #{hosted_zone_domain} does not exist") 140 | exit 141 | end 142 | s.success 143 | 144 | s = spinner('[Route53] Check TXT Record') 145 | if txt_rset_exist? 146 | s.error 147 | puts pastel.red("\n_amazonses.#{email_domain} TXT already exists") 148 | exit 149 | end 150 | s.success 151 | 152 | enforce_region_by_hash do 153 | s = spinner('[Route53] Check MX Record') 154 | if mx_rset_exist? 155 | s.error 156 | puts pastel.red("\n#{email_domain} MX already exist") 157 | exit 158 | end 159 | if cname_rset_exist? 160 | puts pastel.cyan("\n#{email_domain} CNAME already exists. Use #{hosted_zone_domain}") 161 | @cname_exists = true 162 | check_resource 163 | end 164 | s.success 165 | 166 | s = spinner('[SES] Check Active Rule Set') 167 | if active_rule_set_exist? 168 | puts pastel.cyan("\nActive Rule Set already exist. Use #{@current_active_rule_set_name}") 169 | end 170 | s.success 171 | end 172 | 173 | true 174 | end 175 | 176 | def rollback 177 | @do_rollback = true 178 | end 179 | 180 | private 181 | 182 | def enforce_region_by_hash 183 | region = Aws.config[:region] 184 | Aws.config[:region] = region_by_hash 185 | yield 186 | Aws.config[:region] = region 187 | end 188 | 189 | def step(message, save) 190 | return if @do_rollback 191 | s = spinner(message) 192 | begin 193 | yield 194 | @savepoint.push(save) 195 | s.success 196 | rescue => e 197 | pastel = Pastel.new 198 | puts '' 199 | puts pastel.red("Error: #{e.message}") 200 | @do_rollback = true 201 | s.error 202 | end 203 | end 204 | 205 | def cleanup_resources 206 | pastel = Pastel.new 207 | @savepoint.reverse.each do |state| 208 | case state 209 | when :s3_bucket 210 | enforce_region_by_hash do 211 | s = spinner('[S3] Delete Bucket') 212 | delete_bucket 213 | s.success 214 | end 215 | when :ses_domain_identity 216 | enforce_region_by_hash do 217 | s = spinner('[SES] Delete Verified Domain Identiry') 218 | delete_domain_identity 219 | s.success 220 | end 221 | when :route53_txt 222 | s = spinner('[Route53] Delete TXT Record Set') 223 | delete_txt_rset 224 | s.success 225 | when :route53_mx 226 | enforce_region_by_hash do 227 | s = spinner('[Route53] Delete MX Record Set') 228 | delete_mx_rset 229 | s.success 230 | end 231 | when :ses_rule_set 232 | enforce_region_by_hash do 233 | s = spinner('[SES] Delete Receipt Rule Set') 234 | if rule_exist? 235 | puts pastel.cyan("\nReceipt Rule exist. Can not delete Receipt Rule Set.") 236 | s.error 237 | else 238 | delete_rule_set 239 | s.success 240 | end 241 | end 242 | when :ses_rule 243 | enforce_region_by_hash do 244 | s = spinner('[SES] Delete Receipt Rule') 245 | delete_rule 246 | s.success 247 | end 248 | when :acm_certificate 249 | if @do_rollback 250 | delete # certificate 251 | end 252 | end 253 | end 254 | end 255 | 256 | def bucket_name 257 | @bucket_name ||= if "#{email_domain}-certman".length < 63 258 | "#{email_domain}-certman" 259 | else 260 | "#{Digest::SHA1.hexdigest(email_domain)}-certman" 261 | end 262 | end 263 | 264 | def hosted_zone_domain 265 | return @hosted_zone_domain if @hosted_zone_domain 266 | root_domain 267 | end 268 | 269 | def root_domain 270 | PublicSuffix.domain(@domain) 271 | end 272 | 273 | def email_domain 274 | return hosted_zone_domain if @cname_exists 275 | @domain.sub(/\A(www|\*)\./, '') 276 | end 277 | 278 | def validation_domain 279 | return hosted_zone_domain if @cname_exists 280 | @domain 281 | end 282 | 283 | def rule_name 284 | @rule_name ||= if "RuleCertman_#{email_domain}".length < 64 285 | "RuleCertman_#{email_domain}" 286 | else 287 | "RuleCertman_#{Digest::SHA1.hexdigest(email_domain)}" 288 | end 289 | end 290 | 291 | def rule_set_name 292 | @rule_set_name ||= @current_active_rule_set_name 293 | @rule_set_name ||= Certman::Resource::SES::RULE_SET_NAME_BY_CERTMAN 294 | end 295 | 296 | def spinner(message) 297 | Certman::Log.new(message) 298 | end 299 | end 300 | end 301 | -------------------------------------------------------------------------------- /lib/certman/log.rb: -------------------------------------------------------------------------------- 1 | module Certman 2 | class Log 3 | def initialize(message) 4 | @pastel = Pastel.new 5 | @s = TTY::Spinner.new("[:spinner] #{message} (#{Aws.config[:region]})", output: $stdout) 6 | @s.auto_spin 7 | end 8 | 9 | def success 10 | @s.success(@pastel.green('(successful)')) 11 | end 12 | 13 | def error 14 | @s.error(@pastel.red('(error)')) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/certman/resource/acm.rb: -------------------------------------------------------------------------------- 1 | module Certman 2 | module Resource 3 | module ACM 4 | def request_certificate 5 | res = acm.request_certificate( 6 | domain_name: @domain, 7 | subject_alternative_names: @subject_alternative_names, 8 | domain_validation_options: [ 9 | { 10 | domain_name: @domain, 11 | validation_domain: validation_domain 12 | } 13 | ] 14 | ) 15 | @cert_arn = res.certificate_arn 16 | end 17 | 18 | def resend_validation_email 19 | acm.resend_validation_email( 20 | certificate_arn: @cert_arn, 21 | domain: @domain, 22 | validation_domain: validation_domain 23 | ) 24 | end 25 | 26 | def delete_certificate 27 | acm.delete_certificate(certificate_arn: @cert_arn) 28 | @cert_arn = nil 29 | end 30 | 31 | def certificate_exist? 32 | current_cert = acm.list_certificates.certificate_summary_list.find do |cert| 33 | cert.domain_name == @domain 34 | end 35 | @cert_arn = current_cert.certificate_arn if current_cert 36 | end 37 | 38 | def acm 39 | @acm ||= Aws::ACM::Client.new 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/certman/resource/route53.rb: -------------------------------------------------------------------------------- 1 | module Certman 2 | module Resource 3 | # rubocop:disable Metrics/ModuleLength 4 | module Route53 5 | def create_txt_rset 6 | @hosted_zone = route53.list_hosted_zones.hosted_zones.find do |zone| 7 | zone.name == "#{hosted_zone_domain}." 8 | end 9 | route53.change_resource_record_sets( 10 | change_batch: { 11 | changes: [ 12 | { 13 | action: 'CREATE', 14 | resource_record_set: { 15 | name: "_amazonses.#{email_domain}", 16 | resource_records: [ 17 | { 18 | value: '"' + @token + '"' 19 | } 20 | ], 21 | ttl: 60, 22 | type: 'TXT' 23 | } 24 | } 25 | ], 26 | comment: 'Generate by certman' 27 | }, 28 | hosted_zone_id: @hosted_zone.id 29 | ) 30 | end 31 | 32 | def create_mx_rset 33 | route53.change_resource_record_sets( 34 | change_batch: { 35 | changes: [ 36 | { 37 | action: 'CREATE', 38 | resource_record_set: { 39 | name: email_domain, 40 | resource_records: [ 41 | { 42 | value: "10 inbound-smtp.#{Aws.config[:region]}.amazonaws.com" 43 | } 44 | ], 45 | ttl: 60, 46 | type: 'MX' 47 | } 48 | } 49 | ], 50 | comment: 'Generate by certman' 51 | }, 52 | hosted_zone_id: @hosted_zone.id 53 | ) 54 | end 55 | 56 | def delete_txt_rset 57 | route53.change_resource_record_sets( 58 | change_batch: { 59 | changes: [ 60 | { 61 | action: 'DELETE', 62 | resource_record_set: { 63 | name: "_amazonses.#{email_domain}", 64 | resource_records: [ 65 | { 66 | value: '"' + @token + '"' 67 | } 68 | ], 69 | ttl: 60, 70 | type: 'TXT' 71 | } 72 | } 73 | ], 74 | comment: 'Generate by certman' 75 | }, 76 | hosted_zone_id: @hosted_zone.id 77 | ) 78 | end 79 | 80 | def delete_mx_rset 81 | route53.change_resource_record_sets( 82 | change_batch: { 83 | changes: [ 84 | { 85 | action: 'DELETE', 86 | resource_record_set: { 87 | name: email_domain, 88 | resource_records: [ 89 | { 90 | value: "10 inbound-smtp.#{Aws.config[:region]}.amazonaws.com" 91 | } 92 | ], 93 | ttl: 60, 94 | type: 'MX' 95 | } 96 | } 97 | ], 98 | comment: 'Generate by certman' 99 | }, 100 | hosted_zone_id: @hosted_zone.id 101 | ) 102 | end 103 | 104 | def hosted_zone_exist? 105 | @hosted_zone_id = nil 106 | hosted_zone = route53.list_hosted_zones.hosted_zones.find do |zone| 107 | if zone.name == "#{hosted_zone_domain}." 108 | @hosted_zone_id = zone.id 109 | next true 110 | end 111 | end 112 | hosted_zone 113 | end 114 | 115 | def txt_rset_exist? 116 | res = route53.test_dns_answer( 117 | hosted_zone_id: @hosted_zone_id, 118 | record_name: "_amazonses.#{email_domain}.", 119 | record_type: 'TXT' 120 | ) 121 | !res.record_data.empty? 122 | end 123 | 124 | def mx_rset_exist? 125 | res = route53.test_dns_answer( 126 | hosted_zone_id: @hosted_zone_id, 127 | record_name: "#{email_domain}.", 128 | record_type: 'MX' 129 | ) 130 | !res.record_data.empty? 131 | end 132 | 133 | def cname_rset_exist? 134 | res = route53.test_dns_answer( 135 | hosted_zone_id: @hosted_zone_id, 136 | record_name: "#{email_domain}.", 137 | record_type: 'CNAME' 138 | ) 139 | !res.record_data.empty? 140 | end 141 | 142 | def route53 143 | @route53 ||= Aws::Route53::Client.new 144 | end 145 | end 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /lib/certman/resource/s3.rb: -------------------------------------------------------------------------------- 1 | module Certman 2 | module Resource 3 | module S3 4 | def create_bucket 5 | account_id = sts.get_caller_identity.account 6 | bucket_policy = <<-"EOF" 7 | { 8 | "Version": "2008-10-17", 9 | "Statement": [ 10 | { 11 | "Sid": "GiveSESPermissionToWriteEmail", 12 | "Effect": "Allow", 13 | "Principal": { 14 | "Service": [ 15 | "ses.amazonaws.com" 16 | ] 17 | }, 18 | "Action": [ 19 | "s3:PutObject" 20 | ], 21 | "Resource": "arn:aws:s3:::#{bucket_name}/*", 22 | "Condition": { 23 | "StringEquals": { 24 | "aws:Referer": "#{account_id}" 25 | } 26 | } 27 | } 28 | ] 29 | } 30 | EOF 31 | s3.create_bucket( 32 | acl: 'private', 33 | bucket: bucket_name 34 | ) 35 | s3.put_bucket_policy( 36 | bucket: bucket_name, 37 | policy: bucket_policy, 38 | use_accelerate_endpoint: false 39 | ) 40 | end 41 | 42 | def check_approval_mail 43 | is_break = false 44 | 30.times do 45 | sleep 60 46 | s3.list_objects(bucket: bucket_name).contents.map do |object| 47 | res = s3.get_object(bucket: bucket_name, key: object.key) 48 | res.body.read.match(%r{https://[^\s]*certificates\.amazon\.com/approvals[^\s]+}) do |md| 49 | cert_uri = md[0] 50 | handle = open(cert_uri) 51 | document = Oga.parse_html(handle) 52 | data = {} 53 | document.css('form input').each do |input| 54 | data[input.get('name')] = input.get('value') 55 | end 56 | post_uri = cert_uri.sub(/\?.*/, '') 57 | res = Net::HTTP.post_form(URI.parse(post_uri), data) 58 | raise 'Can not approve' unless res.body =~ /Success/ 59 | # success 60 | is_break = true 61 | break 62 | end 63 | end 64 | break if is_break 65 | break if @do_rollback 66 | resend_validation_email 67 | end 68 | raise 'Can not approve' unless is_break 69 | end 70 | 71 | def delete_bucket 72 | objects = s3.list_objects(bucket: bucket_name).contents.map do |object| 73 | { key: object.key } 74 | end 75 | unless objects.empty? 76 | s3.delete_objects( 77 | bucket: bucket_name, 78 | delete: { 79 | objects: objects 80 | } 81 | ) 82 | end 83 | s3.delete_bucket(bucket: bucket_name) 84 | end 85 | 86 | def s3 87 | @s3 ||= Aws::S3::Client.new 88 | end 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/certman/resource/ses.rb: -------------------------------------------------------------------------------- 1 | module Certman 2 | module Resource 3 | module SES 4 | REGIONS = %w(us-east-1 us-west-2 eu-west-1) 5 | RULE_SET_NAME_BY_CERTMAN = 'RuleSetByCertman' 6 | 7 | def region_by_hash 8 | key = Digest::SHA1.hexdigest(@domain).to_i(16) % REGIONS.length 9 | REGIONS[key] 10 | end 11 | 12 | def create_domain_identity 13 | res = ses.verify_domain_identity(domain: email_domain) 14 | @token = res.verification_token 15 | end 16 | 17 | def active_rule_set_exist? 18 | @current_active_rule_set_name = nil 19 | res = ses.describe_active_receipt_rule_set 20 | @current_active_rule_set_name = res.metadata.name if res.metadata 21 | end 22 | 23 | def check_domain_identity_verified 24 | is_break = false 25 | 100.times do 26 | res = ses.get_identity_verification_attributes( 27 | identities: [ 28 | email_domain 29 | ] 30 | ) 31 | if res.verification_attributes[email_domain].verification_status == 'Success' 32 | # success 33 | is_break = true 34 | break 35 | end 36 | break if @do_rollback 37 | sleep 5 38 | end 39 | raise 'Can not check verified' unless is_break 40 | end 41 | 42 | def delete_domain_identity 43 | ses.delete_identity(identity: email_domain) 44 | end 45 | 46 | def create_and_active_rule_set 47 | ses.create_receipt_rule_set(rule_set_name: rule_set_name) 48 | ses.set_active_receipt_rule_set(rule_set_name: rule_set_name) 49 | end 50 | 51 | def create_rule 52 | ses.create_receipt_rule( 53 | rule: { 54 | recipients: ["admin@#{email_domain}"], 55 | actions: [ 56 | { 57 | s3_action: { 58 | bucket_name: bucket_name 59 | } 60 | } 61 | ], 62 | enabled: true, 63 | name: rule_name, 64 | scan_enabled: true, 65 | tls_policy: 'Optional' 66 | }, 67 | rule_set_name: rule_set_name 68 | ) 69 | end 70 | 71 | def rule_exist? 72 | res = ses.describe_active_receipt_rule_set 73 | res.rules && !res.rules.empty? 74 | end 75 | 76 | def delete_rule_set 77 | res = ses.describe_active_receipt_rule_set 78 | return if res.rules && res.rules.length > 1 79 | ses.set_active_receipt_rule_set(rule_set_name: nil) 80 | ses.delete_receipt_rule_set(rule_set_name: rule_set_name) 81 | end 82 | 83 | def delete_rule 84 | ses.delete_receipt_rule( 85 | rule_name: rule_name, 86 | rule_set_name: rule_set_name 87 | ) 88 | end 89 | 90 | def ses 91 | @ses ||= Aws::SES::Client.new 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/certman/resource/sts.rb: -------------------------------------------------------------------------------- 1 | module Certman 2 | module Resource 3 | module STS 4 | def sts 5 | @sts ||= Aws::STS::Client.new 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/certman/version.rb: -------------------------------------------------------------------------------- 1 | module Certman 2 | VERSION = '0.10.0' 3 | end 4 | -------------------------------------------------------------------------------- /spec/certman_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # rubocop:disable Metrics/BlockLength 4 | describe Certman do 5 | it 'has a version number' do 6 | expect(Certman::VERSION).not_to be nil 7 | end 8 | 9 | context 'S3 bucket name' do 10 | it '3 =< FQDN.length < 54 generated by certman' do 11 | domain = '63.example.com' 12 | options = {} 13 | expected = '63.example.com-certman' 14 | expect(Certman::Client.new(domain, options).send(:bucket_name)).to eq expected 15 | end 16 | 17 | it '54 =< FQDN.length generated by certman' do 18 | domain = '0123456789012345678901234567890123456789012.example.com' 19 | options = {} 20 | expected = '7c0326349f8377cea67ea8752e9dcdb0c2442ac2-certman' 21 | expect(Certman::Client.new(domain, options).send(:bucket_name)).to eq expected 22 | end 23 | 24 | it '`www` subdomain support' do 25 | domain = 'www.63.example.com' 26 | options = {} 27 | expected = '63.example.com-certman' 28 | expect(Certman::Client.new(domain, options).send(:bucket_name)).to eq expected 29 | end 30 | 31 | it 'wildcard domain support' do 32 | domain = '*.63.example.com' 33 | options = {} 34 | expected = '63.example.com-certman' 35 | expect(Certman::Client.new(domain, options).send(:bucket_name)).to eq expected 36 | end 37 | end 38 | 39 | context 'SES rule name' do 40 | it '3 =< FQDN.length < 51 generated by certman' do 41 | domain = '64.example.com' 42 | options = {} 43 | expected = 'RuleCertman_64.example.com' 44 | expect(Certman::Client.new(domain, options).send(:rule_name)).to eq expected 45 | end 46 | 47 | it '51 =< FQDN.length generated by certman' do 48 | domain = '0123456789012345678901234567890123456789.example.com' 49 | options = {} 50 | expected = 'RuleCertman_ddb217d92c02b447e714a5af0f380d98c6e12cf4' 51 | expect(Certman::Client.new(domain, options).send(:rule_name)).to eq expected 52 | end 53 | 54 | it '`www` subdomain support' do 55 | domain = 'www.64.example.com' 56 | options = {} 57 | expected = 'RuleCertman_64.example.com' 58 | expect(Certman::Client.new(domain, options).send(:rule_name)).to eq expected 59 | end 60 | 61 | it 'wildcard domain support' do 62 | domain = '*.64.example.com' 63 | options = {} 64 | expected = 'RuleCertman_64.example.com' 65 | expect(Certman::Client.new(domain, options).send(:rule_name)).to eq expected 66 | end 67 | end 68 | 69 | context 'HostedZone' do 70 | it 'normal subdomain' do 71 | domain = 'dev.example.com' 72 | options = {} 73 | expected = 'example.com' 74 | expect(Certman::Client.new(domain, options).send(:hosted_zone_domain)).to eq expected 75 | end 76 | it '`www` subdomain support' do 77 | domain = 'www.example.com' 78 | options = {} 79 | expected = 'example.com' 80 | expect(Certman::Client.new(domain, options).send(:hosted_zone_domain)).to eq expected 81 | end 82 | it 'wildcard domain support' do 83 | domain = '*.example.com' 84 | options = {} 85 | expected = 'example.com' 86 | expect(Certman::Client.new(domain, options).send(:hosted_zone_domain)).to eq expected 87 | end 88 | it '`--hosted_zone` option' do 89 | domain = 'dev.example.com' 90 | options = { hosted_zone: 'dev.example.com' } 91 | expected = 'dev.example.com' 92 | expect(Certman::Client.new(domain, options).send(:hosted_zone_domain)).to eq expected 93 | end 94 | it 'subdomain with `--hosted_zone`' do 95 | domain = 'foo.dev.example.com' 96 | options = { hosted_zone: 'dev.example.com' } 97 | expected = 'dev.example.com' 98 | expect(Certman::Client.new(domain, options).send(:hosted_zone_domain)).to eq expected 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'certman' 3 | --------------------------------------------------------------------------------