acmesmith 2.3.1 → 2.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +2 -0
- data/.github/stale.yml +17 -0
- data/.github/workflows/build.yml +4 -4
- data/CHANGELOG.md +14 -0
- data/Gemfile.lock +47 -41
- data/README.md +26 -1
- data/acmesmith.gemspec +1 -1
- data/docs/challenge_responders/route53.md +13 -0
- data/docs/vendor/aws.md +7 -1
- data/lib/acmesmith/authorization_service.rb +24 -3
- data/lib/acmesmith/certificate_retrieving_service.rb +126 -0
- data/lib/acmesmith/challenge_responder_filter.rb +8 -13
- data/lib/acmesmith/challenge_responders/route53.rb +132 -6
- data/lib/acmesmith/client.rb +1 -0
- data/lib/acmesmith/config.rb +20 -0
- data/lib/acmesmith/domain_name_filter.rb +22 -0
- data/lib/acmesmith/ordering_service.rb +12 -3
- data/lib/acmesmith/storages/s3.rb +2 -1
- data/lib/acmesmith/version.rb +1 -1
- metadata +20 -10
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 18161ba6306c7d98accbbdfdec37599c67c8ba6e3d84a75e2ce52dc7cf2561b6
|
4
|
+
data.tar.gz: 4fdb29425b0e3a9c998bd86ad755c46dd01244936d718531309ebac17d57e4bb
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 292b84e68ccaa7e7e3f946cdd7e3caaf281155085cd1813e2e4903f8a9737c67b35e8dd8b46ba95fa023865b6e329cd3be923da54a48f6220148d0cfa086e719
|
7
|
+
data.tar.gz: 8c25c548b5c4dcc11afe50279773f9991f22f750b2ea23fbe7be5f38f8e7ece8d04dec7c2705d11c7ecaebdad7669c77fe1af9178f4bfda2d55f8ca4197167c6
|
data/.github/FUNDING.yml
ADDED
data/.github/stale.yml
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
# Number of days of inactivity before an issue becomes stale
|
2
|
+
daysUntilStale: 30
|
3
|
+
# Number of days of inactivity before a stale issue is closed
|
4
|
+
daysUntilClose: 7
|
5
|
+
# Issues with these labels will never be considered stale
|
6
|
+
exemptLabels:
|
7
|
+
- pinned
|
8
|
+
- security
|
9
|
+
# Label to use when marking an issue as stale
|
10
|
+
staleLabel: rotten
|
11
|
+
# Comment to post when marking an issue as stale. Set to `false` to disable
|
12
|
+
markComment: >
|
13
|
+
This issue has been automatically marked as stale because it has not had
|
14
|
+
recent activity. It will be closed if no further activity occurs. Thank you
|
15
|
+
for your contributions.
|
16
|
+
# Comment to post when closing a stale issue. Set to `false` to disable
|
17
|
+
closeComment: false
|
data/.github/workflows/build.yml
CHANGED
@@ -18,9 +18,9 @@ jobs:
|
|
18
18
|
strategy:
|
19
19
|
fail-fast: false
|
20
20
|
matrix:
|
21
|
-
ruby-version: ['2.
|
21
|
+
ruby-version: ['2.7', '3.0', '3.1']
|
22
22
|
container:
|
23
|
-
image: sorah/ruby:${{ matrix.ruby-version }}-dev
|
23
|
+
image: public.ecr.aws/sorah/ruby:${{ matrix.ruby-version }}-dev
|
24
24
|
steps:
|
25
25
|
|
26
26
|
- name: Cache bundled gems
|
@@ -40,7 +40,7 @@ jobs:
|
|
40
40
|
strategy:
|
41
41
|
fail-fast: false
|
42
42
|
matrix:
|
43
|
-
ruby-version: ['2.
|
43
|
+
ruby-version: ['2.7', '3.0', '3.1']
|
44
44
|
|
45
45
|
# FIXME: once GitHub Actions gains support of adding command line arguments to container
|
46
46
|
# services:
|
@@ -72,7 +72,7 @@ jobs:
|
|
72
72
|
|
73
73
|
- run: 'docker run -d --net=host --rm letsencrypt/pebble pebble -config /test/config/pebble-config.json -strict -dnsserver 127.0.0.1:8053'
|
74
74
|
- run: 'docker run -d --net=host --rm letsencrypt/pebble-challtestsrv pebble-challtestsrv -management :8055 -defaultIPv4 127.0.0.1'
|
75
|
-
- run: 'docker run --net=host -e CI --rm -v $(pwd):/work -v $(realpath ~/bundle):/bundle sorah/ruby:${{ matrix.ruby-version }}-dev sh -c "cd /work && bundle install --path /bundle && bundle exec rspec -fd -t integration_pebble"'
|
75
|
+
- run: 'docker run --net=host -e CI --rm -v $(pwd):/work -v $(realpath ~/bundle):/bundle public.ecr.aws/sorah/ruby:${{ matrix.ruby-version }}-dev sh -c "cd /work && bundle install --path /bundle && bundle exec rspec -fd -t integration_pebble"'
|
76
76
|
|
77
77
|
docker-build:
|
78
78
|
name: docker-build
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,17 @@
|
|
1
|
+
## v2.5.0 (2020-10-09)
|
2
|
+
|
3
|
+
### Enhancement
|
4
|
+
|
5
|
+
- Gains `chain_preferences` configuration to choose alternate chain. [#47](https://github.com/sorah/acmesmith/pull/47)
|
6
|
+
- route53: Gains `substitution_map` to allow delegation of `_acme-challenge` via predefined CNAME record. [#53](https://github.com/sorah/acmesmith/pull/53)
|
7
|
+
- s3: Gains `endpoint` option. [#52](https://github.com/sorah/acmesmith/pull/52)
|
8
|
+
|
9
|
+
## v2.4.0 (2020-12-03)
|
10
|
+
|
11
|
+
### Enhancement
|
12
|
+
|
13
|
+
- route53: Gains `restore_to_original_records` option. When enabled, existing record will be restored after authorizing domain names. Useful when other ACME tools or providers using ACME where requires a certain record to remain as long as possible for their renewal process (e.g. Fastly TLS).
|
14
|
+
|
1
15
|
## v2.3.1 (2020-05-12)
|
2
16
|
|
3
17
|
### Fixes
|
data/Gemfile.lock
CHANGED
@@ -1,8 +1,8 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
acmesmith (2.
|
5
|
-
acme-client (
|
4
|
+
acmesmith (2.5.0)
|
5
|
+
acme-client (>= 2.0.7, < 3)
|
6
6
|
aws-sdk-acm
|
7
7
|
aws-sdk-route53
|
8
8
|
aws-sdk-s3
|
@@ -11,53 +11,59 @@ PATH
|
|
11
11
|
GEM
|
12
12
|
remote: https://rubygems.org/
|
13
13
|
specs:
|
14
|
-
acme-client (2.0.
|
15
|
-
faraday (>= 0
|
16
|
-
|
17
|
-
aws-
|
18
|
-
aws-
|
19
|
-
|
14
|
+
acme-client (2.0.11)
|
15
|
+
faraday (>= 1.0, < 3.0.0)
|
16
|
+
faraday-retry (~> 1.0)
|
17
|
+
aws-eventstream (1.2.0)
|
18
|
+
aws-partitions (1.644.0)
|
19
|
+
aws-sdk-acm (1.52.0)
|
20
|
+
aws-sdk-core (~> 3, >= 3.127.0)
|
20
21
|
aws-sigv4 (~> 1.1)
|
21
|
-
aws-sdk-core (3.
|
22
|
+
aws-sdk-core (3.159.0)
|
22
23
|
aws-eventstream (~> 1, >= 1.0.2)
|
23
|
-
aws-partitions (~> 1, >= 1.
|
24
|
+
aws-partitions (~> 1, >= 1.525.0)
|
24
25
|
aws-sigv4 (~> 1.1)
|
25
|
-
jmespath (~> 1.
|
26
|
-
aws-sdk-kms (1.
|
27
|
-
aws-sdk-core (~> 3, >= 3.
|
26
|
+
jmespath (~> 1, >= 1.6.1)
|
27
|
+
aws-sdk-kms (1.58.0)
|
28
|
+
aws-sdk-core (~> 3, >= 3.127.0)
|
28
29
|
aws-sigv4 (~> 1.1)
|
29
|
-
aws-sdk-route53 (1.
|
30
|
-
aws-sdk-core (~> 3, >= 3.
|
30
|
+
aws-sdk-route53 (1.65.0)
|
31
|
+
aws-sdk-core (~> 3, >= 3.127.0)
|
31
32
|
aws-sigv4 (~> 1.1)
|
32
|
-
aws-sdk-s3 (1.
|
33
|
-
aws-sdk-core (~> 3, >= 3.
|
33
|
+
aws-sdk-s3 (1.114.0)
|
34
|
+
aws-sdk-core (~> 3, >= 3.127.0)
|
34
35
|
aws-sdk-kms (~> 1)
|
35
|
-
aws-sigv4 (~> 1.
|
36
|
-
aws-sigv4 (1.
|
37
|
-
aws-eventstream (~> 1
|
38
|
-
diff-lcs (1.
|
39
|
-
faraday (
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
rspec
|
52
|
-
rspec-
|
53
|
-
|
36
|
+
aws-sigv4 (~> 1.4)
|
37
|
+
aws-sigv4 (1.5.2)
|
38
|
+
aws-eventstream (~> 1, >= 1.0.2)
|
39
|
+
diff-lcs (1.4.4)
|
40
|
+
faraday (2.6.0)
|
41
|
+
faraday-net_http (>= 2.0, < 3.1)
|
42
|
+
ruby2_keywords (>= 0.0.4)
|
43
|
+
faraday-net_http (3.0.1)
|
44
|
+
faraday-retry (1.0.3)
|
45
|
+
jmespath (1.6.1)
|
46
|
+
mini_portile2 (2.8.0)
|
47
|
+
nokogiri (1.13.3)
|
48
|
+
mini_portile2 (~> 2.8.0)
|
49
|
+
racc (~> 1.4)
|
50
|
+
racc (1.6.0)
|
51
|
+
rake (13.0.6)
|
52
|
+
rspec (3.10.0)
|
53
|
+
rspec-core (~> 3.10.0)
|
54
|
+
rspec-expectations (~> 3.10.0)
|
55
|
+
rspec-mocks (~> 3.10.0)
|
56
|
+
rspec-core (3.10.1)
|
57
|
+
rspec-support (~> 3.10.0)
|
58
|
+
rspec-expectations (3.10.1)
|
54
59
|
diff-lcs (>= 1.2.0, < 2.0)
|
55
|
-
rspec-support (~> 3.
|
56
|
-
rspec-mocks (3.
|
60
|
+
rspec-support (~> 3.10.0)
|
61
|
+
rspec-mocks (3.10.2)
|
57
62
|
diff-lcs (>= 1.2.0, < 2.0)
|
58
|
-
rspec-support (~> 3.
|
59
|
-
rspec-support (3.
|
60
|
-
|
63
|
+
rspec-support (~> 3.10.0)
|
64
|
+
rspec-support (3.10.2)
|
65
|
+
ruby2_keywords (0.0.5)
|
66
|
+
thor (1.2.1)
|
61
67
|
|
62
68
|
PLATFORMS
|
63
69
|
ruby
|
data/README.md
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# Acmesmith: A simple, effective ACME v2 client to use with many servers and a cloud
|
2
2
|
|
3
|
-
![ci](https://github.com/sorah/acmesmith/workflows/ci/badge.svg?event=push)
|
3
|
+
![ci](https://github.com/sorah/acmesmith/workflows/ci/badge.svg?event=push) <a href='https://ko-fi.com/J3J8CKMUU' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://cdn.ko-fi.com/cdn/kofi3.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
|
4
|
+
|
4
5
|
|
5
6
|
Acmesmith is an [ACME (Automatic Certificate Management Environment)](https://github.com/ietf-wg-acme/acme) client that works perfect on environment with multiple servers. This client saves certificate and keys on cloud services (e.g. AWS S3) securely, then allow to deploy issued certificates onto your servers smoothly. This works well on [Let's encrypt](https://letsencrypt.org).
|
6
7
|
|
@@ -151,6 +152,30 @@ are configurable per certificate's common-name.
|
|
151
152
|
- Shell script: [shell](./docs/post_issuing_hooks/shell.md)
|
152
153
|
- Amazon Certificate Manager (ACM): [acm](./docs/post_issuing_hooks/acm.md)
|
153
154
|
|
155
|
+
### Chain preference
|
156
|
+
|
157
|
+
If you want to prefer an alternative chain given by CA ([RFC8555 Section 7.4.2.](https://datatracker.ietf.org/doc/html/rfc8555#section-7.4.2)), use the following configuration. Preference may be delcared with common name.
|
158
|
+
|
159
|
+
When chain preferences are configured for the common name of an ordered certificate, Acmesmith will retrieve all available alternative chains and evaluate rules in an configured order. The first chain matched to a rule will be used and saved to a storage.
|
160
|
+
|
161
|
+
During rule evaluation, a root issuer name and key id are taken from the last available intermediate (Issuer and AKI) provided in a chain, when a chain doesn't have a root certificate (trust anchor).
|
162
|
+
|
163
|
+
```yaml
|
164
|
+
chain_preferences:
|
165
|
+
- root_issuer_name: "ISRG Root X1"
|
166
|
+
### Optionally, you may specify CA SKI/AKI:
|
167
|
+
# root_issuer_key_id: "79:b4:59:e6:7b:b6:e5:e4:01:73:80:08:88:c8:1a:58:f6:e9:9b:6e"
|
168
|
+
|
169
|
+
### Filter by common name (optional)
|
170
|
+
filter:
|
171
|
+
exact:
|
172
|
+
- my-app.example.com
|
173
|
+
suffix:
|
174
|
+
- .example.org
|
175
|
+
regexp:
|
176
|
+
- '\Aapp\d+.example.org\z'
|
177
|
+
```
|
178
|
+
|
154
179
|
## Vendor dependent notes
|
155
180
|
|
156
181
|
- [./docs/vendor/aws.md](./docs/vendor/aws.md): IAM and KMS key policies, and some tips
|
data/acmesmith.gemspec
CHANGED
@@ -21,7 +21,7 @@ Acmesmith is an [ACME (Automatic Certificate Management Environment)](https://gi
|
|
21
21
|
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
22
22
|
spec.require_paths = ["lib"]
|
23
23
|
|
24
|
-
spec.add_dependency "acme-client", '
|
24
|
+
spec.add_dependency "acme-client", '>= 2.0.7', '< 3'
|
25
25
|
spec.add_dependency "aws-sdk-acm"
|
26
26
|
spec.add_dependency "aws-sdk-route53"
|
27
27
|
spec.add_dependency "aws-sdk-s3"
|
@@ -21,6 +21,19 @@ challenge_responders:
|
|
21
21
|
## Required when you have multiple hosted zones for the same domain name.
|
22
22
|
hosted_zone_map:
|
23
23
|
"example.org.": "/hostedzone/DEADBEEF"
|
24
|
+
|
25
|
+
# Restore to original records on cleanup (after domain authorization). Default to false.
|
26
|
+
# Useful when you need to keep existing record as long as possible.
|
27
|
+
restore_to_original_records: false
|
28
|
+
|
29
|
+
### Substitution record names map (optional)
|
30
|
+
## This specifies alias for specific _acme-challenge record. For instance the following example
|
31
|
+
## updates _acme-challenge.test-example-com.example.org instead of _acme-challenge.test.example.com.
|
32
|
+
##
|
33
|
+
## This eases using the route53 responder for domains not managed in route53, by registering CNAME record to
|
34
|
+
## the alias record name on the original record name in advance. This is called delegation.
|
35
|
+
substitution_map:
|
36
|
+
"test.example.com.": "test-example-com.example.org."
|
24
37
|
```
|
25
38
|
|
26
39
|
## IAM Policy
|
data/docs/vendor/aws.md
CHANGED
@@ -20,12 +20,18 @@
|
|
20
20
|
"Effect": "Allow",
|
21
21
|
"Action": "route53:ChangeResourceRecordSets",
|
22
22
|
"Resource": ["arn:aws:route53:::hostedzone/*"]
|
23
|
+
},
|
24
|
+
{
|
25
|
+
"Effect": "Allow",
|
26
|
+
"Action": "route53:ListResourceRecordSets",
|
27
|
+
"Resource": ["arn:aws:route53:::hostedzone/*"]
|
23
28
|
}
|
24
29
|
]
|
25
30
|
}
|
26
31
|
```
|
27
32
|
|
28
|
-
|
33
|
+
- You can limit allowed hosted zone by `Resource` of `route53:ChangeResourceRecordSets` grant
|
34
|
+
- `route53:ListResourceRecordSets` will be only required when `restore_to_original_records` is set
|
29
35
|
|
30
36
|
##### Only fetching certificates
|
31
37
|
|
@@ -69,9 +69,30 @@ module Acmesmith
|
|
69
69
|
puts "=> Requesting validations..."
|
70
70
|
puts
|
71
71
|
processes.each do |process|
|
72
|
-
|
73
|
-
process.challenge.
|
74
|
-
|
72
|
+
challenge = process.challenge
|
73
|
+
print " * #{process.domain} (#{challenge.challenge_type}) ..."
|
74
|
+
retried = false
|
75
|
+
begin
|
76
|
+
challenge.request_validation()
|
77
|
+
puts " [ ok ]"
|
78
|
+
rescue Acme::Client::Error::Malformed
|
79
|
+
# Rescue in case of requesting validation for a challenge which has already determined valid (asynchronously while we're receiving it).
|
80
|
+
# LE Boulder doesn't take this as an error, but pebble do.
|
81
|
+
# https://github.com/letsencrypt/boulder/blob/ebba443cad233111ee2b769ef09b32a13c3ba57e/wfe2/wfe.go#L1235
|
82
|
+
# https://github.com/letsencrypt/pebble/blob/b60b0b677c280ccbf63de55a26775591935c448b/wfe/wfe.go#L2166
|
83
|
+
challenge.reload
|
84
|
+
if process.valid?
|
85
|
+
puts " [ ok ] (turned valid in background)"
|
86
|
+
next
|
87
|
+
end
|
88
|
+
|
89
|
+
if retried
|
90
|
+
raise
|
91
|
+
else
|
92
|
+
retried = true
|
93
|
+
retry
|
94
|
+
end
|
95
|
+
end
|
75
96
|
end
|
76
97
|
puts
|
77
98
|
|
@@ -0,0 +1,126 @@
|
|
1
|
+
require 'acmesmith/certificate'
|
2
|
+
|
3
|
+
module Acmesmith
|
4
|
+
class CertificateRetrievingService
|
5
|
+
# @param acme [Acme::Client]
|
6
|
+
# @param common_name [String]
|
7
|
+
# @param url [String] ACME Certificate URL
|
8
|
+
# @param chain_preferences [Array<Acmesmith::Config::ChainPreference>]
|
9
|
+
def initialize(acme, common_name, url, chain_preferences: [])
|
10
|
+
@acme = acme
|
11
|
+
@url = url
|
12
|
+
@chain_preferences = chain_preferences.select { |_| _.filter.match?(common_name) }
|
13
|
+
end
|
14
|
+
|
15
|
+
attr_reader :acme
|
16
|
+
attr_reader :url
|
17
|
+
attr_reader :chain_preferences
|
18
|
+
|
19
|
+
def pem_chain
|
20
|
+
response = download(url, format: :pem)
|
21
|
+
pem = response.body
|
22
|
+
|
23
|
+
return pem if chain_preferences.empty?
|
24
|
+
|
25
|
+
puts " * Retrieving all chains..."
|
26
|
+
alternative_urls = Array(response.headers.dig('link', 'alternate'))
|
27
|
+
alternative_chains = alternative_urls.map { |_| CertificateChain.new(download(_, format: :pem).body) }
|
28
|
+
|
29
|
+
chains = [CertificateChain.new(pem), *alternative_chains]
|
30
|
+
|
31
|
+
chains.each_with_index do |chain, i|
|
32
|
+
puts " #{i.succ}. #{chain.to_s}"
|
33
|
+
end
|
34
|
+
puts
|
35
|
+
|
36
|
+
chain_preferences.each do |rule|
|
37
|
+
chains.each_with_index do |chain, i|
|
38
|
+
if chain.match?(name: rule.root_issuer_name, key_id: rule.root_issuer_key_id)
|
39
|
+
puts " * Chain chosen: ##{i.succ}"
|
40
|
+
return chain.pem_chain
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
warn " ! Preferred chain is not available, chain chosen: #1"
|
46
|
+
chains.first.pem_chain
|
47
|
+
end
|
48
|
+
|
49
|
+
class CertificateChain
|
50
|
+
def initialize(pem_chain)
|
51
|
+
@pem_chain = pem_chain
|
52
|
+
@pems = Certificate.split_pems(pem_chain)
|
53
|
+
@certificates = @pems.map { |_| OpenSSL::X509::Certificate.new(_) }
|
54
|
+
end
|
55
|
+
|
56
|
+
attr_reader :pem_chain
|
57
|
+
attr_reader :certificates
|
58
|
+
|
59
|
+
def to_s
|
60
|
+
certificates[1..-1].map do |c|
|
61
|
+
"s:#{c.subject},i:#{c.issuer}"
|
62
|
+
end.join(" | ")
|
63
|
+
end
|
64
|
+
|
65
|
+
def match?(name: nil, key_id: nil)
|
66
|
+
has_root = top.issuer == top.subject
|
67
|
+
|
68
|
+
if name
|
69
|
+
return false unless name == (has_root ? top.subject : top.issuer).to_a.assoc('CN')[1]
|
70
|
+
end
|
71
|
+
|
72
|
+
if key_id
|
73
|
+
top_key_id = if has_root
|
74
|
+
value_der(top.extensions.find { |e| e.oid == 'subjectKeyIdentifier' })&.slice(2..-1)
|
75
|
+
else
|
76
|
+
value_der(top.extensions.find { |e| e.oid == 'authorityKeyIdentifier' })&.slice(4,20)
|
77
|
+
end&.unpack1('H*')&.downcase
|
78
|
+
return false unless key_id.downcase.gsub(/:/,'') == top_key_id
|
79
|
+
end
|
80
|
+
|
81
|
+
true
|
82
|
+
end
|
83
|
+
|
84
|
+
def top
|
85
|
+
@top ||= find_top()
|
86
|
+
end
|
87
|
+
|
88
|
+
private def find_top
|
89
|
+
c = certificates.first
|
90
|
+
while c
|
91
|
+
up = find_issuer(c)
|
92
|
+
return c unless up
|
93
|
+
c = up
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
private def find_issuer(cert)
|
98
|
+
return nil if cert.issuer == cert.subject
|
99
|
+
|
100
|
+
# https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.1
|
101
|
+
# sequence(\x30\x16) context-specific(\x80\x14) + keyid
|
102
|
+
aki = value_der(cert.extensions.find { |e| e.oid == 'authorityKeyIdentifier' })
|
103
|
+
|
104
|
+
# compare using SKI as a AKI DER. this doesn't support AKI using other than keyid but it should be okay
|
105
|
+
certificates.find do |c|
|
106
|
+
ski_der = value_der(c.extensions.find { |e| e.oid == 'subjectKeyIdentifier' })
|
107
|
+
next unless ski_der
|
108
|
+
hdr = "\x30\x16\x80\x14".b
|
109
|
+
keyid = ski_der[2..-1]
|
110
|
+
|
111
|
+
"#{hdr}#{keyid}" == aki && cert.issuer == c.subject
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
private def value_der(ext)
|
116
|
+
return nil unless ext
|
117
|
+
ext.respond_to?(:value_der) ? ext.value_der : ext.to_der[9..-1]
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
private def download(url, format:)
|
122
|
+
# XXX: Use of private API https://github.com/unixcharles/acme-client/blob/5990b3105569a9d791ea011e0c5e57506eb54353/lib/acme/client.rb#L311
|
123
|
+
acme.__send__(:download, url, format: format)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -1,23 +1,18 @@
|
|
1
|
+
require 'acmesmith/domain_name_filter'
|
2
|
+
|
1
3
|
module Acmesmith
|
2
4
|
class ChallengeResponderFilter
|
3
5
|
def initialize(responder, subject_name_exact: nil, subject_name_suffix: nil, subject_name_regexp: nil)
|
4
6
|
@responder = responder
|
5
|
-
@
|
6
|
-
|
7
|
-
|
7
|
+
@domain_name_filter = DomainNameFilter.new(
|
8
|
+
exact: subject_name_exact,
|
9
|
+
suffix: subject_name_suffix,
|
10
|
+
regexp: subject_name_regexp,
|
11
|
+
)
|
8
12
|
end
|
9
13
|
|
10
14
|
def applicable?(domain)
|
11
|
-
|
12
|
-
return false unless @subject_name_exact.include?(domain)
|
13
|
-
end
|
14
|
-
if @subject_name_suffix
|
15
|
-
return false unless @subject_name_suffix.any? { |suffix| domain.end_with?(suffix) }
|
16
|
-
end
|
17
|
-
if @subject_name_regexp
|
18
|
-
return false unless @subject_name_regexp.any? { |regexp| domain.match?(regexp) }
|
19
|
-
end
|
20
|
-
@responder.applicable?(domain)
|
15
|
+
@domain_name_filter.match?(domain) && @responder.applicable?(domain)
|
21
16
|
end
|
22
17
|
end
|
23
18
|
end
|
@@ -17,7 +17,7 @@ module Acmesmith
|
|
17
17
|
true
|
18
18
|
end
|
19
19
|
|
20
|
-
def initialize(aws_access_key: nil, assume_role: nil, hosted_zone_map: {})
|
20
|
+
def initialize(aws_access_key: nil, assume_role: nil, hosted_zone_map: {}, restore_to_original_records: false, substitution_map: {})
|
21
21
|
aws_options = {region: 'us-east-1'}.tap do |opt|
|
22
22
|
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
|
23
23
|
end
|
@@ -34,13 +34,29 @@ module Acmesmith
|
|
34
34
|
|
35
35
|
@hosted_zone_map = hosted_zone_map
|
36
36
|
@hosted_zone_cache = {}
|
37
|
+
|
38
|
+
@restore_to_original_records = restore_to_original_records
|
39
|
+
@original_records = {}
|
40
|
+
|
41
|
+
@substitution_map = substitution_map.map { |k,v| [canonical_fqdn(k), v] }.to_h
|
37
42
|
end
|
38
43
|
|
39
44
|
def respond_all(*domain_and_challenges)
|
45
|
+
domain_and_challenges = apply_substitution_for_domain_and_challenges(domain_and_challenges)
|
46
|
+
|
47
|
+
save_original_records(*domain_and_challenges) if @restore_to_original_records
|
48
|
+
|
40
49
|
challenges_by_hosted_zone = domain_and_challenges.group_by { |(domain, _)| find_hosted_zone(domain) }
|
41
50
|
|
42
51
|
zone_and_batches = challenges_by_hosted_zone.map do |zone_id, dcs|
|
43
|
-
[
|
52
|
+
[
|
53
|
+
zone_id,
|
54
|
+
change_batch_for_challenges(
|
55
|
+
dcs,
|
56
|
+
action: 'UPSERT',
|
57
|
+
pre_changes: changes_to_delete_original_cname(zone_id, *dcs),
|
58
|
+
),
|
59
|
+
]
|
44
60
|
end
|
45
61
|
|
46
62
|
change_ids = request_changing_rrset(zone_and_batches, comment: 'for challenge response')
|
@@ -48,10 +64,20 @@ module Acmesmith
|
|
48
64
|
end
|
49
65
|
|
50
66
|
def cleanup_all(*domain_and_challenges)
|
67
|
+
domain_and_challenges = apply_substitution_for_domain_and_challenges(domain_and_challenges)
|
68
|
+
|
51
69
|
challenges_by_hosted_zone = domain_and_challenges.group_by { |(domain, _)| find_hosted_zone(domain) }
|
52
70
|
|
53
71
|
zone_and_batches = challenges_by_hosted_zone.map do |zone_id, dcs|
|
54
|
-
[
|
72
|
+
[
|
73
|
+
zone_id,
|
74
|
+
change_batch_for_challenges(
|
75
|
+
dcs,
|
76
|
+
action: 'DELETE',
|
77
|
+
comment: '(cleanup)',
|
78
|
+
post_changes: changes_to_restore_original_records(zone_id, *dcs),
|
79
|
+
),
|
80
|
+
]
|
55
81
|
end
|
56
82
|
|
57
83
|
request_changing_rrset(zone_and_batches, comment: 'to remove challenge responses')
|
@@ -59,6 +85,69 @@ module Acmesmith
|
|
59
85
|
|
60
86
|
private
|
61
87
|
|
88
|
+
def save_original_records(*domain_and_challenges)
|
89
|
+
domain_and_challenges.each do |domain, challenge|
|
90
|
+
|
91
|
+
hosted_zone_id = find_hosted_zone(domain)
|
92
|
+
name = "#{challenge.record_name}.#{domain}."
|
93
|
+
|
94
|
+
rrsets = list_existing_rrsets(hosted_zone_id, name)
|
95
|
+
next if rrsets.empty?
|
96
|
+
|
97
|
+
@original_records[hosted_zone_id] ||= {}
|
98
|
+
@original_records[hosted_zone_id][name] = rrsets
|
99
|
+
puts " * original_record: #{domain}(#{hosted_zone_id}): #{rrsets.inspect}"
|
100
|
+
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def changes_to_delete_original_cname(zone_id, *domain_and_challenges)
|
105
|
+
@original_records[zone_id] ||= {}
|
106
|
+
domain_and_challenges.map do |domain, challenge|
|
107
|
+
name = "#{challenge.record_name}.#{domain}."
|
108
|
+
original_records = @original_records[zone_id][name]
|
109
|
+
next unless original_records
|
110
|
+
original_cname = original_records.find{ |_| _.type == 'CNAME' }
|
111
|
+
next unless original_cname
|
112
|
+
|
113
|
+
# FIXME: support set_identifier?
|
114
|
+
{
|
115
|
+
action: 'DELETE',
|
116
|
+
resource_record_set: {
|
117
|
+
name: original_cname.name,
|
118
|
+
ttl: original_cname.ttl,
|
119
|
+
type: original_cname.type,
|
120
|
+
resource_records: original_cname.resource_records.map(&:to_h),
|
121
|
+
alias_target: original_cname.alias_target&.to_h,
|
122
|
+
},
|
123
|
+
}
|
124
|
+
end.compact
|
125
|
+
end
|
126
|
+
|
127
|
+
def changes_to_restore_original_records(zone_id, *domain_and_challenges)
|
128
|
+
@original_records[zone_id] ||= {}
|
129
|
+
domain_and_challenges.flat_map do |domain, challenge|
|
130
|
+
name = "#{challenge.record_name}.#{domain}."
|
131
|
+
original_records = @original_records[zone_id][name]
|
132
|
+
next unless original_records
|
133
|
+
|
134
|
+
# FIXME: support set_identifier?
|
135
|
+
original_records.map do |original_record|
|
136
|
+
next if original_record.type != challenge.record_type && original_record.type != 'CNAME'
|
137
|
+
{
|
138
|
+
action: 'CREATE',
|
139
|
+
resource_record_set: {
|
140
|
+
name: original_record.name,
|
141
|
+
ttl: original_record.ttl,
|
142
|
+
type: original_record.type,
|
143
|
+
resource_records: original_record.resource_records.map(&:to_h),
|
144
|
+
alias_target: original_record.alias_target&.to_h,
|
145
|
+
},
|
146
|
+
}
|
147
|
+
end
|
148
|
+
end.compact
|
149
|
+
end
|
150
|
+
|
62
151
|
def request_changing_rrset(zone_and_batches, comment: nil)
|
63
152
|
puts "=> Requesting RRSet change #{comment}"
|
64
153
|
puts
|
@@ -107,10 +196,9 @@ module Acmesmith
|
|
107
196
|
end
|
108
197
|
end
|
109
198
|
puts
|
110
|
-
|
111
199
|
end
|
112
200
|
|
113
|
-
def change_batch_for_challenges(domain_and_challenges, comment: nil, action: 'UPSERT')
|
201
|
+
def change_batch_for_challenges(domain_and_challenges, comment: nil, action: 'UPSERT', pre_changes: [], post_changes: [])
|
114
202
|
changes = domain_and_challenges
|
115
203
|
.map do |d, c|
|
116
204
|
rrset_for_challenge(d, c)
|
@@ -133,7 +221,7 @@ module Acmesmith
|
|
133
221
|
|
134
222
|
{
|
135
223
|
comment: "ACME challenge response #{comment}",
|
136
|
-
changes: changes,
|
224
|
+
changes: pre_changes + changes + post_changes,
|
137
225
|
}
|
138
226
|
end
|
139
227
|
|
@@ -181,6 +269,44 @@ module Acmesmith
|
|
181
269
|
end.group_by(&:first).map { |domain, kvs| [domain, kvs.map(&:last)] }.to_h.merge(hosted_zone_map)
|
182
270
|
end
|
183
271
|
end
|
272
|
+
|
273
|
+
def apply_substitution_for_domain_and_challenges(domain_and_challenges)
|
274
|
+
domain_and_challenges.map { |(domain, challenge)| [@substitution_map.fetch(canonical_fqdn(domain), domain), challenge] }
|
275
|
+
end
|
276
|
+
|
277
|
+
def list_existing_rrsets(hosted_zone_id, name)
|
278
|
+
rrsets = []
|
279
|
+
start_record_name = name
|
280
|
+
start_record_type = nil
|
281
|
+
start_record_identifier = nil
|
282
|
+
|
283
|
+
while start_record_name == name
|
284
|
+
begin
|
285
|
+
tries = 0
|
286
|
+
page = @route53.list_resource_record_sets(
|
287
|
+
hosted_zone_id: hosted_zone_id,
|
288
|
+
start_record_name: start_record_name,
|
289
|
+
start_record_type: start_record_type,
|
290
|
+
start_record_identifier: start_record_identifier,
|
291
|
+
max_items: 10,
|
292
|
+
)
|
293
|
+
page.resource_record_sets.each do |rrset|
|
294
|
+
rrsets << rrset if rrset.name == name
|
295
|
+
end
|
296
|
+
|
297
|
+
start_record_name = page.next_record_name
|
298
|
+
start_record_type = page.next_record_type
|
299
|
+
start_record_identifier = page.next_record_identifier
|
300
|
+
rescue Aws::Route53::Errors::Throttling => e
|
301
|
+
interval = (2**tries) * 0.1
|
302
|
+
$stderr.puts " ! #{e.class}: Sleeping #{interval} seconds (#{e.message})"
|
303
|
+
sleep interval
|
304
|
+
tries += 1
|
305
|
+
retry
|
306
|
+
end
|
307
|
+
end
|
308
|
+
rrsets
|
309
|
+
end
|
184
310
|
end
|
185
311
|
end
|
186
312
|
end
|
data/lib/acmesmith/client.rb
CHANGED
data/lib/acmesmith/config.rb
CHANGED
@@ -2,11 +2,13 @@ require 'yaml'
|
|
2
2
|
require 'acmesmith/storages'
|
3
3
|
require 'acmesmith/challenge_responders'
|
4
4
|
require 'acmesmith/challenge_responder_filter'
|
5
|
+
require 'acmesmith/domain_name_filter'
|
5
6
|
require 'acmesmith/post_issuing_hooks'
|
6
7
|
|
7
8
|
module Acmesmith
|
8
9
|
class Config
|
9
10
|
ChallengeResponderRule = Struct.new(:challenge_responder, :filter, keyword_init: true)
|
11
|
+
ChainPreference = Struct.new(:root_issuer_name, :root_issuer_key_id, :filter, keyword_init: true)
|
10
12
|
|
11
13
|
def self.load_yaml(path)
|
12
14
|
new YAML.load_file(path)
|
@@ -29,6 +31,10 @@ module Acmesmith
|
|
29
31
|
unless @config['directory']
|
30
32
|
raise ArgumentError, "config['directory'] must be provided, e.g. https://acme-v02.api.letsencrypt.org/directory or https://acme-staging-v02.api.letsencrypt.org/directory"
|
31
33
|
end
|
34
|
+
|
35
|
+
if @config.key?('chain_preferences') && !@config.fetch('chain_preferences').kind_of?(Array)
|
36
|
+
raise ArgumentError, "config['chain_preferences'] must be an Array"
|
37
|
+
end
|
32
38
|
end
|
33
39
|
|
34
40
|
def [](key)
|
@@ -104,6 +110,20 @@ module Acmesmith
|
|
104
110
|
end
|
105
111
|
end
|
106
112
|
|
113
|
+
def chain_preferences
|
114
|
+
@preferred_chains ||= begin
|
115
|
+
specs = @config['chain_preferences'] || []
|
116
|
+
specs.flat_map do |spec|
|
117
|
+
filter = spec.fetch('filter', {}).map { |k,v| [k.to_sym, v] }.to_h
|
118
|
+
ChainPreference.new(
|
119
|
+
root_issuer_name: spec['root_issuer_name'],
|
120
|
+
root_issuer_key_id: spec['root_issuer_key_id'],
|
121
|
+
filter: DomainNameFilter.new(**filter),
|
122
|
+
)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
107
127
|
# def post_actions
|
108
128
|
# end
|
109
129
|
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Acmesmith
|
2
|
+
class DomainNameFilter
|
3
|
+
def initialize(exact: nil, suffix: nil, regexp: nil)
|
4
|
+
@exact = exact && [*exact].flatten.compact
|
5
|
+
@suffix = suffix && [*suffix].flatten.compact
|
6
|
+
@regexp = regexp && [*regexp].flatten.compact.map{ |_| Regexp.new(_) }
|
7
|
+
end
|
8
|
+
|
9
|
+
def match?(domain)
|
10
|
+
if @exact
|
11
|
+
return false unless @exact.include?(domain)
|
12
|
+
end
|
13
|
+
if @suffix
|
14
|
+
return false unless @suffix.any? { |suffix| domain.end_with?(suffix) }
|
15
|
+
end
|
16
|
+
if @regexp
|
17
|
+
return false unless @regexp.any? { |regexp| domain.match?(regexp) }
|
18
|
+
end
|
19
|
+
true
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -1,5 +1,6 @@
|
|
1
1
|
require 'acmesmith/authorization_service'
|
2
2
|
require 'acmesmith/certificate'
|
3
|
+
require 'acmesmith/certificate_retrieving_service'
|
3
4
|
|
4
5
|
module Acmesmith
|
5
6
|
class OrderingService
|
@@ -8,17 +9,19 @@ module Acmesmith
|
|
8
9
|
# @param acme [Acme::Client] ACME client
|
9
10
|
# @param identifiers [Array<String>] Array of domain names for a ordering certificate. The first item will be a common name.
|
10
11
|
# @param challenge_responder_rules [Array<Acmesmith::Config::ChallengeResponderRule>] responders
|
12
|
+
# @param chain_preferences [Array<Acmesmith::Config::ChainPreference>] chain_preferences
|
11
13
|
# @param not_before [Time]
|
12
14
|
# @param not_after [Time]
|
13
|
-
def initialize(acme:, identifiers:, challenge_responder_rules:, not_before: nil, not_after: nil)
|
15
|
+
def initialize(acme:, identifiers:, challenge_responder_rules:, chain_preferences:, not_before: nil, not_after: nil)
|
14
16
|
@acme = acme
|
15
17
|
@identifiers = identifiers
|
16
18
|
@challenge_responder_rules = challenge_responder_rules
|
19
|
+
@chain_preferences = chain_preferences
|
17
20
|
@not_before = not_before
|
18
21
|
@not_after = not_after
|
19
22
|
end
|
20
23
|
|
21
|
-
attr_reader :acme, :identifiers, :challenge_responder_rules, :not_before, :not_after
|
24
|
+
attr_reader :acme, :identifiers, :challenge_responder_rules, :chain_preferences, :not_before, :not_after
|
22
25
|
|
23
26
|
def perform!
|
24
27
|
puts "=> Ordering a certificate for the following identifiers:"
|
@@ -38,7 +41,7 @@ module Acmesmith
|
|
38
41
|
finalize_order()
|
39
42
|
wait_order_for_complete()
|
40
43
|
|
41
|
-
@certificate = Certificate.by_issuance(
|
44
|
+
@certificate = Certificate.by_issuance(pem_chain, csr)
|
42
45
|
|
43
46
|
puts
|
44
47
|
puts "=> Certificate issued"
|
@@ -77,6 +80,12 @@ module Acmesmith
|
|
77
80
|
end
|
78
81
|
end
|
79
82
|
|
83
|
+
# @return String
|
84
|
+
def pem_chain
|
85
|
+
url = order.certificate_url or raise NotCompleted, "not completed yet"
|
86
|
+
CertificateRetrievingService.new(acme, common_name, url, chain_preferences: chain_preferences).pem_chain
|
87
|
+
end
|
88
|
+
|
80
89
|
def certificate
|
81
90
|
@certificate or raise NotCompleted, "not completed yet"
|
82
91
|
end
|
@@ -7,7 +7,7 @@ require 'acmesmith/certificate'
|
|
7
7
|
module Acmesmith
|
8
8
|
module Storages
|
9
9
|
class S3 < Base
|
10
|
-
def initialize(aws_access_key: nil, bucket:, prefix: nil, region:, use_kms: true, kms_key_id: nil, kms_key_id_account: nil, kms_key_id_certificate_key: nil, pkcs12_passphrase: nil, pkcs12_common_names: nil)
|
10
|
+
def initialize(aws_access_key: nil, bucket:, prefix: nil, region:, use_kms: true, kms_key_id: nil, kms_key_id_account: nil, kms_key_id_certificate_key: nil, pkcs12_passphrase: nil, pkcs12_common_names: nil, endpoint: nil)
|
11
11
|
@region = region
|
12
12
|
@bucket = bucket
|
13
13
|
@prefix = prefix
|
@@ -25,6 +25,7 @@ module Acmesmith
|
|
25
25
|
|
26
26
|
@s3 = Aws::S3::Client.new({region: region}.tap do |opt|
|
27
27
|
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
|
28
|
+
opt[:endpoint] = endpoint if endpoint
|
28
29
|
end)
|
29
30
|
end
|
30
31
|
|
data/lib/acmesmith/version.rb
CHANGED
metadata
CHANGED
@@ -1,29 +1,35 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: acmesmith
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sorah Fukumori
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2022-10-08 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: acme-client
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
16
16
|
requirements:
|
17
|
-
- - "
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 2.0.7
|
20
|
+
- - "<"
|
18
21
|
- !ruby/object:Gem::Version
|
19
|
-
version: '
|
22
|
+
version: '3'
|
20
23
|
type: :runtime
|
21
24
|
prerelease: false
|
22
25
|
version_requirements: !ruby/object:Gem::Requirement
|
23
26
|
requirements:
|
24
|
-
- - "
|
27
|
+
- - ">="
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: 2.0.7
|
30
|
+
- - "<"
|
25
31
|
- !ruby/object:Gem::Version
|
26
|
-
version: '
|
32
|
+
version: '3'
|
27
33
|
- !ruby/object:Gem::Dependency
|
28
34
|
name: aws-sdk-acm
|
29
35
|
requirement: !ruby/object:Gem::Requirement
|
@@ -136,6 +142,8 @@ extensions: []
|
|
136
142
|
extra_rdoc_files: []
|
137
143
|
files:
|
138
144
|
- ".dockerignore"
|
145
|
+
- ".github/FUNDING.yml"
|
146
|
+
- ".github/stale.yml"
|
139
147
|
- ".github/workflows/build.yml"
|
140
148
|
- ".gitignore"
|
141
149
|
- ".rspec"
|
@@ -161,6 +169,7 @@ files:
|
|
161
169
|
- lib/acmesmith/account_key.rb
|
162
170
|
- lib/acmesmith/authorization_service.rb
|
163
171
|
- lib/acmesmith/certificate.rb
|
172
|
+
- lib/acmesmith/certificate_retrieving_service.rb
|
164
173
|
- lib/acmesmith/challenge_responder_filter.rb
|
165
174
|
- lib/acmesmith/challenge_responders.rb
|
166
175
|
- lib/acmesmith/challenge_responders/base.rb
|
@@ -170,6 +179,7 @@ files:
|
|
170
179
|
- lib/acmesmith/client.rb
|
171
180
|
- lib/acmesmith/command.rb
|
172
181
|
- lib/acmesmith/config.rb
|
182
|
+
- lib/acmesmith/domain_name_filter.rb
|
173
183
|
- lib/acmesmith/ordering_service.rb
|
174
184
|
- lib/acmesmith/post_issueing_hooks.rb
|
175
185
|
- lib/acmesmith/post_issueing_hooks/base.rb
|
@@ -190,7 +200,7 @@ homepage: https://github.com/sorah/acmesmith
|
|
190
200
|
licenses:
|
191
201
|
- MIT
|
192
202
|
metadata: {}
|
193
|
-
post_install_message:
|
203
|
+
post_install_message:
|
194
204
|
rdoc_options: []
|
195
205
|
require_paths:
|
196
206
|
- lib
|
@@ -205,8 +215,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
205
215
|
- !ruby/object:Gem::Version
|
206
216
|
version: '0'
|
207
217
|
requirements: []
|
208
|
-
rubygems_version: 3.
|
209
|
-
signing_key:
|
218
|
+
rubygems_version: 3.4.0.dev
|
219
|
+
signing_key:
|
210
220
|
specification_version: 4
|
211
221
|
summary: ACME client (Let's encrypt client) to manage certificate in multi server
|
212
222
|
environment with cloud services (e.g. AWS)
|