acmesmith 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,9 @@
1
+ require 'acmesmith/utils/finder'
2
+
3
+ module Acmesmith
4
+ module ChallengeResponders
5
+ def self.find(name)
6
+ Utils::Finder.find(self, 'acmesmith/challenge_responders', name)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,20 @@
1
+ module Acmesmith
2
+ module ChallengeResponders
3
+ class Base
4
+ def support?(type)
5
+ raise NotImplementedError
6
+ end
7
+
8
+ def initialize()
9
+ end
10
+
11
+ def respond(challenge)
12
+ raise NotImplementedError
13
+ end
14
+
15
+ def cleanup(challenge)
16
+ raise NotImplementedError
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,134 @@
1
+ require 'acmesmith/challenge_responders/base'
2
+
3
+ require 'aws-sdk'
4
+
5
+ module Acmesmith
6
+ module ChallengeResponders
7
+ class Route53 < Base
8
+ class HostedZoneNotFound < StandardError; end
9
+ class AmbiguousHostedZones < StandardError; end
10
+
11
+ def support?(type)
12
+ # Acme::Client::Resources::Challenges::DNS01
13
+ type == 'dns-01'
14
+ end
15
+
16
+ def initialize(aws_access_key: nil, hosted_zone_map: {})
17
+ @route53 = Aws::Route53::Client.new({region: 'us-east-1'}.tap do |opt|
18
+ 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
19
+ end)
20
+ @hosted_zone_map = hosted_zone_map
21
+ @hosted_zone_cache = {}
22
+ end
23
+
24
+ def respond(domain, challenge)
25
+ puts "=> Responding challenge dns-01 for #{domain} in #{self.class.name}"
26
+
27
+ domain = canonical_fqdn(domain)
28
+ record_name = "#{challenge.record_name}.#{domain}"
29
+ record_type = challenge.record_type
30
+ record_content = "\"#{challenge.record_content}\""
31
+ zone_id = find_hosted_zone(domain)
32
+
33
+ puts " * UPSERT: #{record_type} #{record_name.inspect}, #{record_content.inspect} on #{zone_id}"
34
+ change_resp = @route53.change_resource_record_sets(
35
+ hosted_zone_id: zone_id, # required
36
+ change_batch: { # required
37
+ comment: "ACME challenge response",
38
+ changes: [
39
+ {
40
+ action: "UPSERT",
41
+ resource_record_set: { # required
42
+ name: record_name, # required
43
+ type: record_type,
44
+ ttl: 5,
45
+ resource_records: [
46
+ value: record_content
47
+ ],
48
+ },
49
+ },
50
+ ],
51
+ },
52
+ )
53
+
54
+ change_id = change_resp.change_info.id
55
+ puts " * requested change: #{change_id}"
56
+
57
+ puts "=> Waiting for change"
58
+ while (resp = @route53.get_change(id: change_id)).change_info.status != 'INSYNC'
59
+ puts " * change #{change_id.inspect} is still #{resp.change_info.status.inspect} ..."
60
+ sleep 5
61
+ end
62
+
63
+ puts " * synced!"
64
+ end
65
+
66
+ def cleanup(domain, challenge)
67
+ puts "=> Cleaning up challenge dns-01 for #{domain} in #{self.class.name}"
68
+
69
+ domain = canonical_fqdn(domain)
70
+ record_name = "#{challenge.record_name}.#{domain}"
71
+ record_type = challenge.record_type
72
+ record_content = "\"#{challenge.record_content}\""
73
+ zone_id = find_hosted_zone(domain)
74
+
75
+ puts " * DELETE: #{record_type} #{record_name.inspect}, #{record_content.inspect} on #{zone_id}"
76
+ change_resp = @route53.change_resource_record_sets(
77
+ hosted_zone_id: zone_id, # required
78
+ change_batch: { # required
79
+ comment: "ACME challenge response: cleanup",
80
+ changes: [
81
+ {
82
+ action: "DELETE", # required, accepts CREATE, DELETE, UPSERT
83
+ resource_record_set: { # required
84
+ name: record_name, # required
85
+ type: record_type,
86
+ ttl: 5,
87
+ resource_records: [
88
+ value: record_content
89
+ ],
90
+ },
91
+ },
92
+ ],
93
+ },
94
+ )
95
+
96
+ change_id = change_resp.change_info.id
97
+ puts " * requested: #{change_id}"
98
+ end
99
+
100
+ private
101
+
102
+ def canonical_fqdn(domain)
103
+ "#{domain}.".sub(/\.+$/, '')
104
+ end
105
+
106
+ def find_hosted_zone(domain)
107
+ labels = domain.split(?.)
108
+ zones = nil
109
+ 0.upto(labels.size-1).each do |i|
110
+ zones = hosted_zone_list["#{labels[i .. -1].join(?.)}."]
111
+ break if zones
112
+ end
113
+
114
+ raise HostedZoneNotFound, "hosted zone not found for #{domain.inspect}" unless zones
115
+ raise AmbiguousHostedZones, "multiple hosted zones found for #{domain.inspect}: #{zones.inspect}, set @hosted_zone_map to identify" if zones.size != 1
116
+ zones.first
117
+ end
118
+
119
+ def hosted_zone_map
120
+ @hosted_zone_map.map { |domain, zone_id|
121
+ [canonical_fqdn(domain), [zone_id]]
122
+ }.to_h
123
+ end
124
+
125
+ def hosted_zone_list
126
+ @hosted_zone_list ||= begin
127
+ @route53.list_hosted_zones.each.flat_map do |page|
128
+ page.hosted_zones.map { |zone| [zone.name, zone.id] }
129
+ end.group_by(&:first).map { |domain, kvs| [domain, kvs.map(&:last)] }.to_h.merge(hosted_zone_map)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,130 @@
1
+ require 'thor'
2
+
3
+ require 'acmesmith/config'
4
+ require 'acmesmith/account_key'
5
+ require 'acmesmith/certificate'
6
+ require 'acme/client'
7
+
8
+ module Acmesmith
9
+ class Command < Thor
10
+ class_option :config, default: './acmesmith.yml'
11
+
12
+ desc "register CONTACT", "Create account key (contact e.g. mailto:xxx@example.org)"
13
+ def register(contact)
14
+ key = AccountKey.generate
15
+ acme = Acme::Client.new(private_key: key.private_key, endpoint: config['endpoint'])
16
+ registration = acme.register(contact: contact)
17
+ registration.agree_terms
18
+
19
+ storage.put_account_key(key, config['account_key_passphrase'])
20
+ puts "Generated:\n#{key.private_key.public_key.to_pem}"
21
+ end
22
+
23
+ desc "authorize DOMAIN", "Get authz for DOMAIN."
24
+ def authorize(domain)
25
+ authz = acme.authorize(domain: domain)
26
+
27
+ challenges = [authz.http01, authz.dns01, authz.tls_sni01].compact
28
+
29
+ challenge = nil
30
+ responder = config.challenge_responders.find do |x|
31
+ challenge = challenges.find { |_| x.support?(_.class::CHALLENGE_TYPE) }
32
+ end
33
+
34
+ responder.respond(domain, challenge)
35
+
36
+ puts "=> Requesting verification..."
37
+ challenge.request_verification
38
+ loop do
39
+ status = challenge.verify_status
40
+ puts " * verify_status: #{status}"
41
+ break if status == 'valid'
42
+ sleep 3
43
+ end
44
+
45
+ responder.cleanup(domain, challenge)
46
+ puts "=> Done"
47
+ end
48
+
49
+ desc "request COMMON_NAME [SAN]", "request certificate for CN +COMMON_NAME+ with SANs +SAN+"
50
+ def request(common_name, *sans)
51
+ csr = Acme::Client::CertificateRequest.new(common_name: common_name, names: sans)
52
+ acme_cert = acme.new_certificate(csr)
53
+
54
+ cert = Certificate.from_acme_client_certificate(acme_cert)
55
+ storage.put_certificate(cert, config['certificate_key_passphrase'])
56
+
57
+ puts cert.certificate.to_text
58
+ puts cert.certificate.to_pem
59
+ end
60
+
61
+ desc "list [COMMON_NAME]", "list certificates or its versions"
62
+ def list(common_name = nil)
63
+ if common_name
64
+ puts storage.list_certificate_versions(common_name).sort
65
+ else
66
+ puts storage.list_certificates.sort
67
+ end
68
+ end
69
+
70
+ desc "current COMMON_NAME", "show current version for certificate"
71
+ def current(common_name)
72
+ puts storage.get_current_certificate_version(common_name)
73
+ end
74
+
75
+ desc "show-certificate COMMON_NAME", "show certificate"
76
+ method_option :version, type: :string, default: 'current'
77
+ method_option :type, type: :string, enum: %w(text certificate chain fullchain), default: 'text'
78
+ def show_certificate(common_name)
79
+ cert = storage.get_certificate(common_name, version: options[:version])
80
+
81
+ case options[:type]
82
+ when 'text'
83
+ puts cert.certificate.to_text
84
+ puts cert.certificate.to_pem
85
+ when 'certificate'
86
+ puts cert.certificate.to_pem
87
+ when 'chain'
88
+ puts cert.chain
89
+ when 'fullchain'
90
+ puts cert.fullchain
91
+ end
92
+ end
93
+ map 'show-certiticate' => :show_certificate
94
+
95
+ desc "show-private-key COMMON_NAME", "show private key"
96
+ method_option :version, type: :string, default: 'current'
97
+ def show_private_key(common_name)
98
+ cert = storage.get_certificate(common_name, version: options[:version])
99
+ cert.key_passphrase = config['certificate_key_passphrase'] if config['certificate_key_passphrase']
100
+
101
+ puts cert.private_key.to_pem
102
+ end
103
+ map 'show-private-key' => :show_private_key
104
+
105
+ # desc "autorenew", "request renewal of certificates which expires soon"
106
+ # method_option :days, alias: %w(-d), type: :integer, default: 7, desc: 'specify threshold in days to select certificates to renew'
107
+ # def autorenew
108
+ # end
109
+
110
+ private
111
+
112
+ def config
113
+ @config ||= Config.load_yaml(options[:config])
114
+ end
115
+
116
+ def storage
117
+ config.storage
118
+ end
119
+
120
+ def account_key
121
+ @account_key ||= storage.get_account_key.tap do |x|
122
+ x.key_passphrase = config['account_key_passphrase'] if config['account_key_passphrase']
123
+ end
124
+ end
125
+
126
+ def acme
127
+ @acme ||= Acme::Client.new(private_key: account_key.private_key, endpoint: config['endpoint'])
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,59 @@
1
+ require 'yaml'
2
+ require 'acmesmith/storages'
3
+ require 'acmesmith/challenge_responders'
4
+
5
+ module Acmesmith
6
+ class Config
7
+ def self.load_yaml(path)
8
+ new YAML.load_file(path)
9
+ end
10
+
11
+ def initialize(config)
12
+ @config = config
13
+ validate
14
+ end
15
+
16
+ def validate
17
+ unless @config['storage']
18
+ raise ArgumentError, "config['storage'] must be provided"
19
+ end
20
+
21
+ unless @config['endpoint']
22
+ raise ArgumentError, "config['endpoint'] must be provided, e.g. https://acme-v01.api.letsencrypt.org/ or https://acme-staging.api.letsencrypt.org/"
23
+ end
24
+ end
25
+
26
+ def [](key)
27
+ @config[key]
28
+ end
29
+
30
+ def account_key_passphrase
31
+ @config['account_key_passphrase']
32
+ end
33
+
34
+ def certificate_key_passphrase
35
+ @config['certificate_key_passphrase']
36
+ end
37
+
38
+ def storage
39
+ @storage ||= begin
40
+ c = @config['storage'].dup
41
+ Storages.find(c.delete('type')).new(**c.map{ |k,v| [k.to_sym, v]}.to_h)
42
+ end
43
+ end
44
+
45
+ def challenge_responders
46
+ @challange_responders ||= begin
47
+ specs = @config['challenge_responders'].kind_of?(Hash) ? @config['challenge_responders'].map { |k,v| [k => v] } : @config['challenge_responders']
48
+ specs.flat_map do |specs_sub|
49
+ specs_sub.map do |k, v|
50
+ ChallengeResponders.find(k).new(**v.map{ |k_,v_| [k_.to_sym, v_]}.to_h)
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ # def post_actions
57
+ # end
58
+ end
59
+ end
@@ -0,0 +1,9 @@
1
+ require 'acmesmith/utils/finder'
2
+
3
+ module Acmesmith
4
+ module Storages
5
+ def self.find(name)
6
+ Utils::Finder.find(self, 'acmesmith/storages', name)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,39 @@
1
+ module Acmesmith
2
+ module Storages
3
+ class Base
4
+ class NotExist < StandardError; end
5
+ class AlreadyExist < StandardError; end
6
+
7
+ def initialize()
8
+ end
9
+
10
+ def get_account_key
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def put_account_key(key, passphrase = nil)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def put_certificate(cert, passphrase = nil, update_current: true)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ def get_certificate(common_name, version: 'current')
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def list_certificates
27
+ raise NotImplementedError
28
+ end
29
+
30
+ def list_certificate_versions(common_name)
31
+ raise NotImplementedError
32
+ end
33
+
34
+ def get_current_certificate_version(common_name)
35
+ raise NotImplementedError
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,86 @@
1
+ require 'pathname'
2
+
3
+ require 'acmesmith/storages/base'
4
+ require 'acmesmith/account_key'
5
+ require 'acmesmith/certificate'
6
+
7
+ module Acmesmith
8
+ module Storages
9
+ class Filesystem < Base
10
+ def initialize(path:)
11
+ @path = Pathname(path)
12
+ end
13
+
14
+ attr_reader :path
15
+
16
+ def get_account_key
17
+ raise NotExist unless account_key_path.exist?
18
+ AccountKey.new account_key_path.read
19
+ end
20
+
21
+ def put_account_key(key, passphrase = nil)
22
+ raise AlreadyExist if account_key_path.exist?
23
+ File.write account_key_path.to_s, key.export(passphrase), 0, perm: 0600
24
+ end
25
+
26
+ def put_certificate(cert, passphrase = nil, update_current: true)
27
+ h = cert.export(passphrase)
28
+ certificate_base_path(cert.common_name, cert.version).mkpath
29
+ File.write certificate_path(cert.common_name, cert.version), "#{h[:certificate].rstrip}\n"
30
+ File.write chain_path(cert.common_name, cert.version), "#{h[:chain].rstrip}\n"
31
+ File.write fullchain_path(cert.common_name, cert.version), "#{h[:fullchain].rstrip}\n"
32
+ File.write private_key_path(cert.common_name, cert.version), "#{h[:private_key].rstrip}\n", 0, perm: 0600
33
+ if update_current
34
+ File.symlink(cert.version, certificate_base_path(cert.common_name, 'current.new'))
35
+ File.rename(certificate_base_path(cert.common_name, 'current.new'), certificate_base_path(cert.common_name, 'current'))
36
+ end
37
+ end
38
+
39
+ def get_certificate(common_name, version: 'current')
40
+ raise NotExist unless certificate_base_path(common_name, version).exist?
41
+ certificate = certificate_path(common_name, version).read
42
+ chain = chain_path(common_name, version).read
43
+ private_key = private_key_path(common_name, version).read
44
+ Certificate.new(certificate, chain, private_key)
45
+ end
46
+
47
+ def list_certificates
48
+ Dir[path.join('certs', '*').to_s].map { |_| File.basename(_) }
49
+ end
50
+
51
+ def list_certificate_versions(common_name)
52
+ Dir[path.join('certs', common_name, '*').to_s].map { |_| File.basename(_) }.reject { |_| _ == 'current' }
53
+ end
54
+
55
+ def get_current_certificate_version(common_name)
56
+ path.join('certs', common_name, 'current').readlink
57
+ end
58
+
59
+ private
60
+
61
+ def account_key_path
62
+ path.join('account.pem')
63
+ end
64
+
65
+ def certificate_base_path(cn, ver)
66
+ path.join('certs', cn, ver)
67
+ end
68
+
69
+ def certificate_path(cn, ver)
70
+ certificate_base_path(cn, ver).join('cert.pem')
71
+ end
72
+
73
+ def private_key_path(cn, ver)
74
+ certificate_base_path(cn, ver).join('key.pem')
75
+ end
76
+
77
+ def chain_path(cn, ver)
78
+ certificate_base_path(cn, ver).join('chain.pem')
79
+ end
80
+
81
+ def fullchain_path(cn, ver)
82
+ certificate_base_path(cn, ver).join('fullchain.pem')
83
+ end
84
+ end
85
+ end
86
+ end