acmesmith 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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