lib/certmgr.ex

defmodule Certmgr do
  @moduledoc """
  Manager of the status of the certificate. This genserver maintains
  a timer to renew the certificate one day ahead of expiration.

  If a certificate is not found in path, it is immediately generated.

  Additionally, by specifying an `:update_handler` in the `:zerossl`
  configuration it's possible for an application to get notified about
  certificate generation/renewal.
  """
  use GenServer

  require Logger

  @doc """
  Childspec of Certmgr genserver
  """
  def child_spec(arg) do
    %{
      id: __MODULE__,
      start: {__MODULE__, :start_link, [arg]}
    }
  end

  def start_link(args) do
    GenServer.start_link(__MODULE__, args, name: __MODULE__)
  end

  @impl true
  def init(opts) do
    manage_cert_renewal()
    {:ok, opts}
  end

  @impl true
  def handle_info(_info, state) do
    manage_cert_renewal()
    {:noreply, state}
  end

  @doc """
  Read key and certificate from file, or raise an error if
  it does not find them
  """
  def read_cert() do
    keyfile = Application.get_env(:zerossl, :keyfile)
    certfile = Application.get_env(:zerossl, :certfile)

    with {:filenames, {true, true}} <- {:filenames, {keyfile != nil, certfile != nil}},
         {:ok, key} <- File.read(keyfile),
         {:ok, cert} <- File.read(certfile) do
      Logger.debug("Loaded certfile #{certfile} and keyfile #{keyfile}")
      {key, cert}
    else
      {:filenames, _} ->
        raise("Missing certificate / key filename paths")

      {:error, _} ->
        Logger.info("File #{keyfile} or #{certfile} missing")
        {nil, nil}
    end
  end

  @doc """
  Write key and certificate to the files specified by the config
  """
  def write_cert(key, cert) do
    keyfile = Application.get_env(:zerossl, :keyfile)
    certfile = Application.get_env(:zerossl, :certfile)
    Logger.debug("Storing certfile #{certfile} and keyfile #{keyfile}")

    if certfile != nil and keyfile != nil do
      File.write(certfile, cert)
      File.write(keyfile, key)
    end

    :ok
  end

  @days_milliseconds 24 * 60 * 60 * 1000
  @doc """
  Manage the certificate renewal by checking it's not_before and not_after Validities.

  Every time the certificate has less than one day of validity, a renewal is issued.
  Otherwise a timer is set to wait until the moment such last day of validity is reached,
  to trigger the renewal again.
  """
  @spec manage_cert_renewal() :: :ok
  def manage_cert_renewal() do
    with {key, cert} when not is_nil(key) and not is_nil(cert) <- read_cert(),
         days_left <- days_left(cert) do
      case days_left <= 0 do
        true ->
          renew_certificate()
          manage_cert_renewal()

        false ->
          Logger.debug("Valid cert, renewing key/cert in #{days_left} days")
          Process.send_after(self(), :work, days_left * @days_milliseconds)
          :ok
      end
    else
      _ ->
        renew_certificate()
        manage_cert_renewal()
    end
  end

  defp renew_certificate() do
    domain = Application.get_env(:zerossl, :cert_domain)

    Logger.debug("Renewing certificate now!")

    {cert_priv_key, public_cert} =
      case Application.get_env(:zerossl, :selfsigned, false) do
        true -> Selfsigned.gen_cert(domain)
        false -> Acmev2.gen_cert(domain)
      end

    write_cert(cert_priv_key, public_cert)
    notify_update_handler(cert_priv_key, public_cert)
  end

  defp notify_update_handler(cert_priv_key, public_cert) do
    case Application.get_env(:zerossl, :update_handler) do
      nil -> :ok
      module -> module.update(cert_priv_key, public_cert)
    end
  end

  def days_left(cert) do
    {:Validity, {:utcTime, not_before}, {:utcTime, not_after}} =
      hd(:public_key.pem_decode(cert))
      |> :public_key.pem_entry_decode()
      |> X509.Certificate.validity()

    <<year::binary-size(4), month::binary-size(2), day::binary-size(2), hour::binary-size(2),
      minute::binary-size(2), _rest::binary>> = "20#{not_before}"

    not_before =
      DateTime.new!(
        Date.new!(i2s(year), i2s(month), i2s(day)),
        Time.new!(i2s(hour), i2s(minute), 0),
        "Etc/UTC"
      )

    <<year::binary-size(4), month::binary-size(2), day::binary-size(2), hour::binary-size(2),
      minute::binary-size(2), _rest::binary>> = "20#{not_after}"

    not_after =
      DateTime.new!(
        Date.new!(i2s(year), i2s(month), i2s(day)),
        Time.new!(i2s(hour), i2s(minute), 0),
        "Etc/UTC"
      )

    # Logger.info("Not before: #{inspect(not_before)}, not after: #{inspect(not_after)}")

    now = DateTime.now!("Etc/UTC")

    case DateTime.compare(not_before, now) == :lt and DateTime.compare(now, not_after) == :lt do
      true -> DateTime.diff(not_after, now, :day) - 1
      false -> 0
    end
  end

  defp i2s(str) when is_binary(str), do: String.to_integer(str)
end