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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
9.7 KiB · text
History
b868907