Files
ubicloud/prog/vnet/cert_nexus.rb
Furkan Sahin 803906916c CertNexus restart counter is added to not DOS ZeroSSL
In case the passed DNSZone is not valid globally, DNS validation or cert
generation may fail. In that case, CertNexus restarts to do a new acme
dance. It goes on forever. Instead of this, we are introducing a restart
logic. In case of restarts, we start napping aggressively up until 10
minutes.
2025-02-11 17:49:22 +01:00

171 lines
5.3 KiB
Ruby

# frozen_string_literal: true
require "acme-client"
require "openssl"
class Prog::Vnet::CertNexus < Prog::Base
subject_is :cert
REVOKE_REASON = "cessationOfOperation"
def self.assemble(hostname, dns_zone_id)
unless Config.development? || DnsZone[dns_zone_id]
fail "Given DNS zone doesn't exist with the id #{dns_zone_id}"
end
DB.transaction do
cert = Cert.create_with_id(hostname: hostname, dns_zone_id: dns_zone_id)
Strand.create(prog: "Vnet::CertNexus", label: "start", stack: [{"restarted" => 0}]) { _1.id = cert.id }
end
end
def before_run
when_destroy_set? do
hop_destroy unless %w[destroy].include?(strand.label)
end
end
label def start
register_deadline("wait", 10 * 60)
if Config.development? && cert.dns_zone_id.nil?
crt, key = Util.create_certificate(subject: "/CN=" + cert.hostname, duration: 60 * 60 * 24 * 30 * 3)
cert.update(cert: crt, csr_key: key.to_der)
hop_wait
end
account_key = OpenSSL::PKey::EC.generate("prime256v1")
client = Acme::Client.new(private_key: account_key, directory: Config.acme_directory)
account = client.new_account(contact: "mailto:#{Config.acme_email}", terms_of_service_agreed: true, external_account_binding: {kid: Config.acme_eab_kid, hmac_key: Config.acme_eab_hmac_key})
order = client.new_order(identifiers: [cert.hostname])
authorization = order.authorizations.first
cert.update(kid: account.kid, account_key: account_key.to_der, order_url: order.url)
dns_challenge = authorization.dns
dns_zone.insert_record(record_name: dns_record_name, type: dns_challenge.record_type, ttl: 600, data: dns_challenge.record_content)
hop_wait_dns_update
end
label def wait_dns_update
dns_record = DnsRecord[dns_zone_id: dns_zone.id, name: dns_record_name + ".", tombstoned: false, data: dns_challenge.record_content]
if DB[:seen_dns_records_by_dns_servers].where(dns_record_id: dns_record.id).empty?
nap 10
end
dns_challenge.request_validation
hop_wait_dns_validation
end
label def wait_dns_validation
case dns_challenge.status
when "pending"
nap 10
when "valid"
csr_key = OpenSSL::PKey::EC.generate("prime256v1")
csr = Acme::Client::CertificateRequest.new(private_key: csr_key, common_name: cert.hostname)
acme_order.finalize(csr: csr)
cert.update(csr_key: csr_key.to_der)
hop_wait_cert_finalization
else
Clog.emit("DNS validation failed") { {order_status: dns_challenge.status} }
dns_zone.delete_record(record_name: dns_record_name)
hop_restart
end
end
label def wait_cert_finalization
case acme_order.status
when "processing"
nap 10
when "valid"
cert.update(cert: acme_order.certificate, created_at: Time.now)
dns_zone.delete_record(record_name: dns_record_name)
hop_wait
else
Clog.emit("Certificate finalization failed") { {order_status: acme_order.status} }
dns_zone.delete_record(record_name: dns_record_name)
hop_restart
end
end
label def wait
if cert.created_at < Time.now - 60 * 60 * 24 * 30 * 3 # 3 months
cert.incr_destroy
nap 0
end
nap 60 * 60 * 24 * 30 # 1 month
end
label def restart
when_restarted_set? do
decr_restarted
update_stack_restart_counter
hop_start
end
cert.incr_restarted
nap [60 * (strand.stack.first["restarted"] + 1), 60 * 10].min
end
label def destroy
if Config.development? && cert.dns_zone_id.nil?
cert.destroy
pop "self-signed certificate destroyed"
end
# the reason is chosen as "cessationOfOperation"
begin
acme_client.revoke(certificate: cert.cert, reason: REVOKE_REASON) if cert.cert
rescue Acme::Client::Error::AlreadyRevoked => ex
Clog.emit("Certificate is already revoked") { {cert_revoke_failure: {ubid: cert.ubid, exception: Util.exception_to_hash(ex)}} }
rescue Acme::Client::Error::NotFound => ex
Clog.emit("Certificate is not found") { {cert_revoke_failure: {ubid: cert.ubid, exception: Util.exception_to_hash(ex)}} }
rescue Acme::Client::Error::Unauthorized => ex
if ex.message.include?("The certificate has expired and cannot be revoked")
Clog.emit("Certificate is expired and cannot be revoked") { {cert_revoke_failure: {ubid: cert.ubid, exception: Util.exception_to_hash(ex)}} }
else
raise ex
end
end
dns_zone.delete_record(record_name: dns_record_name) if dns_challenge
cert.destroy
pop "certificate revoked and destroyed"
end
def update_stack_restart_counter
current_frame = strand.stack.first
current_frame["restarted"] += 1
strand.modified!(:stack)
strand.save_changes
end
def acme_client
# If the private_key is not yet set, we did not start the communication with
# ACME server yet, therefore, we return nil.
Acme::Client.new(private_key: Util.parse_key(cert.account_key), directory: Config.acme_directory, kid: cert.kid) if cert.account_key
end
def acme_order
# If the order_url is set, acme_client cannot be nil, so, this is nullref safe
acme_client.order(url: cert.order_url) if cert.order_url
end
def dns_challenge
acme_order.authorizations.first.dns
end
def dns_record_name
dns_challenge.record_name + "." + cert.hostname
end
def dns_zone
@dns_zone ||= DnsZone[cert.dns_zone_id]
end
end