defmodule Integrations.Forgejo do @moduledoc """ Forgejo / Gitea multi-step authenticated check. Proves the *whole* authenticated path, not just "the server responds": logs in with Basic Auth to mint a fresh, short-lived API token, uses that token to list the account's repositories, then deletes the token again. A plain liveness ping can't see an auth path that's broken behind a healthy-looking TCP/HTTP front door — this can. Collection only — see `Integrations.Forgejo.Display` (same package) for the dashboard panel. `Display.BundledDefault` auto-hooks it whenever this monitor starts, same as a single-module package would; a release without `raven_web` simply never compiles the display half and runs this monitor headless. ## Params * `:base_url` — Forgejo base URL, e.g. `http://forgejo-gitea-http.forgejo.svc.cluster.local:3000`. Required. * `:username` — Login username for the check account. Required. * `:password` — Login password for the check account. Required. Use a dedicated, low-privilege account — this integration mints and deletes a real API token on every check. * `:verify_repo` — `"owner/name"` of a repo the account can see. If set, the check is `:degraded` unless it appears in the authenticated repo list. * `:timeout_ms` — Per-request timeout in milliseconds. Defaults to `5000`. * `:verify_tls` — Verify the server's TLS certificate chain (only relevant for `https://` base URLs). Defaults to `true`. ## Health signal * `:up` — Login, token mint, and authenticated repo list all succeeded (and `:verify_repo`, if set, was found). * `:degraded` — The authenticated call succeeded but `:verify_repo` wasn't found in the list. * `:down` — Login failed (bad credentials, unreachable, timeout) or the authenticated call itself failed. """ use CodeNameRaven.Monitor require Logger @default_timeout_ms 5_000 @impl true def params_template do %{ base_url: "", username: "", password: "", verify_repo: "", timeout_ms: "5000" } end @impl true def params_schema do [ base_url: [type: :string, required: true, doc: "Forgejo base URL"], username: [type: :string, required: true, doc: "Login username for the check account"], password: [type: :string, required: true, doc: "Login password for the check account"], verify_repo: [type: :string, required: false, doc: "\"owner/name\" the account must be able to see"], timeout_ms: [type: :non_neg_integer, default: 5_000, doc: "Per-request timeout in milliseconds"], verify_tls: [type: :boolean, default: true, doc: "Verify TLS certificate chain"] ] end @impl true def target_uri(params) do case get_param(params, :base_url) do nil -> :none url -> {:ok, url} end end @impl true def identity_params(params) do %{base_url: get_param(params, :base_url), username: get_param(params, :username)} end # --------------------------------------------------------------------------- # Collect # --------------------------------------------------------------------------- @impl true def collect(params, state) do base_url = get_param(params, :base_url) username = get_param(params, :username) password = get_param(params, :password) cond do is_nil(base_url) -> {:error, "missing required param :base_url", state} is_nil(username) -> {:error, "missing required param :username", state} is_nil(password) -> {:error, "missing required param :password", state} true -> do_collect(base_url, username, password, params, state) end end defp do_collect(base_url, username, password, params, state) do timeout_ms = parse_int(get_param(params, :timeout_ms), @default_timeout_ms) verify_tls = truthy?(get_param(params, :verify_tls), true) verify_repo = get_param(params, :verify_repo) base_url = String.trim_trailing(base_url, "/") total_start = System.monotonic_time(:millisecond) case login(base_url, username, password, timeout_ms, verify_tls) do {:ok, token_id, token, login_ms} -> try do case list_repos(base_url, token, timeout_ms, verify_tls) do {:ok, repos, call_ms} -> repo_count = length(repos) repo_found = if verify_repo do Enum.any?(repos, &(&1["full_name"] == verify_repo)) else nil end total_ms = System.monotonic_time(:millisecond) - total_start {:ok, %{ login_ms: login_ms, call_ms: call_ms, total_ms: total_ms, repo_count: repo_count, verify_repo: verify_repo, repo_found: repo_found }, state} {:error, reason} -> {:error, "authenticated call failed: #{reason}", state} end after cleanup_token(base_url, username, password, token_id, timeout_ms, verify_tls) end {:error, reason} -> {:error, "login failed: #{reason}", state} end end # --------------------------------------------------------------------------- # Healthy? # --------------------------------------------------------------------------- @impl true def healthy?(%{repo_found: false}), do: :degraded def healthy?(_result), do: :up # --------------------------------------------------------------------------- # Metrics # --------------------------------------------------------------------------- @impl true def metrics(result) do %{ login_ms: result.login_ms, call_ms: result.call_ms, total_ms: result.total_ms, repo_count: result.repo_count } end # --------------------------------------------------------------------------- # Private — Forgejo API calls # --------------------------------------------------------------------------- defp login(base_url, username, password, timeout_ms, verify_tls) do started_at = System.monotonic_time(:millisecond) token_name = "raven-check-#{System.unique_integer([:positive])}" url = "#{base_url}/api/v1/users/#{URI.encode(username)}/tokens" req_opts = [ auth: {:basic, "#{username}:#{password}"}, json: %{name: token_name, scopes: ["read:user", "read:repository"]}, receive_timeout: timeout_ms, retry: false ] ++ tls_opts(base_url, verify_tls) case Req.post(url, req_opts) do {:ok, %{status: 201, body: %{"id" => id, "sha1" => sha1}}} -> {:ok, id, sha1, System.monotonic_time(:millisecond) - started_at} {:ok, %{status: 401}} -> {:error, "authentication failed — check username/password"} {:ok, %{status: status, body: body}} -> {:error, "HTTP #{status}: #{inspect(body)}"} {:error, reason} -> {:error, format_error(reason)} end end defp list_repos(base_url, token, timeout_ms, verify_tls) do started_at = System.monotonic_time(:millisecond) url = "#{base_url}/api/v1/user/repos" # Forgejo expects the literal header `Authorization: token `, not # a bearer scheme — set it directly rather than via Req's :auth option. req_opts = [ headers: [{"authorization", "token #{token}"}], receive_timeout: timeout_ms, retry: false ] ++ tls_opts(base_url, verify_tls) case Req.get(url, req_opts) do {:ok, %{status: 200, body: body}} when is_list(body) -> {:ok, body, System.monotonic_time(:millisecond) - started_at} {:ok, %{status: status, body: body}} -> {:error, "HTTP #{status}: #{inspect(body)}"} {:error, reason} -> {:error, format_error(reason)} end end defp cleanup_token(base_url, username, password, token_id, timeout_ms, verify_tls) do url = "#{base_url}/api/v1/users/#{URI.encode(username)}/tokens/#{token_id}" req_opts = [ auth: {:basic, "#{username}:#{password}"}, receive_timeout: timeout_ms, retry: false ] ++ tls_opts(base_url, verify_tls) case Req.delete(url, req_opts) do {:ok, %{status: status}} when status in [200, 204, 404] -> :ok {:ok, %{status: status}} -> Logger.warning("Integrations.Forgejo: token cleanup returned HTTP #{status}") {:error, reason} -> Logger.warning("Integrations.Forgejo: token cleanup failed: #{format_error(reason)}") end end defp tls_opts(base_url, verify_tls) do if String.starts_with?(base_url, "https") and not verify_tls do [connect_options: [transport_opts: [verify: :verify_none]]] else [] end end defp format_error(%{reason: reason}), do: inspect(reason) defp format_error(reason), do: inspect(reason) defp truthy?(nil, default), do: default defp truthy?("true", _), do: true defp truthy?(true, _), do: true defp truthy?(_, _), do: false defp get_param(params, key) when is_atom(key) do v = case Map.fetch(params, key) do {:ok, val} -> val :error -> params[to_string(key)] end if is_binary(v) and String.trim(v) == "", do: nil, else: v end defp parse_int(nil, default), do: default defp parse_int(v, _) when is_integer(v), do: v defp parse_int(v, default) when is_binary(v) do case Integer.parse(v) do {n, _} -> n :error -> default end end defp parse_int(_, default), do: default end