diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ec519..6c97326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## [unreleased] +### New features + +- `acmesmith order` and `acmesmith new-account` gain `--ensure` option for idempotency; when an account key or a certificate already exists, `acmesmith` exits with doing nothing. [#86](https://github.com/sorah/acmesmith/pull/86) + ## v2.9.0 (2026-03-14) ### Enhancements diff --git a/lib/acmesmith/client.rb b/lib/acmesmith/client.rb index 7a63efa..f284721 100644 --- a/lib/acmesmith/client.rb +++ b/lib/acmesmith/client.rb @@ -3,6 +3,7 @@ require 'acmesmith/authorization_service' require 'acmesmith/ordering_service' require 'acmesmith/save_certificate_service' +require 'acmesmith/storages/base' require 'acme-client' module Acmesmith @@ -11,7 +12,13 @@ def initialize(config: nil) @config ||= config end - def new_account(contact, tos_agreed: true) + def new_account(contact, tos_agreed: true, ensure_existence: false) + if ensure_existence && storage.account_key_exist? + puts "=> Account key already exists; skipping (--ensure)" + return account_key + end + + puts "=> Creating an account ..." key = AccountKey.generate acme = Acme::Client.new(private_key: key.private_key, directory: config.directory, connection_options: config.connection_options, bad_nonce_retry: config.bad_nonce_retry) acme.new_account(contact: contact, terms_of_service_agreed: tos_agreed) @@ -21,9 +28,9 @@ def new_account(contact, tos_agreed: true) key end - def order(*identifiers, key_type: 'rsa', rsa_key_size: 2048, elliptic_curve: 'prime256v1', not_before: nil, not_after: nil) + def order(*identifiers, key_type: 'rsa', rsa_key_size: 2048, elliptic_curve: 'prime256v1', not_before: nil, not_after: nil, ensure_existence: false) private_key = generate_private_key(key_type: key_type, rsa_key_size: rsa_key_size, elliptic_curve: elliptic_curve) - order_with_private_key(*identifiers, private_key: private_key, not_before: not_before, not_after: not_after) + order_with_private_key(*identifiers, private_key: private_key, not_before: not_before, not_after: not_after, ensure_existence: ensure_existence) end def authorize(*identifiers) @@ -218,7 +225,7 @@ def account_key_passphrase end end - def order_with_private_key(name, *identifiers, private_key:, not_before: nil, not_after: nil) + def order_with_private_key(name, *identifiers, private_key:, not_before: nil, not_after: nil, ensure_existence: false) order = OrderingService.new( acme: acme, common_name: name, @@ -230,6 +237,19 @@ def order_with_private_key(name, *identifiers, private_key:, not_before: nil, no not_before: not_before, not_after: not_after ) + + if ensure_existence + existing = begin + storage.get_certificate(name, version: 'current') + rescue Storages::Base::NotExist + nil + end + if existing && order.covers?(existing) + puts "=> Current certificate for #{name} covers all requested identifiers and is not expired; skipping (--ensure)" + return existing + end + end + order.perform! cert = order.certificate diff --git a/lib/acmesmith/command.rb b/lib/acmesmith/command.rb index 95e8104..ff18cf1 100644 --- a/lib/acmesmith/command.rb +++ b/lib/acmesmith/command.rb @@ -9,9 +9,9 @@ class Command < Thor class_option :passphrase_from_env, type: :boolean, aliases: %w(-E), default: nil, desc: 'Read $ACMESMITH_ACCOUNT_KEY_PASSPHRASE and $ACMESMITH_CERTIFICATE_KEY_PASSPHRASE for passphrases' desc "new-account CONTACT", "Create account key (contact e.g. mailto:xxx@example.org)" + method_option :ensure, type: :boolean, default: false, desc: 'Exit normally if account key already exists (for idempotency)' def new_account(contact) - puts "=> Creating an account ..." - key = client.new_account(contact) + key = client.new_account(contact, ensure_existence: options[:ensure]) puts "=> Public Key:" puts "\n#{key.private_key.public_key.to_pem}" end @@ -35,12 +35,14 @@ def authorize(*domains) method_option :key_type, type: :string, enum: %w(rsa ec), default: 'rsa', desc: 'key type' method_option :rsa_key_size, type: :numeric, default: 2048, desc: 'size of RSA key' method_option :elliptic_curve, type: :string, default: 'prime256v1', desc: 'elliptic curve group for EC key' + method_option :ensure, type: :boolean, default: false, desc: 'Skip issuance if a current certificate already covers all identifiers and has not expired (for idempotency)' def order(name, *sans) cert = client.order( name, *sans, key_type: options[:key_type], rsa_key_size: options[:rsa_key_size], elliptic_curve: options[:elliptic_curve], + ensure_existence: options[:ensure], ) if options[:show_certificate] puts cert.certificate.to_text diff --git a/lib/acmesmith/ordering_service.rb b/lib/acmesmith/ordering_service.rb index e0ce2f2..2d9d140 100644 --- a/lib/acmesmith/ordering_service.rb +++ b/lib/acmesmith/ordering_service.rb @@ -31,6 +31,16 @@ def initialize(acme:, common_name:, identifiers:, private_key:, challenge_respon attr_reader :acme, :common_name, :identifiers, :private_key, :challenge_responder_rules, :chain_preferences, :profile_rules, :not_before, :not_after + # @param certificate [Acmesmith::Certificate] + # @return [Boolean] true iff the certificate is unexpired and already contains every identifier this order would request + def covers?(certificate) + return false if certificate.certificate.not_after.utc <= Time.now.utc + + existing = (certificate.sans + certificate.ip_sans).map { |s| acme_identifier(s) } + requested = identifiers.map { |id| acme_identifier(id) } + (requested - existing).empty? + end + def perform! puts "=> Ordering a certificate for the following identifiers:" puts diff --git a/lib/acmesmith/storages/base.rb b/lib/acmesmith/storages/base.rb index 9ee2a67..b9bffc3 100644 --- a/lib/acmesmith/storages/base.rb +++ b/lib/acmesmith/storages/base.rb @@ -18,6 +18,14 @@ def put_account_key(key, passphrase = nil) raise NotImplementedError end + # @return [Boolean] + def account_key_exist? + get_account_key + true + rescue NotExist + false + end + # @param cert [Acmesmith::Certificate] # @param passphrase [String, nil] # @param update_current [true, false]