9.7 KiB · text History b868907
defmodule GitGud.Packages.Signing.OpenBao.Session do
  @moduledoc """
  Holds the OpenBao token the signer uses, and keeps it alive.

  Two ways in, and the difference is whether a human has to remember
  something:

    * **Kubernetes auth** (`:role` set) — the app logs in with its own
      ServiceAccount JWT and gets a short-lived token back. Nothing is
      stored anywhere, the credential is the pod's identity, and a
      leaked token expires on its own. This is the one to use.

    * **A static token** (`:token` set) — what the first deploy did,
      because it needs no auth method configured. A `-period=768h`
      token is renewable rather than eternal, so this process renews it
      before it lapses. Without that, F-Droid signing breaks 32 days
      after a deploy, at a moment unrelated to anything anyone changed.

  Renewal happens at two thirds of the lease, so a failure still has a
  third of the lease to retry in rather than being the last word. Under
  Kubernetes auth a failed renewal falls back to logging in again; a
  static token has no such move, so it keeps the token it has and
  retries — that token is valid until it isn't.

  Started unconditionally but idle unless OpenBao is configured: no
  address, no work, and no state beyond an empty struct.
  """

  use GenServer

  require Logger

  # Renew at two thirds of the lease. Earlier wastes calls; later leaves
  # no room to retry.
  @renew_fraction 2 / 3

  # Kubernetes projects the pod's ServiceAccount token here.
  @jwt_path "/var/run/secrets/kubernetes.io/serviceaccount/token"

  defstruct token: nil, expires_at: nil, timer: nil

  @doc false
  def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__)

  @doc """
  The token to present to OpenBao, or `nil` when it isn't configured.

  Logs in on first ask rather than at boot: an instance that never
  serves an F-Droid repository should never talk to OpenBao at all, and
  a boot that waits on a sealed OpenBao is a boot that fails for the
  wrong reason.
  """
  @spec token() :: String.t() | nil
  def token do
    case GenServer.whereis(__MODULE__) do
      nil -> static_token()
      _pid -> GenServer.call(__MODULE__, :token, 20_000)
    end
  end

  @doc "Drop the cached token, so the next ask acquires a new one."
  @spec invalidate() :: :ok
  def invalidate do
    case GenServer.whereis(__MODULE__) do
      nil -> :ok
      _pid -> GenServer.cast(__MODULE__, :invalidate)
    end
  end

  @impl true
  def init(_opts), do: {:ok, %__MODULE__{}}

  @impl true
  def handle_call(:token, _from, state) do
    case ensure_token(state) do
      {:ok, state} -> {:reply, state.token, state}
      {:error, reason, state} -> {:reply, nil, log_failure(reason, state)}
    end
  end

  @impl true
  def handle_cast(:invalidate, state), do: {:noreply, clear(state)}

  @impl true
  def handle_info(:renew, state) do
    case renew(state) do
      {:ok, state} ->
        {:noreply, state}

      {:error, reason, state} ->
        Logger.warning("openbao: renewal failed (#{inspect(reason)})")

        if role() do
          {_result, state} = login_or_log(clear(state))
          {:noreply, state}
        else
          {:noreply, schedule(state, retry_after(state))}
        end
    end
  end

  def handle_info(_msg, state), do: {:noreply, state}

  # ── acquiring ────────────────────────────────────────────────────────

  defp ensure_token(%__MODULE__{token: token} = state) when is_binary(token) do
    if expired?(state), do: acquire(clear(state)), else: {:ok, state}
  end

  defp ensure_token(state), do: acquire(state)

  defp acquire(state) do
    cond do
      is_nil(address()) -> {:error, :not_configured, state}
      role() -> login(state)
      static_token() -> introspect(state, static_token())
      true -> {:error, :not_configured, state}
    end
  end

  defp login(state) do
    with {:ok, jwt} <- read_jwt(),
         {:ok, %{"auth" => auth}} <-
           post("/v1/auth/#{auth_mount()}/login", %{role: role(), jwt: jwt}, nil) do
      {:ok, store(state, auth["client_token"], auth["lease_duration"])}
    else
      {:error, reason} -> {:error, reason, state}
      {:ok, _unexpected} -> {:error, :unexpected_response, state}
    end
  end

  # A static token's lease has to be asked for: the Secret holds the
  # token, not its expiry.
  defp introspect(state, token) do
    case post("/v1/auth/token/lookup-self", nil, token) do
      {:ok, %{"data" => data}} ->
        {:ok, store(state, token, data["ttl"])}

      {:error, reason} ->
        # Can't read its own lease — use it anyway rather than refusing
        # to sign. `lookup-self` is granted by the default policy, but a
        # policy can be edited.
        Logger.warning("openbao: token lookup failed (#{inspect(reason)}); using it unrenewed")
        {:ok, %{state | token: token, expires_at: nil}}

      {:ok, _} ->
        {:ok, %{state | token: token, expires_at: nil}}
    end
  end

  defp renew(%__MODULE__{token: nil} = state), do: {:error, :no_token, state}

  defp renew(%__MODULE__{token: token} = state) do
    case post("/v1/auth/token/renew-self", %{}, token) do
      {:ok, %{"auth" => auth}} -> {:ok, store(state, token, auth["lease_duration"])}
      {:ok, _} -> {:error, :unexpected_response, state}
      {:error, reason} -> {:error, reason, state}
    end
  end

  defp login_or_log(state) do
    case login(state) do
      {:ok, state} -> {:ok, state}
      {:error, reason, state} -> {:error, log_failure(reason, state)}
    end
  end

  # ── lease bookkeeping ────────────────────────────────────────────────

  defp store(state, token, lease) when is_binary(token) do
    state = cancel_timer(state)
    lease = normalize_lease(lease)

    state = %{
      state
      | token: token,
        expires_at: lease && DateTime.add(DateTime.utc_now(), lease, :second)
    }

    if lease, do: schedule(state, round(lease * @renew_fraction)), else: state
  end

  defp store(state, _token, _lease), do: state

  # A zero or missing lease means "does not expire" (a root token, or
  # one issued with no TTL) — nothing to renew.
  defp normalize_lease(lease) when is_integer(lease) and lease > 0, do: lease
  defp normalize_lease(_), do: nil

  defp schedule(state, seconds) do
    seconds = max(seconds, 1)
    %{state | timer: Process.send_after(self(), :renew, seconds * 1000)}
  end

  defp retry_after(%__MODULE__{expires_at: nil}), do: 300

  defp retry_after(%__MODULE__{expires_at: expires_at}) do
    expires_at
    |> DateTime.diff(DateTime.utc_now())
    |> div(3)
    |> max(30)
    |> min(3600)
  end

  defp expired?(%__MODULE__{expires_at: nil}), do: false

  defp expired?(%__MODULE__{expires_at: expires_at}),
    do: DateTime.compare(DateTime.utc_now(), expires_at) != :lt

  defp clear(state), do: %{cancel_timer(state) | token: nil, expires_at: nil}

  defp cancel_timer(%__MODULE__{timer: nil} = state), do: state

  defp cancel_timer(%__MODULE__{timer: timer} = state) do
    Process.cancel_timer(timer)
    %{state | timer: nil}
  end

  defp log_failure(:not_configured, state), do: state

  defp log_failure(reason, state) do
    Logger.warning("openbao: could not obtain a token (#{inspect(reason)})")
    state
  end

  # ── http ─────────────────────────────────────────────────────────────

  defp post(path, body, token) do
    req =
      Req.new(
        method: :post,
        url: address() <> path,
        json: body,
        headers: token_headers(token),
        receive_timeout: 15_000,
        retry: :safe_transient
      )
      |> Req.merge(Application.get_env(:git_gud, :openbao_req_options, []))

    case Req.request(req) do
      {:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
        {:ok, body || %{}}

      {:ok, %Req.Response{status: 403, body: body}} ->
        {:error, {:denied, errors(body)}}

      {:ok, %Req.Response{status: 503}} ->
        {:error, :sealed}

      {:ok, %Req.Response{status: status, body: body}} ->
        {:error, {:http, status, errors(body)}}

      {:error, reason} ->
        {:error, {:unreachable, reason}}
    end
  end

  defp token_headers(nil), do: []
  defp token_headers(token), do: [{"x-vault-token", token}]

  defp errors(%{"errors" => [_ | _] = errors}), do: Enum.join(errors, "; ")
  defp errors(body) when is_binary(body), do: String.slice(body, 0, 200)
  defp errors(body), do: body |> inspect() |> String.slice(0, 200)

  defp read_jwt do
    case File.read(jwt_path()) do
      {:ok, jwt} -> {:ok, String.trim(jwt)}
      {:error, reason} -> {:error, {:no_service_account_token, reason}}
    end
  end

  # ── config ───────────────────────────────────────────────────────────

  defp config(key, default) do
    Application.get_env(:git_gud, GitGud.Packages.Signing.OpenBao, [])
    |> Keyword.get(key, default)
  end

  defp address do
    case config(:address, nil) do
      address when is_binary(address) and address != "" -> String.trim_trailing(address, "/")
      _ -> nil
    end
  end

  defp role do
    case config(:role, nil) do
      role when is_binary(role) and role != "" -> role
      _ -> nil
    end
  end

  defp static_token do
    case config(:token, nil) do
      token when is_binary(token) and token != "" -> token
      _ -> nil
    end
  end

  defp auth_mount, do: config(:auth_mount, "kubernetes")
  defp jwt_path, do: config(:jwt_path, @jwt_path)
end