When the destroy semaphore is increased, we want to hop to the destroy label from any label. If the prog defines a before_run method, we call it just before running each label. Some of these methods have custom logic, but most of them are similar. To achieve 100% line coverage, we test similar functionality repeatedly. I’ve moved this to the base program. So, if the prog doesn’t override before_run and has a destroy label and semaphore, we call this basic flow in all progs. The custom logics in progs also follow similar patterns. We can gradually move them to the base prog as well.
147 lines
4.8 KiB
Ruby
147 lines
4.8 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") { _1.id = cert.id }
|
|
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_start
|
|
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_start
|
|
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 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 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
|