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

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

View File

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

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

View File

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

View File

@@ -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" => %{}}))