Restrict metrics access and add optional dedicated metrics listener
This commit is contained in:
16
README.md
16
README.md
@@ -6,6 +6,7 @@ It exposes:
|
|||||||
- a WebSocket relay endpoint at `/relay`
|
- a WebSocket relay endpoint at `/relay`
|
||||||
- NIP-11 relay info on `GET /relay` with `Accept: application/nostr+json`
|
- NIP-11 relay info on `GET /relay` with `Accept: application/nostr+json`
|
||||||
- operational HTTP endpoints (`/health`, `/ready`, `/metrics`)
|
- 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)
|
- a NIP-86-style management API at `POST /management` (NIP-98 auth)
|
||||||
|
|
||||||
## Supported NIPs
|
## Supported NIPs
|
||||||
@@ -56,7 +57,7 @@ ws://localhost:4000/relay
|
|||||||
|
|
||||||
- `GET /health` -> `ok`
|
- `GET /health` -> `ok`
|
||||||
- `GET /ready` -> readiness status
|
- `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
|
- `GET /relay` + `Accept: application/nostr+json` -> NIP-11 document
|
||||||
- `POST /management` -> management API (requires NIP-98 auth)
|
- `POST /management` -> management API (requires NIP-98 auth)
|
||||||
|
|
||||||
@@ -81,7 +82,20 @@ config :parrhesia, Parrhesia.Web.Endpoint,
|
|||||||
ip: {0, 0, 0, 0},
|
ip: {0, 0, 0, 0},
|
||||||
port: 4000
|
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,
|
config :parrhesia,
|
||||||
|
metrics: [
|
||||||
|
enabled_on_main_endpoint: false,
|
||||||
|
public: false,
|
||||||
|
private_networks_only: true,
|
||||||
|
allowed_cidrs: [],
|
||||||
|
auth_token: nil
|
||||||
|
],
|
||||||
limits: [
|
limits: [
|
||||||
max_frame_bytes: 1_048_576,
|
max_frame_bytes: 1_048_576,
|
||||||
max_event_bytes: 262_144,
|
max_event_bytes: 262_144,
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ config :parrhesia,
|
|||||||
marmot_push_max_server_recipients: 1,
|
marmot_push_max_server_recipients: 1,
|
||||||
management_auth_required: true
|
management_auth_required: true
|
||||||
],
|
],
|
||||||
|
metrics: [
|
||||||
|
enabled_on_main_endpoint: true,
|
||||||
|
public: false,
|
||||||
|
private_networks_only: true,
|
||||||
|
allowed_cidrs: [],
|
||||||
|
auth_token: nil
|
||||||
|
],
|
||||||
features: [
|
features: [
|
||||||
verify_event_signatures: true,
|
verify_event_signatures: true,
|
||||||
nip_45_count: true,
|
nip_45_count: true,
|
||||||
@@ -63,6 +70,11 @@ config :parrhesia,
|
|||||||
|
|
||||||
config :parrhesia, Parrhesia.Web.Endpoint, port: 4000
|
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]
|
config :parrhesia, ecto_repos: [Parrhesia.Repo]
|
||||||
|
|
||||||
import_config "#{config_env()}.exs"
|
import_config "#{config_env()}.exs"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ defmodule Parrhesia.Application do
|
|||||||
Parrhesia.Auth.Supervisor,
|
Parrhesia.Auth.Supervisor,
|
||||||
Parrhesia.Policy.Supervisor,
|
Parrhesia.Policy.Supervisor,
|
||||||
Parrhesia.Web.Endpoint,
|
Parrhesia.Web.Endpoint,
|
||||||
|
Parrhesia.Web.MetricsEndpoint,
|
||||||
Parrhesia.Tasks.Supervisor
|
Parrhesia.Tasks.Supervisor
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
28
lib/parrhesia/web/metrics.ex
Normal file
28
lib/parrhesia/web/metrics.ex
Normal 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
|
||||||
138
lib/parrhesia/web/metrics_access.ex
Normal file
138
lib/parrhesia/web/metrics_access.ex
Normal 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
|
||||||
34
lib/parrhesia/web/metrics_endpoint.ex
Normal file
34
lib/parrhesia/web/metrics_endpoint.ex
Normal 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
|
||||||
18
lib/parrhesia/web/metrics_router.ex
Normal file
18
lib/parrhesia/web/metrics_router.ex
Normal 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
|
||||||
@@ -3,8 +3,8 @@ defmodule Parrhesia.Web.Router do
|
|||||||
|
|
||||||
use Plug.Router
|
use Plug.Router
|
||||||
|
|
||||||
alias Parrhesia.Telemetry
|
|
||||||
alias Parrhesia.Web.Management
|
alias Parrhesia.Web.Management
|
||||||
|
alias Parrhesia.Web.Metrics
|
||||||
alias Parrhesia.Web.Readiness
|
alias Parrhesia.Web.Readiness
|
||||||
alias Parrhesia.Web.RelayInfo
|
alias Parrhesia.Web.RelayInfo
|
||||||
|
|
||||||
@@ -30,11 +30,11 @@ defmodule Parrhesia.Web.Router do
|
|||||||
end
|
end
|
||||||
|
|
||||||
get "/metrics" do
|
get "/metrics" do
|
||||||
body = TelemetryMetricsPrometheus.Core.scrape(Telemetry.prometheus_reporter())
|
if Metrics.enabled_on_main_endpoint?() do
|
||||||
|
Metrics.handle(conn)
|
||||||
conn
|
else
|
||||||
|> put_resp_content_type("text/plain")
|
send_resp(conn, 404, "not found")
|
||||||
|> send_resp(200, body)
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
post "/management" do
|
post "/management" do
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ defmodule Parrhesia.ApplicationTest do
|
|||||||
assert is_pid(Process.whereis(Parrhesia.Auth.Supervisor))
|
assert is_pid(Process.whereis(Parrhesia.Auth.Supervisor))
|
||||||
assert is_pid(Process.whereis(Parrhesia.Policy.Supervisor))
|
assert is_pid(Process.whereis(Parrhesia.Policy.Supervisor))
|
||||||
assert is_pid(Process.whereis(Parrhesia.Web.Endpoint))
|
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 is_pid(Process.whereis(Parrhesia.Tasks.Supervisor))
|
||||||
|
|
||||||
assert Enum.any?(Supervisor.which_children(Parrhesia.Web.Endpoint), fn {_id, pid, _type,
|
assert Enum.any?(Supervisor.which_children(Parrhesia.Web.Endpoint), fn {_id, pid, _type,
|
||||||
|
|||||||
@@ -43,13 +43,68 @@ defmodule Parrhesia.Web.RouterTest do
|
|||||||
assert 11 in body["supported_nips"]
|
assert 11 in body["supported_nips"]
|
||||||
end
|
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([])
|
conn = conn(:get, "/metrics") |> Router.call([])
|
||||||
|
|
||||||
assert conn.status == 200
|
assert conn.status == 200
|
||||||
assert get_resp_header(conn, "content-type") == ["text/plain; charset=utf-8"]
|
assert get_resp_header(conn, "content-type") == ["text/plain; charset=utf-8"]
|
||||||
end
|
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
|
test "POST /management requires authorization" do
|
||||||
conn =
|
conn =
|
||||||
conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}}))
|
conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}}))
|
||||||
|
|||||||
Reference in New Issue
Block a user