acmesmith 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|