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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c2ccb5560fae2d63385e8460268f5c1fa4ca9e7fa96abb34874fbc2225c4f85
4
- data.tar.gz: 58f74cdbdbb476db11fe8bdde5f6cd8b60e6f05845c47f6293147fb644e29b3c
3
+ metadata.gz: 18161ba6306c7d98accbbdfdec37599c67c8ba6e3d84a75e2ce52dc7cf2561b6
4
+ data.tar.gz: 4fdb29425b0e3a9c998bd86ad755c46dd01244936d718531309ebac17d57e4bb
5
5
  SHA512:
6
- metadata.gz: 49099fbee8ea178501cd45fd468c5554c3dfc938ff9d86ff1a5dcbbe3e8385444486ca46bffdadea7f1f70f89e7d5df69ef4191bbd6f6de4de2c94d9ec73c8cf
7
- data.tar.gz: f841483c5c224e73a6e6b95ca7317dc481642b7786899ad15c31c2c97dfae649fc1cf0133ea7ac3afe8b002d10230c73235c77d90eca85c2103789fc5aed3e7d
6
+ metadata.gz: 292b84e68ccaa7e7e3f946cdd7e3caaf281155085cd1813e2e4903f8a9737c67b35e8dd8b46ba95fa023865b6e329cd3be923da54a48f6220148d0cfa086e719
7
+ data.tar.gz: 8c25c548b5c4dcc11afe50279773f9991f22f750b2ea23fbe7be5f38f8e7ece8d04dec7c2705d11c7ecaebdad7669c77fe1af9178f4bfda2d55f8ca4197167c6
@@ -0,0 +1,2 @@
1
+ ko_fi: sorah
2
+ github: [sorah]
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
@@ -18,9 +18,9 @@ jobs:
18
18
  strategy:
19
19
  fail-fast: false
20
20
  matrix:
21
- ruby-version: ['2.6', '2.7']
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.6', '2.7']
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.3.1)
5
- acme-client (~> 2)
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.6)
15
- faraday (>= 0.17, < 2.0.0)
16
- aws-eventstream (1.1.0)
17
- aws-partitions (1.312.0)
18
- aws-sdk-acm (1.30.0)
19
- aws-sdk-core (~> 3, >= 3.71.0)
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.95.0)
22
+ aws-sdk-core (3.159.0)
22
23
  aws-eventstream (~> 1, >= 1.0.2)
23
- aws-partitions (~> 1, >= 1.239.0)
24
+ aws-partitions (~> 1, >= 1.525.0)
24
25
  aws-sigv4 (~> 1.1)
25
- jmespath (~> 1.0)
26
- aws-sdk-kms (1.31.0)
27
- aws-sdk-core (~> 3, >= 3.71.0)
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.34.0)
30
- aws-sdk-core (~> 3, >= 3.71.0)
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.64.0)
33
- aws-sdk-core (~> 3, >= 3.83.0)
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.1)
36
- aws-sigv4 (1.1.3)
37
- aws-eventstream (~> 1.0, >= 1.0.2)
38
- diff-lcs (1.3)
39
- faraday (1.0.1)
40
- multipart-post (>= 1.2, < 3)
41
- jmespath (1.4.0)
42
- mini_portile2 (2.4.0)
43
- multipart-post (2.1.1)
44
- nokogiri (1.10.9)
45
- mini_portile2 (~> 2.4.0)
46
- rake (13.0.1)
47
- rspec (3.9.0)
48
- rspec-core (~> 3.9.0)
49
- rspec-expectations (~> 3.9.0)
50
- rspec-mocks (~> 3.9.0)
51
- rspec-core (3.9.2)
52
- rspec-support (~> 3.9.3)
53
- rspec-expectations (3.9.2)
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.9.0)
56
- rspec-mocks (3.9.1)
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.9.0)
59
- rspec-support (3.9.3)
60
- thor (1.0.1)
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", '~> 2'
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
- Note: You can limit allowed hosted zone by modifying `Resource` of `route53:ChangeResourceRecordSets`
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
- print " * #{process.domain} (#{process.challenge.challenge_type}) ..."
73
- process.challenge.request_validation()
74
- puts " [ ok ]"
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
- @subject_name_exact = subject_name_exact && [*subject_name_exact].flatten.compact
6
- @subject_name_suffix = subject_name_suffix && [*subject_name_suffix].flatten.compact
7
- @subject_name_regexp = subject_name_regexp && [*subject_name_regexp].flatten.compact.map{ |_| Regexp.new(_) }
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
- if @subject_name_exact
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
- [zone_id, change_batch_for_challenges(dcs, action: 'UPSERT')]
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
- [zone_id, change_batch_for_challenges(dcs, action: 'DELETE', comment: '(cleanup)')]
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
@@ -26,6 +26,7 @@ module Acmesmith
26
26
  acme: acme,
27
27
  identifiers: identifiers,
28
28
  challenge_responder_rules: config.challenge_responders,
29
+ chain_preferences: config.chain_preferences,
29
30
  not_before: not_before,
30
31
  not_after: not_after
31
32
  )
@@ -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(order.certificate, csr)
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
 
@@ -1,3 +1,3 @@
1
1
  module Acmesmith
2
- VERSION = "2.3.1"
2
+ VERSION = "2.5.0"
3
3
  end
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.3.1
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: 2020-05-12 00:00:00.000000000 Z
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: '2'
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: '2'
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.1.2
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)