Right now, we define semaphores in both the model and prog, but they are generally the same. We can retrieve the list of semaphores from the subject in prog. We can still define semaphores in prog if it doesn't have a subject, like in Prog::Test::HetznerServer.
153 lines
4.9 KiB
Ruby
153 lines
4.9 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
|
|
|
|
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_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
|