diff --git a/README.md b/README.md index 86cf783..d76f317 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ It exposes: - a WebSocket relay endpoint at `/relay` - NIP-11 relay info on `GET /relay` with `Accept: application/nostr+json` - operational HTTP endpoints (`/health`, `/ready`, `/metrics`) + - `/metrics` is restricted by default to private/loopback source IPs - a NIP-86-style management API at `POST /management` (NIP-98 auth) ## Supported NIPs @@ -56,7 +57,7 @@ ws://localhost:4000/relay - `GET /health` -> `ok` - `GET /ready` -> readiness status -- `GET /metrics` -> Prometheus metrics +- `GET /metrics` -> Prometheus metrics (private/loopback source IPs by default) - `GET /relay` + `Accept: application/nostr+json` -> NIP-11 document - `POST /management` -> management API (requires NIP-98 auth) @@ -81,7 +82,20 @@ config :parrhesia, Parrhesia.Web.Endpoint, ip: {0, 0, 0, 0}, port: 4000 +# Optional dedicated metrics listener (keep this internal) +config :parrhesia, Parrhesia.Web.MetricsEndpoint, + enabled: true, + ip: {127, 0, 0, 1}, + port: 9568 + config :parrhesia, + metrics: [ + enabled_on_main_endpoint: false, + public: false, + private_networks_only: true, + allowed_cidrs: [], + auth_token: nil + ], limits: [ max_frame_bytes: 1_048_576, max_event_bytes: 262_144, diff --git a/config/config.exs b/config/config.exs index 8ecd920..3917628 100644 --- a/config/config.exs +++ b/config/config.exs @@ -47,6 +47,13 @@ config :parrhesia, marmot_push_max_server_recipients: 1, management_auth_required: true ], + metrics: [ + enabled_on_main_endpoint: true, + public: false, + private_networks_only: true, + allowed_cidrs: [], + auth_token: nil + ], features: [ verify_event_signatures: true, nip_45_count: true, @@ -63,6 +70,11 @@ config :parrhesia, config :parrhesia, Parrhesia.Web.Endpoint, port: 4000 +config :parrhesia, Parrhesia.Web.MetricsEndpoint, + enabled: false, + ip: {127, 0, 0, 1}, + port: 9568 + config :parrhesia, ecto_repos: [Parrhesia.Repo] import_config "#{config_env()}.exs" diff --git a/lib/parrhesia/application.ex b/lib/parrhesia/application.ex index 8a81944..6528164 100644 --- a/lib/parrhesia/application.ex +++ b/lib/parrhesia/application.ex @@ -13,6 +13,7 @@ defmodule Parrhesia.Application do Parrhesia.Auth.Supervisor, Parrhesia.Policy.Supervisor, Parrhesia.Web.Endpoint, + Parrhesia.Web.MetricsEndpoint, Parrhesia.Tasks.Supervisor ] diff --git a/lib/parrhesia/web/metrics.ex b/lib/parrhesia/web/metrics.ex new file mode 100644 index 0000000..8dd82cd --- /dev/null +++ b/lib/parrhesia/web/metrics.ex @@ -0,0 +1,28 @@ +defmodule Parrhesia.Web.Metrics do + @moduledoc false + + import Plug.Conn + + alias Parrhesia.Telemetry + alias Parrhesia.Web.MetricsAccess + + @spec enabled_on_main_endpoint?() :: boolean() + def enabled_on_main_endpoint? do + :parrhesia + |> Application.get_env(:metrics, []) + |> Keyword.get(:enabled_on_main_endpoint, true) + end + + @spec handle(Plug.Conn.t()) :: Plug.Conn.t() + def handle(conn) do + if MetricsAccess.allowed?(conn) do + body = TelemetryMetricsPrometheus.Core.scrape(Telemetry.prometheus_reporter()) + + conn + |> put_resp_content_type("text/plain") + |> send_resp(200, body) + else + send_resp(conn, 403, "forbidden") + end + end +end diff --git a/lib/parrhesia/web/metrics_access.ex b/lib/parrhesia/web/metrics_access.ex new file mode 100644 index 0000000..e855027 --- /dev/null +++ b/lib/parrhesia/web/metrics_access.ex @@ -0,0 +1,138 @@ +defmodule Parrhesia.Web.MetricsAccess do + @moduledoc false + + import Plug.Conn + import Bitwise + + @private_cidrs [ + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "::1/128", + "fc00::/7", + "fe80::/10" + ] + + @spec allowed?(Plug.Conn.t()) :: boolean() + def allowed?(conn) do + if metrics_public?() do + true + else + token_allowed?(conn) and network_allowed?(conn) + end + end + + defp token_allowed?(conn) do + case configured_auth_token() do + nil -> + true + + token -> + provided_token(conn) == token + end + end + + defp provided_token(conn) do + conn + |> get_req_header("authorization") + |> List.first() + |> normalize_authorization_header() + end + + defp normalize_authorization_header("Bearer " <> token), do: token + defp normalize_authorization_header(token) when is_binary(token), do: token + defp normalize_authorization_header(_header), do: nil + + defp network_allowed?(conn) do + remote_ip = conn.remote_ip + + cond do + configured_allowed_cidrs() != [] -> + Enum.any?(configured_allowed_cidrs(), &ip_in_cidr?(remote_ip, &1)) + + metrics_private_networks_only?() -> + Enum.any?(@private_cidrs, &ip_in_cidr?(remote_ip, &1)) + + true -> + true + end + end + + defp ip_in_cidr?(ip, cidr) do + with {network, prefix_len} <- parse_cidr(cidr), + {:ok, ip_size, ip_value} <- ip_to_int(ip), + {:ok, network_size, network_value} <- ip_to_int(network), + true <- ip_size == network_size, + true <- prefix_len >= 0, + true <- prefix_len <= ip_size do + mask = network_mask(ip_size, prefix_len) + (ip_value &&& mask) == (network_value &&& mask) + else + _other -> false + end + end + + defp parse_cidr(cidr) when is_binary(cidr) do + case String.split(cidr, "/", parts: 2) do + [address, prefix_str] -> + with {prefix_len, ""} <- Integer.parse(prefix_str), + {:ok, ip} <- :inet.parse_address(String.to_charlist(address)) do + {ip, prefix_len} + else + _other -> :error + end + + _other -> + :error + end + end + + defp parse_cidr(_cidr), do: :error + + defp ip_to_int({a, b, c, d}) do + {:ok, 32, (a <<< 24) + (b <<< 16) + (c <<< 8) + d} + end + + defp ip_to_int({a, b, c, d, e, f, g, h}) do + {:ok, 128, + (a <<< 112) + (b <<< 96) + (c <<< 80) + (d <<< 64) + (e <<< 48) + (f <<< 32) + (g <<< 16) + + h} + end + + defp ip_to_int(_ip), do: :error + + defp network_mask(_size, 0), do: 0 + + defp network_mask(size, prefix_len) do + all_ones = (1 <<< size) - 1 + all_ones <<< (size - prefix_len) + end + + defp configured_allowed_cidrs do + :parrhesia + |> Application.get_env(:metrics, []) + |> Keyword.get(:allowed_cidrs, []) + |> Enum.filter(&is_binary/1) + end + + defp configured_auth_token do + case :parrhesia |> Application.get_env(:metrics, []) |> Keyword.get(:auth_token) do + token when is_binary(token) and token != "" -> token + _other -> nil + end + end + + defp metrics_public? do + :parrhesia + |> Application.get_env(:metrics, []) + |> Keyword.get(:public, false) + end + + defp metrics_private_networks_only? do + :parrhesia + |> Application.get_env(:metrics, []) + |> Keyword.get(:private_networks_only, true) + end +end diff --git a/lib/parrhesia/web/metrics_endpoint.ex b/lib/parrhesia/web/metrics_endpoint.ex new file mode 100644 index 0000000..6f81081 --- /dev/null +++ b/lib/parrhesia/web/metrics_endpoint.ex @@ -0,0 +1,34 @@ +defmodule Parrhesia.Web.MetricsEndpoint do + @moduledoc """ + Optional dedicated HTTP listener for Prometheus metrics scraping. + """ + + use Supervisor + + def start_link(init_arg \\ []) do + Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) + end + + @impl true + def init(init_arg) do + options = bandit_options(init_arg) + + children = + if Keyword.get(options, :enabled, false) do + [{Bandit, Keyword.delete(options, :enabled)}] + else + [] + end + + Supervisor.init(children, strategy: :one_for_one) + end + + defp bandit_options(overrides) do + configured = Application.get_env(:parrhesia, __MODULE__, []) + + configured + |> Keyword.merge(overrides) + |> Keyword.put_new(:scheme, :http) + |> Keyword.put_new(:plug, Parrhesia.Web.MetricsRouter) + end +end diff --git a/lib/parrhesia/web/metrics_router.ex b/lib/parrhesia/web/metrics_router.ex new file mode 100644 index 0000000..72fd5b4 --- /dev/null +++ b/lib/parrhesia/web/metrics_router.ex @@ -0,0 +1,18 @@ +defmodule Parrhesia.Web.MetricsRouter do + @moduledoc false + + use Plug.Router + + alias Parrhesia.Web.Metrics + + plug(:match) + plug(:dispatch) + + get "/metrics" do + Metrics.handle(conn) + end + + match _ do + send_resp(conn, 404, "not found") + end +end diff --git a/lib/parrhesia/web/router.ex b/lib/parrhesia/web/router.ex index 45d58b6..824f701 100644 --- a/lib/parrhesia/web/router.ex +++ b/lib/parrhesia/web/router.ex @@ -3,8 +3,8 @@ defmodule Parrhesia.Web.Router do use Plug.Router - alias Parrhesia.Telemetry alias Parrhesia.Web.Management + alias Parrhesia.Web.Metrics alias Parrhesia.Web.Readiness alias Parrhesia.Web.RelayInfo @@ -30,11 +30,11 @@ defmodule Parrhesia.Web.Router do end get "/metrics" do - body = TelemetryMetricsPrometheus.Core.scrape(Telemetry.prometheus_reporter()) - - conn - |> put_resp_content_type("text/plain") - |> send_resp(200, body) + if Metrics.enabled_on_main_endpoint?() do + Metrics.handle(conn) + else + send_resp(conn, 404, "not found") + end end post "/management" do diff --git a/test/parrhesia/application_test.exs b/test/parrhesia/application_test.exs index 9135608..c729275 100644 --- a/test/parrhesia/application_test.exs +++ b/test/parrhesia/application_test.exs @@ -10,6 +10,7 @@ defmodule Parrhesia.ApplicationTest do assert is_pid(Process.whereis(Parrhesia.Auth.Supervisor)) assert is_pid(Process.whereis(Parrhesia.Policy.Supervisor)) assert is_pid(Process.whereis(Parrhesia.Web.Endpoint)) + assert is_pid(Process.whereis(Parrhesia.Web.MetricsEndpoint)) assert is_pid(Process.whereis(Parrhesia.Tasks.Supervisor)) assert Enum.any?(Supervisor.which_children(Parrhesia.Web.Endpoint), fn {_id, pid, _type, diff --git a/test/parrhesia/web/router_test.exs b/test/parrhesia/web/router_test.exs index b8632bf..4403f7c 100644 --- a/test/parrhesia/web/router_test.exs +++ b/test/parrhesia/web/router_test.exs @@ -43,13 +43,68 @@ defmodule Parrhesia.Web.RouterTest do assert 11 in body["supported_nips"] end - test "GET /metrics returns prometheus payload" do + test "GET /metrics returns prometheus payload for private-network clients" do conn = conn(:get, "/metrics") |> Router.call([]) assert conn.status == 200 assert get_resp_header(conn, "content-type") == ["text/plain; charset=utf-8"] end + test "GET /metrics denies public-network clients by default" do + conn = conn(:get, "/metrics") + conn = %{conn | remote_ip: {8, 8, 8, 8}} + conn = Router.call(conn, []) + + assert conn.status == 403 + assert conn.resp_body == "forbidden" + end + + test "GET /metrics can be disabled on the main endpoint" do + previous_metrics = Application.get_env(:parrhesia, :metrics, []) + + Application.put_env( + :parrhesia, + :metrics, + Keyword.put(previous_metrics, :enabled_on_main_endpoint, false) + ) + + on_exit(fn -> + Application.put_env(:parrhesia, :metrics, previous_metrics) + end) + + conn = conn(:get, "/metrics") |> Router.call([]) + + assert conn.status == 404 + assert conn.resp_body == "not found" + end + + test "GET /metrics accepts bearer auth when configured" do + previous_metrics = Application.get_env(:parrhesia, :metrics, []) + + Application.put_env( + :parrhesia, + :metrics, + previous_metrics + |> Keyword.put(:private_networks_only, false) + |> Keyword.put(:auth_token, "secret-token") + ) + + on_exit(fn -> + Application.put_env(:parrhesia, :metrics, previous_metrics) + end) + + denied_conn = conn(:get, "/metrics") |> Router.call([]) + + assert denied_conn.status == 403 + + allowed_conn = + conn(:get, "/metrics") + |> put_req_header("authorization", "Bearer secret-token") + |> Router.call([]) + + assert allowed_conn.status == 200 + end + test "POST /management requires authorization" do conn = conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}}))