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.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +250 -0
- data/Rakefile +6 -0
- data/acmesmith.gemspec +31 -0
- data/bin/acmesmith +4 -0
- data/config.sample.yml +18 -0
- data/lib/acmesmith.rb +5 -0
- data/lib/acmesmith/account_key.rb +53 -0
- data/lib/acmesmith/certificate.rb +98 -0
- data/lib/acmesmith/challenge_responders.rb +9 -0
- data/lib/acmesmith/challenge_responders/base.rb +20 -0
- data/lib/acmesmith/challenge_responders/route53.rb +134 -0
- data/lib/acmesmith/command.rb +130 -0
- data/lib/acmesmith/config.rb +59 -0
- data/lib/acmesmith/storages.rb +9 -0
- data/lib/acmesmith/storages/base.rb +39 -0
- data/lib/acmesmith/storages/filesystem.rb +86 -0
- data/lib/acmesmith/storages/s3.rb +176 -0
- data/lib/acmesmith/utils/finder.rb +26 -0
- data/lib/acmesmith/version.rb +3 -0
- data/script/console +14 -0
- data/script/setup +7 -0
- metadata +161 -0
@@ -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,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
|