summaryrefslogtreecommitdiff
path: root/vendor/acme-client/lib/acme/client
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/acme-client/lib/acme/client')
-rw-r--r--vendor/acme-client/lib/acme/client/certificate.rb30
-rw-r--r--vendor/acme-client/lib/acme/client/certificate_request.rb111
-rw-r--r--vendor/acme-client/lib/acme/client/crypto.rb98
-rw-r--r--vendor/acme-client/lib/acme/client/error.rb16
-rw-r--r--vendor/acme-client/lib/acme/client/faraday_middleware.rb123
-rw-r--r--vendor/acme-client/lib/acme/client/resources.rb5
-rw-r--r--vendor/acme-client/lib/acme/client/resources/authorization.rb44
-rw-r--r--vendor/acme-client/lib/acme/client/resources/challenges.rb6
-rw-r--r--vendor/acme-client/lib/acme/client/resources/challenges/base.rb43
-rw-r--r--vendor/acme-client/lib/acme/client/resources/challenges/dns01.rb19
-rw-r--r--vendor/acme-client/lib/acme/client/resources/challenges/http01.rb18
-rw-r--r--vendor/acme-client/lib/acme/client/resources/challenges/tls_sni01.rb24
-rw-r--r--vendor/acme-client/lib/acme/client/resources/registration.rb37
-rw-r--r--vendor/acme-client/lib/acme/client/self_sign_certificate.rb60
-rw-r--r--vendor/acme-client/lib/acme/client/version.rb7
15 files changed, 641 insertions, 0 deletions
diff --git a/vendor/acme-client/lib/acme/client/certificate.rb b/vendor/acme-client/lib/acme/client/certificate.rb
new file mode 100644
index 0000000..6c68cc5
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/certificate.rb
@@ -0,0 +1,30 @@
+class Acme::Client::Certificate
+ extend Forwardable
+
+ attr_reader :x509, :x509_chain, :request, :private_key, :url
+
+ def_delegators :x509, :to_pem, :to_der
+
+ def initialize(certificate, url, chain, request)
+ @x509 = certificate
+ @url = url
+ @x509_chain = chain
+ @request = request
+ end
+
+ def chain_to_pem
+ x509_chain.map(&:to_pem).join
+ end
+
+ def x509_fullchain
+ [x509, *x509_chain]
+ end
+
+ def fullchain_to_pem
+ x509_fullchain.map(&:to_pem).join
+ end
+
+ def common_name
+ x509.subject.to_a.find { |name, _, _| name == 'CN' }[1]
+ end
+end
diff --git a/vendor/acme-client/lib/acme/client/certificate_request.rb b/vendor/acme-client/lib/acme/client/certificate_request.rb
new file mode 100644
index 0000000..8eae0c6
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/certificate_request.rb
@@ -0,0 +1,111 @@
+class Acme::Client::CertificateRequest
+ extend Forwardable
+
+ DEFAULT_KEY_LENGTH = 2048
+ DEFAULT_DIGEST = OpenSSL::Digest::SHA256
+ SUBJECT_KEYS = {
+ common_name: 'CN',
+ country_name: 'C',
+ organization_name: 'O',
+ organizational_unit: 'OU',
+ state_or_province: 'ST',
+ locality_name: 'L'
+ }.freeze
+
+ SUBJECT_TYPES = {
+ 'CN' => OpenSSL::ASN1::UTF8STRING,
+ 'C' => OpenSSL::ASN1::UTF8STRING,
+ 'O' => OpenSSL::ASN1::UTF8STRING,
+ 'OU' => OpenSSL::ASN1::UTF8STRING,
+ 'ST' => OpenSSL::ASN1::UTF8STRING,
+ 'L' => OpenSSL::ASN1::UTF8STRING
+ }.freeze
+
+ attr_reader :private_key, :common_name, :names, :subject
+
+ def_delegators :csr, :to_pem, :to_der
+
+ def initialize(common_name: nil, names: [], private_key: generate_private_key, subject: {}, digest: DEFAULT_DIGEST.new)
+ @digest = digest
+ @private_key = private_key
+ @subject = normalize_subject(subject)
+ @common_name = common_name || @subject[SUBJECT_KEYS[:common_name]] || @subject[:common_name]
+ @names = names.to_a.dup
+ normalize_names
+ @subject[SUBJECT_KEYS[:common_name]] ||= @common_name
+ validate_subject
+ end
+
+ def csr
+ @csr ||= generate
+ end
+
+ private
+
+ def generate_private_key
+ OpenSSL::PKey::RSA.new(DEFAULT_KEY_LENGTH)
+ end
+
+ def normalize_subject(subject)
+ @subject = subject.each_with_object({}) do |(key, value), hash|
+ hash[SUBJECT_KEYS.fetch(key, key)] = value.to_s
+ end
+ end
+
+ def normalize_names
+ if @common_name
+ @names.unshift(@common_name) unless @names.include?(@common_name)
+ else
+ raise ArgumentError, 'No common name and no list of names given' if @names.empty?
+ @common_name = @names.first
+ end
+ end
+
+ def validate_subject
+ validate_subject_attributes
+ validate_subject_common_name
+ end
+
+ def validate_subject_attributes
+ extra_keys = @subject.keys - SUBJECT_KEYS.keys - SUBJECT_KEYS.values
+ return if extra_keys.empty?
+ raise ArgumentError, "Unexpected subject attributes given: #{extra_keys.inspect}"
+ end
+
+ def validate_subject_common_name
+ return if @common_name == @subject[SUBJECT_KEYS[:common_name]]
+ raise ArgumentError, 'Conflicting common name given in arguments and subject'
+ end
+
+ def generate
+ OpenSSL::X509::Request.new.tap do |csr|
+ csr.public_key = @private_key.public_key
+ csr.subject = generate_subject
+ csr.version = 2
+ add_extension(csr)
+ csr.sign @private_key, @digest
+ end
+ end
+
+ def generate_subject
+ OpenSSL::X509::Name.new(
+ @subject.map {|name, value|
+ [name, value, SUBJECT_TYPES[name]]
+ }
+ )
+ end
+
+ def add_extension(csr)
+ return if @names.size <= 1
+
+ extension = OpenSSL::X509::ExtensionFactory.new.create_extension(
+ 'subjectAltName', @names.map { |name| "DNS:#{name}" }.join(', '), false
+ )
+ csr.add_attribute(
+ OpenSSL::X509::Attribute.new(
+ 'extReq',
+ OpenSSL::ASN1::Set.new([OpenSSL::ASN1::Sequence.new([extension])])
+ )
+ )
+ end
+end
diff --git a/vendor/acme-client/lib/acme/client/crypto.rb b/vendor/acme-client/lib/acme/client/crypto.rb
new file mode 100644
index 0000000..dfa5cdc
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/crypto.rb
@@ -0,0 +1,98 @@
+class Acme::Client::Crypto
+ attr_reader :private_key
+
+ def initialize(private_key)
+ @private_key = private_key
+ end
+
+ def generate_signed_jws(header:, payload:)
+ header = { typ: 'JWT', alg: jws_alg, jwk: jwk }.merge(header)
+
+ encoded_header = urlsafe_base64(header.to_json)
+ encoded_payload = urlsafe_base64(payload.to_json)
+ signature_data = "#{encoded_header}.#{encoded_payload}"
+
+ signature = private_key.sign digest, signature_data
+ encoded_signature = urlsafe_base64(signature)
+
+ {
+ protected: encoded_header,
+ payload: encoded_payload,
+ signature: encoded_signature
+ }.to_json
+ end
+
+ def thumbprint
+ urlsafe_base64 digest.digest(jwk.to_json)
+ end
+
+ def digest
+ OpenSSL::Digest::SHA256.new
+ end
+
+ def urlsafe_base64(data)
+ Base64.urlsafe_encode64(data).sub(/[\s=]*\z/, '')
+ end
+
+ private
+
+ def jws_alg
+ { 'RSA' => 'RS256', 'EC' => 'ES256' }.fetch(jwk[:kty])
+ end
+
+ def jwk
+ @jwk ||= case private_key
+ when OpenSSL::PKey::RSA
+ rsa_jwk
+ when OpenSSL::PKey::EC
+ ec_jwk
+ else
+ raise ArgumentError, "Can't handle #{private_key} as private key, only OpenSSL::PKey::RSA and OpenSSL::PKey::EC"
+ end
+ end
+
+ def rsa_jwk
+ {
+ e: urlsafe_base64(public_key.e.to_s(2)),
+ kty: 'RSA',
+ n: urlsafe_base64(public_key.n.to_s(2))
+ }
+ end
+
+ def ec_jwk
+ {
+ crv: curve_name,
+ kty: 'EC',
+ x: urlsafe_base64(coordinates[:x].to_s(2)),
+ y: urlsafe_base64(coordinates[:y].to_s(2))
+ }
+ end
+
+ def curve_name
+ {
+ 'prime256v1' => 'P-256',
+ 'secp384r1' => 'P-384',
+ 'secp521r1' => 'P-521'
+ }.fetch(private_key.group.curve_name) { raise ArgumentError, 'Unknown EC curve' }
+ end
+
+ # rubocop:disable Metrics/AbcSize
+ def coordinates
+ @coordinates ||= begin
+ hex = public_key.to_bn.to_s(16)
+ data_len = hex.length - 2
+ hex_x = hex[2, data_len / 2]
+ hex_y = hex[2 + data_len / 2, data_len / 2]
+
+ {
+ x: OpenSSL::BN.new([hex_x].pack('H*'), 2),
+ y: OpenSSL::BN.new([hex_y].pack('H*'), 2)
+ }
+ end
+ end
+ # rubocop:enable Metrics/AbcSize
+
+ def public_key
+ @public_key ||= private_key.public_key
+ end
+end
diff --git a/vendor/acme-client/lib/acme/client/error.rb b/vendor/acme-client/lib/acme/client/error.rb
new file mode 100644
index 0000000..2b35623
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/error.rb
@@ -0,0 +1,16 @@
+class Acme::Client::Error < StandardError
+ class NotFound < Acme::Client::Error; end
+ class BadCSR < Acme::Client::Error; end
+ class BadNonce < Acme::Client::Error; end
+ class Connection < Acme::Client::Error; end
+ class Dnssec < Acme::Client::Error; end
+ class Malformed < Acme::Client::Error; end
+ class ServerInternal < Acme::Client::Error; end
+ class Acme::Tls < Acme::Client::Error; end
+ class Unauthorized < Acme::Client::Error; end
+ class UnknownHost < Acme::Client::Error; end
+ class Timeout < Acme::Client::Error; end
+ class RateLimited < Acme::Client::Error; end
+ class RejectedIdentifier < Acme::Client::Error; end
+ class UnsupportedIdentifier < Acme::Client::Error; end
+end
diff --git a/vendor/acme-client/lib/acme/client/faraday_middleware.rb b/vendor/acme-client/lib/acme/client/faraday_middleware.rb
new file mode 100644
index 0000000..21e29c9
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/faraday_middleware.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+class Acme::Client::FaradayMiddleware < Faraday::Middleware
+ attr_reader :env, :response, :client
+
+ repo_url = 'https://github.com/unixcharles/acme-client'
+ USER_AGENT = "Acme::Client v#{Acme::Client::VERSION} (#{repo_url})".freeze
+
+ def initialize(app, client:)
+ super(app)
+ @client = client
+ end
+
+ def call(env)
+ @env = env
+ @env[:request_headers]['User-Agent'] = USER_AGENT
+ @env.body = crypto.generate_signed_jws(header: { nonce: pop_nonce }, payload: env.body)
+ @app.call(env).on_complete { |response_env| on_complete(response_env) }
+ rescue Faraday::TimeoutError
+ raise Acme::Client::Error::Timeout
+ end
+
+ def on_complete(env)
+ @env = env
+
+ raise_on_not_found!
+ store_nonce
+ env.body = decode_body
+ env.response_headers['Link'] = decode_link_headers
+
+ return if env.success?
+
+ raise_on_error!
+ end
+
+ private
+
+ def raise_on_not_found!
+ raise Acme::Client::Error::NotFound, env.url.to_s if env.status == 404
+ end
+
+ def raise_on_error!
+ raise error_class, error_message
+ end
+
+ def error_message
+ if env.body.is_a? Hash
+ env.body['detail']
+ else
+ "Error message: #{env.body}"
+ end
+ end
+
+ def error_class
+ if error_name && !error_name.empty? && Acme::Client::Error.const_defined?(error_name)
+ Object.const_get("Acme::Client::Error::#{error_name}")
+ else
+ Acme::Client::Error
+ end
+ end
+
+ def error_name
+ @error_name ||= begin
+ return unless env.body.is_a?(Hash)
+ return unless env.body.key?('type')
+
+ env.body['type'].gsub('urn:acme:error:', '').split(/[_-]/).map(&:capitalize).join
+ end
+ end
+
+ def decode_body
+ content_type = env.response_headers['Content-Type']
+
+ if content_type == 'application/json' || content_type == 'application/problem+json'
+ JSON.load(env.body)
+ else
+ env.body
+ end
+ end
+
+ LINK_MATCH = /<(.*?)>;rel="([\w-]+)"/
+
+ def decode_link_headers
+ return unless env.response_headers.key?('Link')
+ link_header = env.response_headers['Link']
+
+ links = link_header.split(', ').map { |entry|
+ _, link, name = *entry.match(LINK_MATCH)
+ [name, link]
+ }
+
+ Hash[*links.flatten]
+ end
+
+ def store_nonce
+ nonces << env.response_headers['replay-nonce']
+ end
+
+ def pop_nonce
+ if nonces.empty?
+ get_nonce
+ else
+ nonces.pop
+ end
+ end
+
+ def get_nonce
+ response = Faraday.head(env.url, nil, 'User-Agent' => USER_AGENT)
+ response.headers['replay-nonce']
+ end
+
+ def nonces
+ client.nonces
+ end
+
+ def private_key
+ client.private_key
+ end
+
+ def crypto
+ @crypto ||= Acme::Client::Crypto.new(private_key)
+ end
+end
diff --git a/vendor/acme-client/lib/acme/client/resources.rb b/vendor/acme-client/lib/acme/client/resources.rb
new file mode 100644
index 0000000..ad55688
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/resources.rb
@@ -0,0 +1,5 @@
+module Acme::Client::Resources; end
+
+require 'acme/client/resources/registration'
+require 'acme/client/resources/challenges'
+require 'acme/client/resources/authorization'
diff --git a/vendor/acme-client/lib/acme/client/resources/authorization.rb b/vendor/acme-client/lib/acme/client/resources/authorization.rb
new file mode 100644
index 0000000..9ca2e76
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/resources/authorization.rb
@@ -0,0 +1,44 @@
+class Acme::Client::Resources::Authorization
+ HTTP01 = Acme::Client::Resources::Challenges::HTTP01
+ DNS01 = Acme::Client::Resources::Challenges::DNS01
+ TLSSNI01 = Acme::Client::Resources::Challenges::TLSSNI01
+
+ attr_reader :client, :uri, :domain, :status, :expires, :http01, :dns01, :tls_sni01
+
+ def initialize(client, uri, response)
+ @client = client
+ @uri = uri
+ assign_attributes(response.body)
+ end
+
+ def verify_status
+ response = @client.connection.get(@uri)
+
+ assign_attributes(response.body)
+ status
+ end
+
+ private
+
+ def assign_attributes(body)
+ @expires = Time.iso8601(body['expires']) if body.key? 'expires'
+ @domain = body['identifier']['value']
+ @status = body['status']
+ assign_challenges(body['challenges'])
+ end
+
+ def assign_challenges(challenges)
+ challenges.each do |attributes|
+ challenge = case attributes.fetch('type')
+ when 'http-01'
+ @http01 ||= HTTP01.new(self)
+ when 'dns-01'
+ @dns01 ||= DNS01.new(self)
+ when 'tls-sni-01'
+ @tls_sni01 ||= TLSSNI01.new(self)
+ end
+
+ challenge.assign_attributes(attributes) if challenge
+ end
+ end
+end
diff --git a/vendor/acme-client/lib/acme/client/resources/challenges.rb b/vendor/acme-client/lib/acme/client/resources/challenges.rb
new file mode 100644
index 0000000..ec92d47
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/resources/challenges.rb
@@ -0,0 +1,6 @@
+module Acme::Client::Resources::Challenges; end
+
+require 'acme/client/resources/challenges/base'
+require 'acme/client/resources/challenges/http01'
+require 'acme/client/resources/challenges/dns01'
+require 'acme/client/resources/challenges/tls_sni01'
diff --git a/vendor/acme-client/lib/acme/client/resources/challenges/base.rb b/vendor/acme-client/lib/acme/client/resources/challenges/base.rb
new file mode 100644
index 0000000..c78c74e
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/resources/challenges/base.rb
@@ -0,0 +1,43 @@
+class Acme::Client::Resources::Challenges::Base
+ attr_reader :authorization, :status, :uri, :token, :error
+
+ def initialize(authorization)
+ @authorization = authorization
+ end
+
+ def client
+ authorization.client
+ end
+
+ def verify_status
+ authorization.verify_status
+
+ status
+ end
+
+ def request_verification
+ response = client.connection.post(@uri, resource: 'challenge', type: challenge_type, keyAuthorization: authorization_key)
+ response.success?
+ end
+
+ def assign_attributes(attributes)
+ @status = attributes.fetch('status', 'pending')
+ @uri = attributes.fetch('uri')
+ @token = attributes.fetch('token')
+ @error = attributes['error']
+ end
+
+ private
+
+ def challenge_type
+ self.class::CHALLENGE_TYPE
+ end
+
+ def authorization_key
+ "#{token}.#{crypto.thumbprint}"
+ end
+
+ def crypto
+ @crypto ||= Acme::Client::Crypto.new(client.private_key)
+ end
+end
diff --git a/vendor/acme-client/lib/acme/client/resources/challenges/dns01.rb b/vendor/acme-client/lib/acme/client/resources/challenges/dns01.rb
new file mode 100644
index 0000000..543f438
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/resources/challenges/dns01.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class Acme::Client::Resources::Challenges::DNS01 < Acme::Client::Resources::Challenges::Base
+ CHALLENGE_TYPE = 'dns-01'.freeze
+ RECORD_NAME = '_acme-challenge'.freeze
+ RECORD_TYPE = 'TXT'.freeze
+
+ def record_name
+ RECORD_NAME
+ end
+
+ def record_type
+ RECORD_TYPE
+ end
+
+ def record_content
+ crypto.urlsafe_base64(crypto.digest.digest(authorization_key))
+ end
+end
diff --git a/vendor/acme-client/lib/acme/client/resources/challenges/http01.rb b/vendor/acme-client/lib/acme/client/resources/challenges/http01.rb
new file mode 100644
index 0000000..4966091
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/resources/challenges/http01.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+class Acme::Client::Resources::Challenges::HTTP01 < Acme::Client::Resources::Challenges::Base
+ CHALLENGE_TYPE = 'http-01'.freeze
+ CONTENT_TYPE = 'text/plain'.freeze
+
+ def content_type
+ CONTENT_TYPE
+ end
+
+ def file_content
+ authorization_key
+ end
+
+ def filename
+ ".well-known/acme-challenge/#{token}"
+ end
+end
diff --git a/vendor/acme-client/lib/acme/client/resources/challenges/tls_sni01.rb b/vendor/acme-client/lib/acme/client/resources/challenges/tls_sni01.rb
new file mode 100644
index 0000000..8f455f5
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/resources/challenges/tls_sni01.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class Acme::Client::Resources::Challenges::TLSSNI01 < Acme::Client::Resources::Challenges::Base
+ CHALLENGE_TYPE = 'tls-sni-01'.freeze
+
+ def hostname
+ digest = crypto.digest.hexdigest(authorization_key)
+ "#{digest[0..31]}.#{digest[32..64]}.acme.invalid"
+ end
+
+ def certificate
+ self_sign_certificate.certificate
+ end
+
+ def private_key
+ self_sign_certificate.private_key
+ end
+
+ private
+
+ def self_sign_certificate
+ @self_sign_certificate ||= Acme::Client::SelfSignCertificate.new(subject_alt_names: [hostname])
+ end
+end
diff --git a/vendor/acme-client/lib/acme/client/resources/registration.rb b/vendor/acme-client/lib/acme/client/resources/registration.rb
new file mode 100644
index 0000000..b7a4c11
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/resources/registration.rb
@@ -0,0 +1,37 @@
+class Acme::Client::Resources::Registration
+ attr_reader :id, :key, :contact, :uri, :next_uri, :recover_uri, :term_of_service_uri
+
+ def initialize(client, response)
+ @client = client
+ @uri = response.headers['location']
+ assign_links(response.headers['Link'])
+ assign_attributes(response.body)
+ end
+
+ def get_terms
+ return unless @term_of_service_uri
+
+ @client.connection.get(@term_of_service_uri).body
+ end
+
+ def agree_terms
+ return true unless @term_of_service_uri
+
+ response = @client.connection.post(@uri, resource: 'reg', agreement: @term_of_service_uri)
+ response.success?
+ end
+
+ private
+
+ def assign_links(links)
+ @next_uri = links['next']
+ @recover_uri = links['recover']
+ @term_of_service_uri = links['terms-of-service']
+ end
+
+ def assign_attributes(body)
+ @id = body['id']
+ @key = body['key']
+ @contact = body['contact']
+ end
+end
diff --git a/vendor/acme-client/lib/acme/client/self_sign_certificate.rb b/vendor/acme-client/lib/acme/client/self_sign_certificate.rb
new file mode 100644
index 0000000..2e7d98c
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/self_sign_certificate.rb
@@ -0,0 +1,60 @@
+class Acme::Client::SelfSignCertificate
+ attr_reader :private_key, :subject_alt_names, :not_before, :not_after
+
+ extend Forwardable
+ def_delegators :certificate, :to_pem, :to_der
+
+ def initialize(subject_alt_names:, not_before: default_not_before, not_after: default_not_after, private_key: generate_private_key)
+ @private_key = private_key
+ @subject_alt_names = subject_alt_names
+ @not_before = not_before
+ @not_after = not_after
+ end
+
+ def certificate
+ @certificate ||= begin
+ certificate = generate_certificate
+
+ extension_factory = generate_extension_factory(certificate)
+ subject_alt_name_entry = subject_alt_names.map { |d| "DNS: #{d}" }.join(',')
+ subject_alt_name_extension = extension_factory.create_extension('subjectAltName', subject_alt_name_entry)
+ certificate.add_extension(subject_alt_name_extension)
+
+ certificate.sign(private_key, digest)
+ end
+ end
+
+ private
+
+ def generate_private_key
+ OpenSSL::PKey::RSA.new(2048)
+ end
+
+ def default_not_before
+ Time.now - 3600
+ end
+
+ def default_not_after
+ Time.now + 30 * 24 * 3600
+ end
+
+ def digest
+ OpenSSL::Digest::SHA256.new
+ end
+
+ def generate_certificate
+ certificate = OpenSSL::X509::Certificate.new
+ certificate.not_before = not_before
+ certificate.not_after = not_after
+ certificate.public_key = private_key.public_key
+ certificate.version = 2
+ certificate
+ end
+
+ def generate_extension_factory(certificate)
+ extension_factory = OpenSSL::X509::ExtensionFactory.new
+ extension_factory.subject_certificate = certificate
+ extension_factory.issuer_certificate = certificate
+ extension_factory
+ end
+end
diff --git a/vendor/acme-client/lib/acme/client/version.rb b/vendor/acme-client/lib/acme/client/version.rb
new file mode 100644
index 0000000..c989c12
--- /dev/null
+++ b/vendor/acme-client/lib/acme/client/version.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Acme
+ class Client
+ VERSION = '0.4.1'.freeze
+ end
+end