Restrict metrics access and add optional dedicated metrics listener

This commit is contained in:
2026-03-14 04:53:51 +01:00
parent 36365710a8
commit bfdb06b203
10 changed files with 309 additions and 8 deletions

View File

@@ -13,6 +13,7 @@ defmodule Parrhesia.Application do
Parrhesia.Auth.Supervisor,
Parrhesia.Policy.Supervisor,
Parrhesia.Web.Endpoint,
Parrhesia.Web.MetricsEndpoint,
Parrhesia.Tasks.Supervisor
]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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