acme-client 2.0.30 → 2.0.31

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b171e2e960f5e2b99d3718c5ee2b526809dfafa34ee44ec7e406f7280e53ac70
4
- data.tar.gz: 9a14e407059ea9097db344dee1d5d415a91f3b04bd721ac09c11bb8e994f978b
3
+ metadata.gz: 55d60fbf24893ea26c59535b2ffe6041916e678d4bbea5982666c5f9eb0f2bae
4
+ data.tar.gz: b62818cd2dc3ca8b9676e7ec355dfcec40eaf90b3f9b7abc26b2dddea0cc4473
5
5
  SHA512:
6
- metadata.gz: 1a27ca20db9535f84a5319e5a208079a59988c7144c27c1fc4bc91a338c180394dbc5182cee2a1488a741588616b15ceeccc71f611be02ded09ce96f6efdcff4
7
- data.tar.gz: 56515299bd2f31ea6a1bb2e486137277ff14dc97fde3d29dfda850fadf10bfe7f01fe1cfcb8184b885409366ffbe772794353759c7386f4b3c3edefc4a41a14f
6
+ metadata.gz: b3311579f5f990f433e2ede868ce5baf6ce910eb38b19889107436a0dea91f7d96c36619e6039e0e4b4382402e8bc81dce939fee0f915c850b068b80ced67c1d
7
+ data.tar.gz: 45b7de2260bad0703cfa5f865a33e5367789cde176e0a906224fc7e7ad29ba5eeb9f756ce79b29719460c03264c26c5808702737be912d2f5df9cce38003b2f9
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## `2.0.31`
2
+
3
+ * Expose Retry-After header on all
4
+ * ARI improvement
5
+ * Expose full error message on Error#acme_error_body
6
+ * Expose error subproblems (RFC7807) on Error#subproblems
7
+
1
8
  ## `2.0.30`
2
9
 
3
10
  * Add a default message to RateLimited error
data/acme-client.gemspec CHANGED
@@ -18,7 +18,8 @@ Gem::Specification.new do |spec|
18
18
 
19
19
  spec.add_development_dependency 'rake', '~> 13.0'
20
20
  spec.add_development_dependency 'rspec', '~> 3.9'
21
- spec.add_development_dependency 'vcr', '~> 2.9'
21
+ spec.add_development_dependency 'vcr', '~> 6.0'
22
+ spec.add_development_dependency 'bigdecimal'
22
23
  spec.add_development_dependency 'webmock', '~> 3.8'
23
24
  spec.add_development_dependency 'webrick', '~> 1.7'
24
25
 
@@ -1,10 +1,15 @@
1
1
  class Acme::Client::Error::RateLimited < Acme::Client::Error::ServerError
2
- attr_reader :retry_after
3
-
4
2
  DEFAULT_MESSAGE = 'Error message: urn:ietf:params:acme:error:rateLimited'
3
+ DEFAULT_RETRY_SECONDS = 10
5
4
 
6
- def initialize(message = DEFAULT_MESSAGE, retry_after = 10)
7
- super(message)
8
- @retry_after = retry_after.nil? ? 10 : retry_after.to_i
5
+ def initialize(message = DEFAULT_MESSAGE, retry_after = nil, acme_error_body: nil, subproblems: nil)
6
+ retry_after_time = case retry_after
7
+ when Time then retry_after
8
+ when nil then Time.now + DEFAULT_RETRY_SECONDS
9
+ else Acme::Client::Util.parse_retry_after(retry_after) || Time.now + DEFAULT_RETRY_SECONDS
10
+ end
11
+ int_retry_after = retry_after.nil? ? DEFAULT_RETRY_SECONDS : [(retry_after_time - Time.now).ceil, 0].max
12
+ super(message, retry_after: int_retry_after, acme_error_body: acme_error_body, subproblems: subproblems)
13
+ @retry_after_time = retry_after_time
9
14
  end
10
15
  end
@@ -1,4 +1,36 @@
1
1
  class Acme::Client::Error < StandardError
2
+ attr_reader :retry_after, :retry_after_time, :subproblems, :acme_error_body
3
+
4
+ Subproblem = Struct.new(:type, :detail, :identifier, keyword_init: true) do
5
+ def to_h
6
+ { type: type, detail: detail, identifier: identifier }
7
+ end
8
+ end
9
+
10
+ def initialize(message = nil, retry_after: nil, acme_error_body: nil, subproblems: nil)
11
+ super(message)
12
+ @retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
13
+ @retry_after = @retry_after_time ? [(@retry_after_time - Time.now).ceil, 0].max : nil
14
+ @acme_error_body = acme_error_body
15
+ @subproblems = parse_subproblems(subproblems)
16
+ end
17
+
18
+ private
19
+
20
+ def parse_subproblems(raw)
21
+ return [] if raw.nil? || !raw.is_a?(Array)
22
+
23
+ raw.map do |sp|
24
+ Subproblem.new(
25
+ type: sp['type'],
26
+ detail: sp['detail'],
27
+ identifier: sp['identifier']
28
+ )
29
+ end
30
+ end
31
+
32
+ public
33
+
2
34
  class Timeout < Acme::Client::Error; end
3
35
 
4
36
  class ClientError < Acme::Client::Error; end
@@ -9,7 +41,7 @@ class Acme::Client::Error < StandardError
9
41
  class CertificateNotReady < ClientError; end
10
42
  class ForcedChainNotFound < ClientError; end
11
43
  class OrderNotReady < ClientError; end
12
- class OrderNotReloadable < ClientError; end
44
+ class OrderUrlNil < ClientError; end
13
45
 
14
46
  class ServerError < Acme::Client::Error; end
15
47
  class AlreadyReplaced < ServerError; end
@@ -101,10 +101,13 @@ module Acme::Client::HTTPClient
101
101
  end
102
102
 
103
103
  def raise_on_error!
104
+ retry_after = env.response_headers['retry-after']
105
+ body = env.body.is_a?(Hash) ? env.body : nil
106
+ subproblems = error_subproblems
104
107
  if error_class == Acme::Client::Error::RateLimited
105
- raise error_class.new(error_message, env.response_headers['Retry-After'])
108
+ raise error_class.new(error_message, retry_after, acme_error_body: body, subproblems: subproblems)
106
109
  end
107
- raise error_class, error_message
110
+ raise error_class.new(error_message, retry_after: retry_after, acme_error_body: body, subproblems: subproblems)
108
111
  end
109
112
 
110
113
  def error_message
@@ -125,6 +128,11 @@ module Acme::Client::HTTPClient
125
128
  env.body['type']
126
129
  end
127
130
 
131
+ def error_subproblems
132
+ return unless env.body.is_a?(Hash)
133
+ env.body['subproblems']
134
+ end
135
+
128
136
  def decode_body
129
137
  content_type = env.response_headers['Content-Type'].to_s
130
138
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Acme::Client::Resources::Authorization
4
- attr_reader :url, :identifier, :domain, :expires, :status, :wildcard
4
+ attr_reader :url, :identifier, :domain, :expires, :status, :wildcard, :retry_after, :retry_after_time
5
5
 
6
6
  def initialize(client, **arguments)
7
7
  @client = client
@@ -52,7 +52,8 @@ class Acme::Client::Resources::Authorization
52
52
  status: status,
53
53
  expires: expires,
54
54
  challenges: @challenges,
55
- wildcard: wildcard
55
+ wildcard: wildcard,
56
+ retry_after: retry_after
56
57
  }
57
58
  end
58
59
 
@@ -69,7 +70,7 @@ class Acme::Client::Resources::Authorization
69
70
  Acme::Client::Resources::Challenges.new(@client, **arguments)
70
71
  end
71
72
 
72
- def assign_attributes(url:, status:, expires:, challenges:, identifier:, wildcard: false)
73
+ def assign_attributes(url:, status:, expires:, challenges:, identifier:, wildcard: false, retry_after: nil)
73
74
  @url = url
74
75
  @identifier = identifier
75
76
  @domain = identifier.fetch('value')
@@ -77,5 +78,7 @@ class Acme::Client::Resources::Authorization
77
78
  @expires = expires
78
79
  @challenges = challenges
79
80
  @wildcard = wildcard
81
+ @retry_after = retry_after
82
+ @retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
80
83
  end
81
84
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Acme::Client::Resources::Challenges::Base
4
- attr_reader :status, :url, :token, :error, :validated
4
+ attr_reader :status, :url, :token, :error, :validated, :retry_after, :retry_after_time
5
5
 
6
6
  def initialize(client, **arguments)
7
7
  @client = client
@@ -28,8 +28,17 @@ class Acme::Client::Resources::Challenges::Base
28
28
  true
29
29
  end
30
30
 
31
+ def typed_error
32
+ return nil unless error
33
+
34
+ error_type = error['type']
35
+ error_detail = error['detail'] || 'Unknown error'
36
+ error_class = Acme::Client::Error::ACME_ERRORS.fetch(error_type, Acme::Client::Error)
37
+ error_class.new(error_detail)
38
+ end
39
+
31
40
  def to_h
32
- { status: status, url: url, token: token, error: error, validated: validated }
41
+ { status: status, url: url, token: token, error: error, validated: validated, retry_after: retry_after }
33
42
  end
34
43
 
35
44
  private
@@ -40,11 +49,13 @@ class Acme::Client::Resources::Challenges::Base
40
49
  ).to_h
41
50
  end
42
51
 
43
- def assign_attributes(status:, url:, token:, error: nil, validated: nil)
52
+ def assign_attributes(status:, url:, token:, error: nil, validated: nil, retry_after: nil)
44
53
  @status = status
45
54
  @url = url
46
55
  @token = token
47
56
  @error = error
48
57
  @validated = validated
58
+ @retry_after = retry_after
59
+ @retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
49
60
  end
50
61
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Acme::Client::Resources::Order
4
- attr_reader :url, :status, :contact, :finalize_url, :identifiers, :authorization_urls, :expires, :certificate_url, :profile
4
+ attr_reader :url, :status, :contact, :finalize_url, :identifiers, :authorization_urls, :expires, :certificate_url, :profile, :replaces, :retry_after, :retry_after_time
5
5
 
6
6
  def initialize(client, **arguments)
7
7
  @client = client
@@ -9,9 +9,7 @@ class Acme::Client::Resources::Order
9
9
  end
10
10
 
11
11
  def reload
12
- if url.nil?
13
- raise Acme::Client::Error::OrderNotReloadable, 'Finalized orders are not reloadable for this CA'
14
- end
12
+ raise Acme::Client::Error::OrderUrlNil, 'Cannot reload order with nil url.' if url.nil?
15
13
 
16
14
  assign_attributes(**@client.order(url: url).to_h)
17
15
  true
@@ -36,6 +34,18 @@ class Acme::Client::Resources::Order
36
34
  end
37
35
  end
38
36
 
37
+ def renew(replaces: nil, **arguments)
38
+ replaces ||= renewal_info.ari_id
39
+
40
+ @client.new_order(replaces: replaces, **to_h.slice(:identifiers, :profile).merge(arguments))
41
+ end
42
+
43
+ def renewal_info(certificate: nil, ari_id: nil)
44
+ certificate ||= self.certificate if ari_id.nil?
45
+
46
+ @client.renewal_info(certificate:, ari_id:)
47
+ end
48
+
39
49
  def to_h
40
50
  {
41
51
  url: url,
@@ -45,14 +55,16 @@ class Acme::Client::Resources::Order
45
55
  authorization_urls: authorization_urls,
46
56
  identifiers: identifiers,
47
57
  certificate_url: certificate_url,
48
- profile: profile
58
+ profile: profile,
59
+ replaces: replaces,
60
+ retry_after: retry_after
49
61
  }
50
62
  end
51
63
 
52
64
  private
53
65
 
54
- def assign_attributes(url: nil, status:, expires:, finalize_url:, authorization_urls:, identifiers:, certificate_url: nil, profile: nil) # rubocop:disable Layout/LineLength,Metrics/ParameterLists
55
- @url = url
66
+ def assign_attributes(url: nil, status:, expires:, finalize_url:, authorization_urls:, identifiers:, certificate_url: nil, profile: nil, replaces: nil, retry_after: nil) # rubocop:disable Layout/LineLength,Metrics/ParameterLists
67
+ @url = url unless url.nil?
56
68
  @status = status
57
69
  @expires = expires
58
70
  @finalize_url = finalize_url
@@ -60,5 +72,8 @@ class Acme::Client::Resources::Order
60
72
  @identifiers = identifiers
61
73
  @certificate_url = certificate_url
62
74
  @profile = profile
75
+ @replaces = replaces
76
+ @retry_after = retry_after
77
+ @retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
63
78
  end
64
79
  end
@@ -1,13 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Acme::Client::Resources::RenewalInfo
4
- attr_reader :suggested_window, :explanation_url, :retry_after
4
+ attr_reader :ari_id, :suggested_window, :explanation_url, :retry_after, :retry_after_time
5
5
 
6
6
  def initialize(client, **arguments)
7
7
  @client = client
8
8
  assign_attributes(**arguments)
9
9
  end
10
10
 
11
+ def reload
12
+ assign_attributes(**@client.renewal_info(ari_id: ari_id).to_h)
13
+ end
14
+
11
15
  def suggested_window_start
12
16
  suggested_window&.fetch('start', nil)
13
17
  end
@@ -31,6 +35,7 @@ class Acme::Client::Resources::RenewalInfo
31
35
 
32
36
  def to_h
33
37
  {
38
+ ari_id: ari_id,
34
39
  suggested_window: suggested_window,
35
40
  explanation_url: explanation_url,
36
41
  retry_after: retry_after
@@ -39,9 +44,11 @@ class Acme::Client::Resources::RenewalInfo
39
44
 
40
45
  private
41
46
 
42
- def assign_attributes(suggested_window:, explanation_url: nil, retry_after: nil)
47
+ def assign_attributes(ari_id:, suggested_window:, explanation_url: nil, retry_after: nil)
48
+ @ari_id = ari_id
43
49
  @suggested_window = suggested_window
44
50
  @explanation_url = explanation_url
45
51
  @retry_after = retry_after
52
+ @retry_after_time = Acme::Client::Util.parse_retry_after(retry_after)
46
53
  end
47
54
  end
@@ -1,6 +1,24 @@
1
+ require 'time'
2
+
1
3
  module Acme::Client::Util
2
4
  extend self
3
5
 
6
+ # Parses a Retry-After header value into a Time.
7
+ # RFC 7231 §7.1.3: the value is either delay-seconds or an HTTP-date.
8
+ # Returns a Time, or nil if the value is nil or unparseable.
9
+ def parse_retry_after(value)
10
+ return nil if value.nil?
11
+
12
+ value = value.to_s
13
+ Integer(value, 10).then { |seconds| Time.now + seconds }
14
+ rescue ArgumentError, RangeError
15
+ begin
16
+ Time.httpdate(value)
17
+ rescue ArgumentError
18
+ nil
19
+ end
20
+ end
21
+
4
22
  def urlsafe_base64(data)
5
23
  Base64.urlsafe_encode64(data).sub(/[\s=]*\z/, '')
6
24
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Acme
4
4
  class Client
5
- VERSION = '2.0.30'.freeze
5
+ VERSION = '2.0.31'.freeze
6
6
  end
7
7
  end
data/lib/acme/client.rb CHANGED
@@ -225,13 +225,18 @@ class Acme::Client
225
225
  response.success?
226
226
  end
227
227
 
228
- def renewal_info(certificate:)
229
- cert_id = Acme::Client::Util.ari_certificate_identifier(certificate)
230
- renewal_info_url = URI.join(endpoint_for(:renewal_info).to_s + '/', cert_id).to_s
228
+ def renewal_info(certificate: nil, ari_id: nil)
229
+ if certificate.nil? && ari_id.nil?
230
+ raise ArgumentError, 'must specify certificate or ari_id'
231
+ end
232
+
233
+ ari_id ||= Acme::Client::Util.ari_certificate_identifier(certificate)
234
+
235
+ renewal_info_url = URI.join(endpoint_for(:renewal_info).to_s + '/', ari_id).to_s
231
236
 
232
237
  response = get(renewal_info_url)
233
238
  attributes = attributes_from_renewal_info_response(response)
234
- Acme::Client::Resources::RenewalInfo.new(self, **attributes)
239
+ Acme::Client::Resources::RenewalInfo.new(self, ari_id: ari_id, **attributes)
235
240
  end
236
241
 
237
242
  def get_nonce
@@ -316,19 +321,25 @@ class Acme::Client
316
321
  [:authorization_urls, 'authorizations'],
317
322
  [:certificate_url, 'certificate'],
318
323
  :identifiers,
319
- :profile
324
+ :profile,
325
+ :replaces
320
326
  )
321
327
 
322
328
  attributes[:url] = response.headers[:location] if response.headers[:location]
329
+ attributes[:retry_after] = response.headers['retry-after'] if response.headers['retry-after']
323
330
  attributes
324
331
  end
325
332
 
326
333
  def attributes_from_authorization_response(response)
327
- extract_attributes(response.body, :identifier, :status, :expires, :challenges, :wildcard)
334
+ attributes = extract_attributes(response.body, :identifier, :status, :expires, :challenges, :wildcard)
335
+ attributes[:retry_after] = response.headers['retry-after'] if response.headers['retry-after']
336
+ attributes
328
337
  end
329
338
 
330
339
  def attributes_from_challenge_response(response)
331
- extract_attributes(response.body, :status, :url, :token, :type, :error, :validated)
340
+ attributes = extract_attributes(response.body, :status, :url, :token, :type, :error, :validated)
341
+ attributes[:retry_after] = response.headers['retry-after'] if response.headers['retry-after']
342
+ attributes
332
343
  end
333
344
 
334
345
  def attributes_from_renewal_info_response(response)
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acme-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.30
4
+ version: 2.0.31
5
5
  platform: ruby
6
6
  authors:
7
7
  - Charles Barbier
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-12-16 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: rake
@@ -43,14 +43,28 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '2.9'
46
+ version: '6.0'
47
47
  type: :development
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '2.9'
53
+ version: '6.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: bigdecimal
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: webmock
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -195,7 +209,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
195
209
  - !ruby/object:Gem::Version
196
210
  version: '0'
197
211
  requirements: []
198
- rubygems_version: 3.6.6
212
+ rubygems_version: 3.6.9
199
213
  specification_version: 4
200
214
  summary: Client for the ACME protocol.
201
215
  test_files: []