class Tipi::ACME::CertificateManager

Constants

ACME_CHALLENGE_PATH_REGEXP
ACME_DIRECTORY
CERTIFICATE_REGEXP
IP_REGEXP
LOCALHOST_REGEXP

Public Class Methods

new(master_ctx:, store:, challenge_handler:) click to toggle source
# File lib/tipi/acme.rb, line 13
def initialize(master_ctx:, store:, challenge_handler:)
  @master_ctx = master_ctx
  @store = store
  @challenge_handler = challenge_handler
  @contexts = {}
  @requests = Polyphony::Queue.new
  @worker = spin { run }
  setup_sni_callback
end

Public Instance Methods

acme_client() click to toggle source
# File lib/tipi/acme.rb, line 136
def acme_client
  @acme_client ||= setup_acme_client
end
challenge_routing_app(app) click to toggle source
# File lib/tipi/acme.rb, line 25
def challenge_routing_app(app)
  ->(req) do
    (req.path =~ ACME_CHALLENGE_PATH_REGEXP ? @challenge_handler : app)
      .(req)
  rescue => e
    puts "Error while handling request: #{e.inspect} (headers: #{req.headers})"
    req.respond(nil, ':status' => Qeweney::Status::BAD_REQUEST)
  end
end
get_certificate(name) click to toggle source
# File lib/tipi/acme.rb, line 116
def get_certificate(name)
  entry = @store.get(name)
  return entry if entry

  provision_certificate(name).tap do |entry|
    @store.set(name, **entry)
  end
end
get_context(name) click to toggle source
# File lib/tipi/acme.rb, line 75
def get_context(name)
  @contexts[name] = setup_context(name)
end
get_expired_stamp(certificate) click to toggle source
# File lib/tipi/acme.rb, line 110
def get_expired_stamp(certificate)
  chain = parse_certificate(certificate)
  cert = chain.shift
  cert.not_after
end
localhost_context() click to toggle source
# File lib/tipi/acme.rb, line 125
def localhost_context
  @localhost_authority ||= Localhost::Authority.fetch
  @localhost_authority.server_context
end
parse_certificate(certificate) click to toggle source
# File lib/tipi/acme.rb, line 104
def parse_certificate(certificate)
  certificate
    .scan(CERTIFICATE_REGEXP)
    .map { |p|  OpenSSL::X509::Certificate.new(p.first) }
end
private_key() click to toggle source
# File lib/tipi/acme.rb, line 130
def private_key
  @private_key ||= OpenSSL::PKey::RSA.new(4096)
end
provision_certificate(name) click to toggle source
# File lib/tipi/acme.rb, line 152
def provision_certificate(name)
  p provision_certificate: name
  order = acme_client.new_order(identifiers: [name])
  authorization = order.authorizations.first
  challenge = authorization.http

  @challenge_handler.add(challenge)
  challenge.request_validation
  while challenge.status == 'pending'
    sleep(0.25)
    challenge.reload
  end
  raise ACME::Error, "Invalid CSR" if challenge.status == 'invalid'

  p challenge_status: challenge.status
  private_key = OpenSSL::PKey::RSA.new(4096)
  csr = Acme::Client::CertificateRequest.new(
    private_key: private_key,
    subject: { common_name: name }
  )
  order.finalize(csr: csr)
  while order.status == 'processing'
    sleep(0.25)
    order.reload
  end
  certificate = begin
    order.certificate(force_chain: 'DST Root CA X3')
  rescue Acme::Client::Error::ForcedChainNotFound
    order.certificate
  end
  expired_stamp = get_expired_stamp(certificate)
  puts "Certificate for #{name} expires: #{expired_stamp.inspect}"

  {
    private_key: private_key,
    certificate: certificate,
    expired_stamp: expired_stamp
  }
end
provision_context(name) click to toggle source
# File lib/tipi/acme.rb, line 85
def provision_context(name)
  return localhost_context if name =~ LOCALHOST_REGEXP

  info = get_certificate(name)
  ctx = OpenSSL::SSL::SSLContext.new
  chain = parse_certificate(info[:certificate])
  cert = chain.shift
  ctx.add_certificate(cert, info[:private_key], chain)
  ctx
end
run() click to toggle source
# File lib/tipi/acme.rb, line 64
def run
  loop do
    name, state = @requests.shift
    state[:ctx] = get_context(name)
  rescue => e
    state[:error] = e if state
  end
end
setup_acme_client() click to toggle source
# File lib/tipi/acme.rb, line 140
def setup_acme_client
  client = Acme::Client.new(
    private_key: private_key,
    directory: ACME_DIRECTORY
  )
  account = client.new_account(
    contact: 'mailto:info@noteflakes.com',
    terms_of_service_agreed: true
  )
  client
end
setup_context(name) click to toggle source
# File lib/tipi/acme.rb, line 79
def setup_context(name)
  ctx = provision_context(name)
  transfer_ctx_settings(ctx)
  ctx
end
setup_sni_callback() click to toggle source
# File lib/tipi/acme.rb, line 37
def setup_sni_callback
  @master_ctx.servername_cb = proc do |_socket, name|
    p servername_cb: name
    state = { ctx: nil }

    if name =~ IP_REGEXP
      @master_ctx
    else
      @requests << [name, state]
      wait_for_ctx(state)
      p name: name, error: state if state[:error]
      # Eventually we might want to return an error returned in
      # state[:error]. For the time being we handle errors by returning the
      # master context
      state[:ctx] || @master_ctx
    end
  end
end
transfer_ctx_settings(ctx) click to toggle source
# File lib/tipi/acme.rb, line 96
def transfer_ctx_settings(ctx)
  ctx.alpn_protocols = @master_ctx.alpn_protocols
  ctx.alpn_select_cb =  @master_ctx.alpn_select_cb
  ctx.ciphers = @master_ctx.ciphers
end
wait_for_ctx(state) click to toggle source
# File lib/tipi/acme.rb, line 56
def wait_for_ctx(state)
  period = 0.00001
  while !state[:ctx] && !state[:error]
    orig_sleep period
    period *= 2 if period < 0.1
  end
end