Add listener TLS support and pinning tests
This commit is contained in:
@@ -19,6 +19,13 @@ It exposes:
|
||||
- operational HTTP endpoints such as `/health`, `/ready`, and `/metrics` on listeners that enable them
|
||||
- a NIP-86-style management API at `POST /management` on listeners that enable the `admin` feature
|
||||
|
||||
Listeners can run in plain HTTP, HTTPS, mutual TLS, or proxy-terminated TLS modes. The current TLS implementation supports:
|
||||
|
||||
- server TLS on listener sockets
|
||||
- optional client certificate admission with listener-side client pin checks
|
||||
- proxy-asserted client TLS identity on trusted proxy hops
|
||||
- admin-triggered certificate reload by restarting an individual listener from disk
|
||||
|
||||
## Supported NIPs
|
||||
|
||||
Current `supported_nips` list:
|
||||
@@ -174,6 +181,8 @@ CSV env vars use comma-separated values. Boolean env vars accept `1/0`, `true/fa
|
||||
| `:metrics.bind.port` | `PARRHESIA_METRICS_ENDPOINT_PORT` | `9568` | Optional dedicated metrics listener port |
|
||||
| `:metrics.enabled` | `PARRHESIA_METRICS_ENDPOINT_ENABLED` | `false` | Enables the optional dedicated metrics listener |
|
||||
|
||||
Listener `transport.tls` supports `:disabled`, `:server`, `:mutual`, and `:proxy_terminated`. For TLS-enabled listeners, the main config-file fields are `certfile`, `keyfile`, optional `cacertfile`, optional `cipher_suite`, optional `client_pins`, and `proxy_headers` for proxy-terminated identity.
|
||||
|
||||
#### `:limits`
|
||||
|
||||
| Atom key | ENV | Default |
|
||||
|
||||
@@ -103,8 +103,10 @@ Failure model:
|
||||
Ingress model:
|
||||
|
||||
- Ingress is defined through `config :parrhesia, :listeners, ...`.
|
||||
- Each listener has its own bind/transport settings, proxy trust, network allowlist, enabled features (`nostr`, `admin`, `metrics`), auth requirements, and baseline read/write ACL.
|
||||
- Each listener has its own bind/transport settings, TLS mode, proxy trust, network allowlist, enabled features (`nostr`, `admin`, `metrics`), auth requirements, and baseline read/write ACL.
|
||||
- Listeners can therefore expose different security postures, for example a public relay listener and a VPN-only sync-capable listener.
|
||||
- TLS-capable listeners support direct server TLS, mutual TLS with optional client pin checks, and proxy-terminated TLS identity on explicitly trusted proxy hops.
|
||||
- Certificate reload is currently implemented as admin-triggered listener restart from disk rather than background file watching.
|
||||
|
||||
## 5) Core runtime components
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ defmodule Parrhesia.API.Admin do
|
||||
alias Parrhesia.API.Identity
|
||||
alias Parrhesia.API.Sync
|
||||
alias Parrhesia.Storage
|
||||
alias Parrhesia.Web.Endpoint
|
||||
|
||||
@supported_acl_methods ~w(acl_grant acl_revoke acl_list)
|
||||
@supported_identity_methods ~w(identity_ensure identity_get identity_import identity_rotate)
|
||||
@supported_listener_methods ~w(listener_reload)
|
||||
@supported_sync_methods ~w(
|
||||
sync_get_server
|
||||
sync_health
|
||||
@@ -96,7 +98,8 @@ defmodule Parrhesia.API.Admin do
|
||||
end
|
||||
|
||||
(storage_supported ++
|
||||
@supported_acl_methods ++ @supported_identity_methods ++ @supported_sync_methods)
|
||||
@supported_acl_methods ++
|
||||
@supported_identity_methods ++ @supported_listener_methods ++ @supported_sync_methods)
|
||||
|> Enum.uniq()
|
||||
|> Enum.sort()
|
||||
end
|
||||
@@ -111,6 +114,22 @@ defmodule Parrhesia.API.Admin do
|
||||
Identity.import(params)
|
||||
end
|
||||
|
||||
defp listener_reload(params) do
|
||||
case normalize_listener_id(fetch_value(params, :id)) do
|
||||
:all ->
|
||||
Endpoint.reload_all()
|
||||
|> ok_result()
|
||||
|
||||
{:ok, listener_id} ->
|
||||
listener_id
|
||||
|> Endpoint.reload_listener()
|
||||
|> ok_result()
|
||||
|
||||
:error ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
defp sync_put_server(params, opts), do: Sync.put_server(params, opts)
|
||||
|
||||
defp sync_remove_server(params, opts) do
|
||||
@@ -173,6 +192,7 @@ defmodule Parrhesia.API.Admin do
|
||||
defp execute_builtin("identity_ensure", params, _opts), do: identity_ensure(params)
|
||||
defp execute_builtin("identity_import", params, _opts), do: identity_import(params)
|
||||
defp execute_builtin("identity_rotate", params, _opts), do: identity_rotate(params)
|
||||
defp execute_builtin("listener_reload", params, _opts), do: listener_reload(params)
|
||||
defp execute_builtin("sync_put_server", params, opts), do: sync_put_server(params, opts)
|
||||
defp execute_builtin("sync_remove_server", params, opts), do: sync_remove_server(params, opts)
|
||||
defp execute_builtin("sync_get_server", params, opts), do: sync_get_server(params, opts)
|
||||
@@ -203,6 +223,35 @@ defmodule Parrhesia.API.Admin do
|
||||
defp maybe_put_opt(opts, _key, nil), do: opts
|
||||
defp maybe_put_opt(opts, key, value), do: Keyword.put(opts, key, value)
|
||||
|
||||
defp ok_result(:ok), do: {:ok, %{"ok" => true}}
|
||||
defp ok_result({:error, _reason} = error), do: error
|
||||
defp ok_result(other), do: other
|
||||
|
||||
defp normalize_listener_id(nil), do: :all
|
||||
|
||||
defp normalize_listener_id(listener_id) when is_atom(listener_id) do
|
||||
{:ok, listener_id}
|
||||
end
|
||||
|
||||
defp normalize_listener_id(listener_id) when is_binary(listener_id) do
|
||||
case Supervisor.which_children(Endpoint) do
|
||||
children when is_list(children) ->
|
||||
Enum.find_value(children, :error, &match_listener_child(&1, listener_id))
|
||||
|
||||
_other ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_listener_id(_listener_id), do: :error
|
||||
|
||||
defp match_listener_child({{:listener, id}, _pid, _type, _modules}, listener_id) do
|
||||
normalized_id = Atom.to_string(id)
|
||||
if normalized_id == listener_id, do: {:ok, id}, else: false
|
||||
end
|
||||
|
||||
defp match_listener_child(_child, _listener_id), do: false
|
||||
|
||||
defp fetch_required_string(map, key) do
|
||||
case fetch_value(map, key) do
|
||||
value when is_binary(value) and value != "" -> {:ok, value}
|
||||
|
||||
@@ -9,6 +9,7 @@ defmodule Parrhesia.API.RequestContext do
|
||||
remote_ip: nil,
|
||||
subscription_id: nil,
|
||||
peer_id: nil,
|
||||
transport_identity: nil,
|
||||
metadata: %{}
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
@@ -18,6 +19,7 @@ defmodule Parrhesia.API.RequestContext do
|
||||
remote_ip: String.t() | nil,
|
||||
subscription_id: String.t() | nil,
|
||||
peer_id: String.t() | nil,
|
||||
transport_identity: map() | nil,
|
||||
metadata: map()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
defmodule Parrhesia.Sync.TLS do
|
||||
@moduledoc false
|
||||
|
||||
require Record
|
||||
|
||||
Record.defrecordp(
|
||||
:otp_certificate,
|
||||
Record.extract(:OTPCertificate, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
|
||||
)
|
||||
|
||||
Record.defrecordp(
|
||||
:otp_tbs_certificate,
|
||||
Record.extract(:OTPTBSCertificate, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
|
||||
)
|
||||
|
||||
@type tls_config :: %{
|
||||
mode: :required | :disabled,
|
||||
hostname: String.t(),
|
||||
@@ -44,10 +32,19 @@ defmodule Parrhesia.Sync.TLS do
|
||||
server_name_indication: String.to_charlist(hostname),
|
||||
customize_hostname_check: [
|
||||
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
|
||||
],
|
||||
verify_fun:
|
||||
{&verify_certificate/3, %{pins: MapSet.new(Enum.map(pins, & &1.value)), matched?: false}}
|
||||
]
|
||||
]
|
||||
|> maybe_put_verify_fun(pins)
|
||||
end
|
||||
|
||||
defp maybe_put_verify_fun(options, []), do: options
|
||||
|
||||
defp maybe_put_verify_fun(options, pins) do
|
||||
Keyword.put(
|
||||
options,
|
||||
:verify_fun,
|
||||
{&verify_certificate/3, %{pins: MapSet.new(Enum.map(pins, & &1.value)), matched?: false}}
|
||||
)
|
||||
end
|
||||
|
||||
defp verify_certificate(_cert, :valid_peer, %{matched?: true} = state), do: {:valid, state}
|
||||
@@ -56,7 +53,21 @@ defmodule Parrhesia.Sync.TLS do
|
||||
defp verify_certificate(_cert, {:bad_cert, reason}, _state), do: {:fail, reason}
|
||||
|
||||
defp verify_certificate(cert, _event, state) when is_binary(cert) do
|
||||
matched? = MapSet.member?(state.pins, spki_pin(cert))
|
||||
matched? = MapSet.member?(state.pins, spki_pin_from_verify(cert))
|
||||
{:valid, %{state | matched?: state.matched? or matched?}}
|
||||
rescue
|
||||
_error -> {:fail, :invalid_certificate}
|
||||
end
|
||||
|
||||
defp verify_certificate({:OTPCertificate, _tbs, _sig_alg, _sig} = cert, _event, state) do
|
||||
matched? = MapSet.member?(state.pins, spki_pin_from_verify(cert))
|
||||
{:valid, %{state | matched?: state.matched? or matched?}}
|
||||
rescue
|
||||
_error -> {:fail, :invalid_certificate}
|
||||
end
|
||||
|
||||
defp verify_certificate({:Certificate, _tbs, _sig_alg, _sig} = cert, _event, state) do
|
||||
matched? = MapSet.member?(state.pins, spki_pin_from_verify(cert))
|
||||
{:valid, %{state | matched?: state.matched? or matched?}}
|
||||
rescue
|
||||
_error -> {:fail, :invalid_certificate}
|
||||
@@ -65,19 +76,32 @@ defmodule Parrhesia.Sync.TLS do
|
||||
defp verify_certificate(_cert, _event, state), do: {:valid, state}
|
||||
|
||||
defp spki_pin(cert_der) do
|
||||
cert = :public_key.pkix_decode_cert(cert_der, :otp)
|
||||
cert = :public_key.pkix_decode_cert(cert_der, :plain)
|
||||
spki = cert |> elem(1) |> elem(7)
|
||||
|
||||
spki =
|
||||
cert
|
||||
|> otp_certificate(:tbsCertificate)
|
||||
|> otp_tbs_certificate(:subjectPublicKeyInfo)
|
||||
|
||||
spki
|
||||
|> :public_key.der_encode(:SubjectPublicKeyInfo)
|
||||
:public_key.der_encode(:SubjectPublicKeyInfo, spki)
|
||||
|> then(&:crypto.hash(:sha256, &1))
|
||||
|> Base.encode64()
|
||||
end
|
||||
|
||||
defp spki_pin_from_verify(cert) when is_binary(cert), do: spki_pin(cert)
|
||||
|
||||
defp spki_pin_from_verify({:OTPCertificate, _tbs, _sig_alg, _sig} = cert) do
|
||||
cert
|
||||
|> then(&:public_key.pkix_encode(:OTPCertificate, &1, :otp))
|
||||
|> spki_pin()
|
||||
end
|
||||
|
||||
defp spki_pin_from_verify({:Certificate, _tbs, _sig_alg, _sig} = cert) do
|
||||
cert
|
||||
|> then(&:public_key.der_encode(:Certificate, &1))
|
||||
|> spki_pin()
|
||||
end
|
||||
|
||||
defp spki_pin_from_verify(_cert) do
|
||||
raise(ArgumentError, "invalid certificate")
|
||||
end
|
||||
|
||||
defp system_cacerts do
|
||||
if function_exported?(:public_key, :cacerts_get, 0) do
|
||||
:public_key.cacerts_get()
|
||||
|
||||
@@ -17,7 +17,7 @@ defmodule Parrhesia.Sync.Transport.WebSockexClient do
|
||||
transport_opts =
|
||||
server.tls
|
||||
|> TLS.websocket_options()
|
||||
|> Keyword.merge(Keyword.get(opts, :websocket_opts, []))
|
||||
|> merge_websocket_opts(Keyword.get(opts, :websocket_opts, []))
|
||||
|> Keyword.put(:handle_initial_conn_failure, true)
|
||||
|
||||
WebSockex.start(server.url, __MODULE__, state, transport_opts)
|
||||
@@ -71,4 +71,23 @@ defmodule Parrhesia.Sync.Transport.WebSockexClient do
|
||||
send(state.owner, {:sync_transport, self(), :disconnected, status})
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
defp merge_websocket_opts(base_opts, override_opts) do
|
||||
override_ssl_options = Keyword.get(override_opts, :ssl_options)
|
||||
|
||||
merged_ssl_options =
|
||||
case {Keyword.get(base_opts, :ssl_options), override_ssl_options} do
|
||||
{nil, nil} -> nil
|
||||
{base_ssl, nil} -> base_ssl
|
||||
{nil, override_ssl} -> override_ssl
|
||||
{base_ssl, override_ssl} -> Keyword.merge(base_ssl, override_ssl)
|
||||
end
|
||||
|
||||
base_opts
|
||||
|> Keyword.merge(Keyword.delete(override_opts, :ssl_options))
|
||||
|> maybe_put_ssl_options(merged_ssl_options)
|
||||
end
|
||||
|
||||
defp maybe_put_ssl_options(opts, nil), do: opts
|
||||
defp maybe_put_ssl_options(opts, ssl_options), do: Keyword.put(opts, :ssl_options, ssl_options)
|
||||
end
|
||||
|
||||
145
lib/parrhesia/test_support/tls_certs.ex
Normal file
145
lib/parrhesia/test_support/tls_certs.ex
Normal file
@@ -0,0 +1,145 @@
|
||||
defmodule Parrhesia.TestSupport.TLSCerts do
|
||||
@moduledoc false
|
||||
|
||||
@spec create_ca!(String.t(), String.t()) :: map()
|
||||
def create_ca!(dir, name) do
|
||||
keyfile = Path.join(dir, "#{name}-ca.key.pem")
|
||||
certfile = Path.join(dir, "#{name}-ca.cert.pem")
|
||||
|
||||
openssl!([
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-sha256",
|
||||
"-days",
|
||||
"2",
|
||||
"-subj",
|
||||
"/CN=#{name} Test CA",
|
||||
"-keyout",
|
||||
keyfile,
|
||||
"-out",
|
||||
certfile
|
||||
])
|
||||
|
||||
%{keyfile: keyfile, certfile: certfile}
|
||||
end
|
||||
|
||||
@spec issue_server_cert!(String.t(), map(), String.t()) :: map()
|
||||
def issue_server_cert!(dir, ca, name) do
|
||||
issue_cert!(
|
||||
dir,
|
||||
ca,
|
||||
name,
|
||||
"localhost",
|
||||
["DNS:localhost", "IP:127.0.0.1"],
|
||||
"serverAuth"
|
||||
)
|
||||
end
|
||||
|
||||
@spec issue_client_cert!(String.t(), map(), String.t()) :: map()
|
||||
def issue_client_cert!(dir, ca, name) do
|
||||
issue_cert!(dir, ca, name, name, [], "clientAuth")
|
||||
end
|
||||
|
||||
@spec spki_pin!(String.t()) :: String.t()
|
||||
def spki_pin!(certfile) do
|
||||
certfile
|
||||
|> der_cert!()
|
||||
|> spki_pin()
|
||||
end
|
||||
|
||||
@spec cert_sha256!(String.t()) :: String.t()
|
||||
def cert_sha256!(certfile) do
|
||||
certfile
|
||||
|> der_cert!()
|
||||
|> then(&Base.encode64(:crypto.hash(:sha256, &1)))
|
||||
end
|
||||
|
||||
defp issue_cert!(dir, ca, name, common_name, san_entries, extended_key_usage) do
|
||||
keyfile = Path.join(dir, "#{name}.key.pem")
|
||||
csrfile = Path.join(dir, "#{name}.csr.pem")
|
||||
certfile = Path.join(dir, "#{name}.cert.pem")
|
||||
extfile = Path.join(dir, "#{name}.ext.cnf")
|
||||
|
||||
openssl!([
|
||||
"req",
|
||||
"-new",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-subj",
|
||||
"/CN=#{common_name}",
|
||||
"-keyout",
|
||||
keyfile,
|
||||
"-out",
|
||||
csrfile
|
||||
])
|
||||
|
||||
File.write!(extfile, extension_config(san_entries, extended_key_usage))
|
||||
|
||||
openssl!([
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
csrfile,
|
||||
"-CA",
|
||||
ca.certfile,
|
||||
"-CAkey",
|
||||
ca.keyfile,
|
||||
"-CAcreateserial",
|
||||
"-out",
|
||||
certfile,
|
||||
"-days",
|
||||
"2",
|
||||
"-sha256",
|
||||
"-extfile",
|
||||
extfile,
|
||||
"-extensions",
|
||||
"v3_req"
|
||||
])
|
||||
|
||||
%{keyfile: keyfile, certfile: certfile}
|
||||
end
|
||||
|
||||
defp extension_config(san_entries, extended_key_usage) do
|
||||
san_block =
|
||||
case san_entries do
|
||||
[] -> ""
|
||||
entries -> "subjectAltName = #{Enum.join(entries, ",")}\n"
|
||||
end
|
||||
|
||||
"""
|
||||
[v3_req]
|
||||
basicConstraints = CA:FALSE
|
||||
keyUsage = digitalSignature,keyEncipherment
|
||||
extendedKeyUsage = #{extended_key_usage}
|
||||
#{san_block}
|
||||
"""
|
||||
end
|
||||
|
||||
defp der_cert!(certfile) do
|
||||
certfile
|
||||
|> File.read!()
|
||||
|> :public_key.pem_decode()
|
||||
|> List.first()
|
||||
|> elem(1)
|
||||
end
|
||||
|
||||
defp spki_pin(cert_der) do
|
||||
cert = :public_key.pkix_decode_cert(cert_der, :plain)
|
||||
spki = cert |> elem(1) |> elem(7)
|
||||
|
||||
:public_key.der_encode(:SubjectPublicKeyInfo, spki)
|
||||
|> then(&:crypto.hash(:sha256, &1))
|
||||
|> Base.encode64()
|
||||
end
|
||||
|
||||
defp openssl!(args) do
|
||||
case System.cmd("/usr/bin/openssl", args, stderr_to_stdout: true) do
|
||||
{output, 0} -> output
|
||||
{output, status} -> raise "openssl failed with status #{status}: #{output}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -45,6 +45,7 @@ defmodule Parrhesia.Web.Connection do
|
||||
defstruct subscriptions: %{},
|
||||
authenticated_pubkeys: MapSet.new(),
|
||||
listener: nil,
|
||||
transport_identity: nil,
|
||||
max_subscriptions_per_connection: @default_max_subscriptions_per_connection,
|
||||
subscription_index: Index,
|
||||
auth_challenges: Challenges,
|
||||
@@ -77,6 +78,7 @@ defmodule Parrhesia.Web.Connection do
|
||||
subscriptions: %{String.t() => subscription()},
|
||||
authenticated_pubkeys: MapSet.t(String.t()),
|
||||
listener: map() | nil,
|
||||
transport_identity: map() | nil,
|
||||
max_subscriptions_per_connection: pos_integer(),
|
||||
subscription_index: GenServer.server() | nil,
|
||||
auth_challenges: GenServer.server() | nil,
|
||||
@@ -105,6 +107,7 @@ defmodule Parrhesia.Web.Connection do
|
||||
|
||||
state = %__MODULE__{
|
||||
listener: Listener.from_opts(opts),
|
||||
transport_identity: transport_identity(opts),
|
||||
max_subscriptions_per_connection: max_subscriptions_per_connection(opts),
|
||||
subscription_index: subscription_index(opts),
|
||||
auth_challenges: auth_challenges,
|
||||
@@ -1475,10 +1478,18 @@ defmodule Parrhesia.Web.Connection do
|
||||
caller: :websocket,
|
||||
remote_ip: state.remote_ip,
|
||||
subscription_id: subscription_id,
|
||||
metadata: %{listener_id: state.listener.id}
|
||||
transport_identity: state.transport_identity,
|
||||
metadata: %{
|
||||
listener_id: state.listener.id,
|
||||
transport_identity: state.transport_identity
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp transport_identity(opts) when is_list(opts), do: Keyword.get(opts, :transport_identity)
|
||||
defp transport_identity(opts) when is_map(opts), do: Map.get(opts, :transport_identity)
|
||||
defp transport_identity(_opts), do: nil
|
||||
|
||||
defp authorize_authenticated_pubkey(%{"pubkey" => pubkey}) when is_binary(pubkey) do
|
||||
ConnectionPolicy.authorize_authenticated_pubkey(pubkey)
|
||||
end
|
||||
|
||||
@@ -7,14 +7,43 @@ defmodule Parrhesia.Web.Endpoint do
|
||||
|
||||
alias Parrhesia.Web.Listener
|
||||
|
||||
def start_link(_init_arg \\ []) do
|
||||
Supervisor.start_link(__MODULE__, :ok, name: __MODULE__)
|
||||
def start_link(opts \\ []) do
|
||||
name = Keyword.get(opts, :name, __MODULE__)
|
||||
listeners = Keyword.get(opts, :listeners, :configured)
|
||||
Supervisor.start_link(__MODULE__, listeners, name: name)
|
||||
end
|
||||
|
||||
@spec reload_listener(Supervisor.supervisor(), atom()) :: :ok | {:error, term()}
|
||||
def reload_listener(supervisor \\ __MODULE__, listener_id) when is_atom(listener_id) do
|
||||
with :ok <- Supervisor.terminate_child(supervisor, {:listener, listener_id}),
|
||||
{:ok, _pid} <- Supervisor.restart_child(supervisor, {:listener, listener_id}) do
|
||||
:ok
|
||||
else
|
||||
{:error, :not_found} = error -> error
|
||||
{:error, _reason} = error -> error
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
@spec reload_all(Supervisor.supervisor()) :: :ok | {:error, term()}
|
||||
def reload_all(supervisor \\ __MODULE__) do
|
||||
supervisor
|
||||
|> Supervisor.which_children()
|
||||
|> Enum.filter(fn {id, _pid, _type, _modules} ->
|
||||
match?({:listener, _listener_id}, id)
|
||||
end)
|
||||
|> Enum.reduce_while(:ok, fn {{:listener, listener_id}, _pid, _type, _modules}, :ok ->
|
||||
case reload_listener(supervisor, listener_id) do
|
||||
:ok -> {:cont, :ok}
|
||||
{:error, _reason} = error -> {:halt, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(:ok) do
|
||||
def init(listeners) do
|
||||
children =
|
||||
Listener.all()
|
||||
listeners(listeners)
|
||||
|> Enum.map(fn listener ->
|
||||
%{
|
||||
id: {:listener, listener.id},
|
||||
@@ -24,4 +53,22 @@ defmodule Parrhesia.Web.Endpoint do
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
defp listeners(:configured), do: Listener.all()
|
||||
|
||||
defp listeners(listeners) when is_list(listeners) do
|
||||
Enum.map(listeners, fn
|
||||
{id, listener} when is_atom(id) and is_map(listener) ->
|
||||
Listener.from_opts(listener: Map.put_new(listener, :id, id))
|
||||
|
||||
listener ->
|
||||
Listener.from_opts(listener: listener)
|
||||
end)
|
||||
end
|
||||
|
||||
defp listeners(listeners) when is_map(listeners) do
|
||||
listeners
|
||||
|> Enum.map(fn {id, listener} -> {id, listener} end)
|
||||
|> listeners()
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ defmodule Parrhesia.Web.Listener do
|
||||
import Bitwise
|
||||
|
||||
alias Parrhesia.Protocol.Filter
|
||||
alias Parrhesia.Web.TLS
|
||||
|
||||
@private_cidrs [
|
||||
"127.0.0.0/8",
|
||||
@@ -81,11 +82,38 @@ defmodule Parrhesia.Web.Listener do
|
||||
listener.proxy.trusted_cidrs
|
||||
end
|
||||
|
||||
@spec trusted_proxy_request?(t(), Plug.Conn.t()) :: boolean()
|
||||
def trusted_proxy_request?(listener, conn) do
|
||||
TLS.trusted_proxy_request?(conn, trusted_proxies(listener))
|
||||
end
|
||||
|
||||
@spec remote_ip_allowed?(t(), tuple() | String.t() | nil) :: boolean()
|
||||
def remote_ip_allowed?(listener, remote_ip) do
|
||||
access_allowed?(listener.network, remote_ip)
|
||||
end
|
||||
|
||||
@spec authorize_transport_request(t(), Plug.Conn.t()) ::
|
||||
{:ok, map() | nil} | {:error, atom()}
|
||||
def authorize_transport_request(listener, conn) do
|
||||
TLS.authorize_request(listener.transport.tls, conn, trusted_proxy_request?(listener, conn))
|
||||
end
|
||||
|
||||
@spec request_scheme(t(), Plug.Conn.t()) :: :http | :https
|
||||
def request_scheme(listener, conn) do
|
||||
TLS.request_scheme(listener.transport.tls, conn, trusted_proxy_request?(listener, conn))
|
||||
end
|
||||
|
||||
@spec request_host(t(), Plug.Conn.t()) :: String.t()
|
||||
def request_host(listener, conn) do
|
||||
TLS.request_host(conn, trusted_proxy_request?(listener, conn))
|
||||
end
|
||||
|
||||
@spec request_port(t(), Plug.Conn.t()) :: non_neg_integer()
|
||||
def request_port(listener, conn) do
|
||||
scheme = request_scheme(listener, conn)
|
||||
TLS.request_port(listener.transport.tls, conn, trusted_proxy_request?(listener, conn), scheme)
|
||||
end
|
||||
|
||||
@spec metrics_allowed?(t(), Plug.Conn.t()) :: boolean()
|
||||
def metrics_allowed?(listener, conn) do
|
||||
metrics = Map.get(listener.features, :metrics, %{})
|
||||
@@ -97,17 +125,19 @@ defmodule Parrhesia.Web.Listener do
|
||||
|
||||
@spec relay_url(t(), Plug.Conn.t()) :: String.t()
|
||||
def relay_url(listener, conn) do
|
||||
scheme = listener.transport.scheme
|
||||
scheme = request_scheme(listener, conn)
|
||||
host = request_host(listener, conn)
|
||||
port = request_port(listener, conn)
|
||||
ws_scheme = if scheme == :https, do: "wss", else: "ws"
|
||||
|
||||
port_segment =
|
||||
if default_http_port?(scheme, conn.port) do
|
||||
if default_http_port?(scheme, port) do
|
||||
""
|
||||
else
|
||||
":#{conn.port}"
|
||||
":#{port}"
|
||||
end
|
||||
|
||||
"#{ws_scheme}://#{conn.host}#{port_segment}#{conn.request_path}"
|
||||
"#{ws_scheme}://#{host}#{port_segment}#{conn.request_path}"
|
||||
end
|
||||
|
||||
@spec relay_auth_required?(t()) :: boolean()
|
||||
@@ -131,12 +161,18 @@ defmodule Parrhesia.Web.Listener do
|
||||
|
||||
@spec bandit_options(t()) :: keyword()
|
||||
def bandit_options(listener) do
|
||||
scheme =
|
||||
case listener.transport.tls.mode do
|
||||
mode when mode in [:server, :mutual] -> :https
|
||||
_other -> listener.transport.scheme
|
||||
end
|
||||
|
||||
[
|
||||
ip: listener.bind.ip,
|
||||
port: listener.bind.port,
|
||||
scheme: listener.transport.scheme,
|
||||
scheme: scheme,
|
||||
plug: {Parrhesia.Web.ListenerPlug, listener: listener}
|
||||
] ++ listener.bandit_options
|
||||
] ++ TLS.bandit_options(listener.transport.tls) ++ listener.bandit_options
|
||||
end
|
||||
|
||||
defp normalize_listeners(listeners) when is_list(listeners) do
|
||||
@@ -202,13 +238,15 @@ defmodule Parrhesia.Web.Listener do
|
||||
end
|
||||
|
||||
defp normalize_transport(transport) when is_map(transport) do
|
||||
scheme = normalize_scheme(fetch_value(transport, :scheme), :http)
|
||||
|
||||
%{
|
||||
scheme: normalize_scheme(fetch_value(transport, :scheme), :http),
|
||||
tls: normalize_map(fetch_value(transport, :tls))
|
||||
scheme: scheme,
|
||||
tls: TLS.normalize_config(fetch_value(transport, :tls), scheme)
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_transport(_transport), do: %{scheme: :http, tls: %{}}
|
||||
defp normalize_transport(_transport), do: %{scheme: :http, tls: TLS.default_config()}
|
||||
|
||||
defp normalize_proxy(proxy) when is_map(proxy) do
|
||||
%{
|
||||
@@ -478,7 +516,7 @@ defmodule Parrhesia.Web.Listener do
|
||||
id: :public,
|
||||
enabled: true,
|
||||
bind: %{ip: {0, 0, 0, 0}, port: 4413},
|
||||
transport: %{scheme: :http, tls: %{}},
|
||||
transport: %{scheme: :http, tls: TLS.default_config()},
|
||||
proxy: %{trusted_cidrs: [], honor_x_forwarded_for: true},
|
||||
network: %{public?: false, private_networks_only?: false, allow_cidrs: [], allow_all?: true},
|
||||
features: %{
|
||||
@@ -524,9 +562,6 @@ defmodule Parrhesia.Web.Listener do
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_map(value) when is_map(value), do: value
|
||||
defp normalize_map(_value), do: %{}
|
||||
|
||||
defp normalize_boolean(value, _default) when is_boolean(value), do: value
|
||||
defp normalize_boolean(nil, default), do: default
|
||||
defp normalize_boolean(_value, default), do: default
|
||||
|
||||
@@ -7,6 +7,7 @@ defmodule Parrhesia.Web.Management do
|
||||
|
||||
alias Parrhesia.API.Admin
|
||||
alias Parrhesia.API.Auth
|
||||
alias Parrhesia.Web.Listener
|
||||
|
||||
@spec handle(Plug.Conn.t(), keyword()) :: Plug.Conn.t()
|
||||
def handle(conn, opts \\ []) do
|
||||
@@ -94,14 +95,15 @@ defmodule Parrhesia.Web.Management do
|
||||
end
|
||||
|
||||
defp full_request_url(conn) do
|
||||
scheme = Atom.to_string(conn.scheme)
|
||||
host = conn.host
|
||||
port = conn.port
|
||||
listener = Listener.from_conn(conn)
|
||||
scheme = Listener.request_scheme(listener, conn)
|
||||
host = Listener.request_host(listener, conn)
|
||||
port = Listener.request_port(listener, conn)
|
||||
|
||||
port_suffix =
|
||||
cond do
|
||||
conn.scheme == :http and port == 80 -> ""
|
||||
conn.scheme == :https and port == 443 -> ""
|
||||
scheme == :http and port == 80 -> ""
|
||||
scheme == :https and port == 443 -> ""
|
||||
true -> ":#{port}"
|
||||
end
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ defmodule Parrhesia.Web.RemoteIp do
|
||||
|
||||
@spec call(Plug.Conn.t(), term()) :: Plug.Conn.t()
|
||||
def call(conn, _opts) do
|
||||
if trusted_proxy?(conn) do
|
||||
if trusted_proxy?(conn) and honor_x_forwarded_for?(conn) do
|
||||
case forwarded_ip(conn) do
|
||||
nil -> conn
|
||||
forwarded_ip -> %{conn | remote_ip: forwarded_ip}
|
||||
@@ -70,6 +70,11 @@ defmodule Parrhesia.Web.RemoteIp do
|
||||
end
|
||||
end
|
||||
|
||||
defp honor_x_forwarded_for?(conn) do
|
||||
listener = Listener.from_conn(conn)
|
||||
listener.proxy.honor_x_forwarded_for
|
||||
end
|
||||
|
||||
defp parse_x_forwarded_for(value) when is_binary(value) do
|
||||
value
|
||||
|> String.split(",")
|
||||
|
||||
@@ -39,7 +39,7 @@ defmodule Parrhesia.Web.Router do
|
||||
|
||||
if Listener.feature_enabled?(listener, :metrics) do
|
||||
case authorize_listener_request(conn, listener) do
|
||||
:ok -> Metrics.handle(conn)
|
||||
{:ok, conn} -> Metrics.handle(conn)
|
||||
{:error, :forbidden} -> send_resp(conn, 403, "forbidden")
|
||||
end
|
||||
else
|
||||
@@ -52,7 +52,7 @@ defmodule Parrhesia.Web.Router do
|
||||
|
||||
if Listener.feature_enabled?(listener, :admin) do
|
||||
case authorize_listener_request(conn, listener) do
|
||||
:ok -> Management.handle(conn, listener: listener)
|
||||
{:ok, conn} -> Management.handle(conn, listener: listener)
|
||||
{:error, :forbidden} -> send_resp(conn, 403, "forbidden")
|
||||
end
|
||||
else
|
||||
@@ -65,7 +65,7 @@ defmodule Parrhesia.Web.Router do
|
||||
|
||||
if Listener.feature_enabled?(listener, :nostr) do
|
||||
case authorize_listener_request(conn, listener) do
|
||||
:ok ->
|
||||
{:ok, conn} ->
|
||||
if accepts_nip11?(conn) do
|
||||
body = JSON.encode!(RelayInfo.document(listener))
|
||||
|
||||
@@ -79,7 +79,8 @@ defmodule Parrhesia.Web.Router do
|
||||
%{
|
||||
listener: listener,
|
||||
relay_url: Listener.relay_url(listener, conn),
|
||||
remote_ip: remote_ip(conn)
|
||||
remote_ip: remote_ip(conn),
|
||||
transport_identity: transport_identity(conn)
|
||||
},
|
||||
timeout: 60_000,
|
||||
max_frame_size: max_frame_bytes()
|
||||
@@ -118,10 +119,12 @@ defmodule Parrhesia.Web.Router do
|
||||
|
||||
defp authorize_listener_request(conn, listener) do
|
||||
with :ok <- authorize_remote_ip(conn),
|
||||
true <- Listener.remote_ip_allowed?(listener, conn.remote_ip) do
|
||||
:ok
|
||||
true <- Listener.remote_ip_allowed?(listener, conn.remote_ip),
|
||||
{:ok, transport_identity} <- Listener.authorize_transport_request(listener, conn) do
|
||||
{:ok, maybe_put_transport_identity(conn, transport_identity)}
|
||||
else
|
||||
{:error, :ip_blocked} -> {:error, :forbidden}
|
||||
{:error, _reason} -> {:error, :forbidden}
|
||||
false -> {:error, :forbidden}
|
||||
end
|
||||
end
|
||||
@@ -137,4 +140,14 @@ defmodule Parrhesia.Web.Router do
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_transport_identity(conn, nil), do: conn
|
||||
|
||||
defp maybe_put_transport_identity(conn, transport_identity) do
|
||||
Plug.Conn.put_private(conn, :parrhesia_transport_identity, transport_identity)
|
||||
end
|
||||
|
||||
defp transport_identity(conn) do
|
||||
Map.get(conn.private, :parrhesia_transport_identity)
|
||||
end
|
||||
end
|
||||
|
||||
514
lib/parrhesia/web/tls.ex
Normal file
514
lib/parrhesia/web/tls.ex
Normal file
@@ -0,0 +1,514 @@
|
||||
defmodule Parrhesia.Web.TLS do
|
||||
@moduledoc false
|
||||
|
||||
import Bitwise
|
||||
|
||||
require Record
|
||||
|
||||
Record.defrecordp(
|
||||
:otp_certificate,
|
||||
Record.extract(:OTPCertificate, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
|
||||
)
|
||||
|
||||
Record.defrecordp(
|
||||
:otp_tbs_certificate,
|
||||
Record.extract(:OTPTBSCertificate, from_lib: "public_key/include/OTP-PUB-KEY.hrl")
|
||||
)
|
||||
|
||||
@default_proxy_headers %{
|
||||
enabled: false,
|
||||
required: false,
|
||||
verify_header: "x-parrhesia-client-cert-verified",
|
||||
verified_values: ["1", "true", "success", "verified"],
|
||||
cert_sha256_header: "x-parrhesia-client-cert-sha256",
|
||||
spki_sha256_header: "x-parrhesia-client-spki-sha256",
|
||||
subject_header: "x-parrhesia-client-cert-subject"
|
||||
}
|
||||
|
||||
@type pin :: %{
|
||||
type: :cert_sha256 | :spki_sha256,
|
||||
value: String.t()
|
||||
}
|
||||
|
||||
@type config :: %{
|
||||
mode: :disabled | :server | :mutual | :proxy_terminated,
|
||||
certfile: String.t() | nil,
|
||||
keyfile: String.t() | nil,
|
||||
cacertfile: String.t() | nil,
|
||||
otp_app: atom() | nil,
|
||||
cipher_suite: :strong | :compatible | nil,
|
||||
client_pins: [pin()],
|
||||
proxy_headers: map()
|
||||
}
|
||||
|
||||
@spec default_config() :: config()
|
||||
def default_config do
|
||||
%{
|
||||
mode: :disabled,
|
||||
certfile: nil,
|
||||
keyfile: nil,
|
||||
cacertfile: nil,
|
||||
otp_app: nil,
|
||||
cipher_suite: nil,
|
||||
client_pins: [],
|
||||
proxy_headers: @default_proxy_headers
|
||||
}
|
||||
end
|
||||
|
||||
@spec normalize_config(map() | nil, atom()) :: config()
|
||||
def normalize_config(tls, scheme)
|
||||
|
||||
def normalize_config(tls, scheme) when is_map(tls) do
|
||||
defaults =
|
||||
default_config()
|
||||
|> Map.put(:mode, default_mode(scheme))
|
||||
|
||||
%{
|
||||
mode: normalize_mode(fetch_value(tls, :mode), defaults.mode),
|
||||
certfile: normalize_optional_string(fetch_value(tls, :certfile)),
|
||||
keyfile: normalize_optional_string(fetch_value(tls, :keyfile)),
|
||||
cacertfile: normalize_optional_string(fetch_value(tls, :cacertfile)),
|
||||
otp_app: normalize_optional_atom(fetch_value(tls, :otp_app)),
|
||||
cipher_suite: normalize_cipher_suite(fetch_value(tls, :cipher_suite)),
|
||||
client_pins: normalize_pins(fetch_value(tls, :client_pins)),
|
||||
proxy_headers: normalize_proxy_headers(fetch_value(tls, :proxy_headers))
|
||||
}
|
||||
end
|
||||
|
||||
def normalize_config(_tls, scheme) do
|
||||
%{default_config() | mode: default_mode(scheme)}
|
||||
end
|
||||
|
||||
@spec bandit_options(config()) :: keyword()
|
||||
def bandit_options(%{mode: mode}) when mode in [:disabled, :proxy_terminated], do: []
|
||||
|
||||
def bandit_options(%{mode: mode} = tls) when mode in [:server, :mutual] do
|
||||
transport_options =
|
||||
transport_options(tls)
|
||||
|> Keyword.merge(mutual_tls_options(tls))
|
||||
|> configure_server_tls()
|
||||
|
||||
[
|
||||
scheme: :https,
|
||||
thousand_island_options: [transport_options: transport_options]
|
||||
]
|
||||
end
|
||||
|
||||
@spec authorize_request(config(), Plug.Conn.t(), boolean()) ::
|
||||
{:ok, map() | nil} | {:error, atom()}
|
||||
def authorize_request(%{mode: :disabled}, _conn, _trusted_proxy?), do: {:ok, nil}
|
||||
|
||||
def authorize_request(%{mode: :server}, conn, _trusted_proxy?) do
|
||||
{:ok, socket_identity(conn)}
|
||||
end
|
||||
|
||||
def authorize_request(%{mode: :mutual} = tls, conn, _trusted_proxy?) do
|
||||
with %{} = identity <- socket_identity(conn),
|
||||
:ok <- verify_pins(identity, tls.client_pins, :client_certificate_pin_mismatch) do
|
||||
{:ok, identity}
|
||||
else
|
||||
nil -> {:error, :client_certificate_required}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
def authorize_request(%{mode: :proxy_terminated} = tls, conn, trusted_proxy?) do
|
||||
proxy_headers = tls.proxy_headers
|
||||
|
||||
if proxy_headers.enabled do
|
||||
authorize_proxy_identity(conn, proxy_headers, tls.client_pins, trusted_proxy?)
|
||||
else
|
||||
{:ok, nil}
|
||||
end
|
||||
end
|
||||
|
||||
@spec request_scheme(config(), Plug.Conn.t(), boolean()) :: :http | :https
|
||||
def request_scheme(%{mode: :proxy_terminated}, conn, true) do
|
||||
case header_value(conn, "x-forwarded-proto") do
|
||||
"https" -> :https
|
||||
"http" -> :http
|
||||
_other -> conn.scheme
|
||||
end
|
||||
end
|
||||
|
||||
def request_scheme(_tls, conn, _trusted_proxy?), do: conn.scheme
|
||||
|
||||
@spec request_host(Plug.Conn.t(), boolean()) :: String.t()
|
||||
def request_host(conn, true) do
|
||||
case header_value(conn, "x-forwarded-host") do
|
||||
nil -> conn.host
|
||||
value -> String.split(value, ",", parts: 2) |> List.first() |> String.trim()
|
||||
end
|
||||
end
|
||||
|
||||
def request_host(conn, false), do: conn.host
|
||||
|
||||
@spec request_port(config(), Plug.Conn.t(), boolean(), :http | :https) :: non_neg_integer()
|
||||
def request_port(%{mode: :proxy_terminated}, conn, true, forwarded_scheme) do
|
||||
case header_value(conn, "x-forwarded-port") do
|
||||
nil -> default_port(forwarded_scheme)
|
||||
value -> parse_port(value, default_port(forwarded_scheme))
|
||||
end
|
||||
end
|
||||
|
||||
def request_port(_tls, conn, _trusted_proxy?, _scheme), do: conn.port
|
||||
|
||||
@spec trusted_proxy_request?(Plug.Conn.t(), [String.t()]) :: boolean()
|
||||
def trusted_proxy_request?(conn, trusted_cidrs) when is_list(trusted_cidrs) do
|
||||
case Plug.Conn.get_peer_data(conn) do
|
||||
%{address: address} -> Enum.any?(trusted_cidrs, &ip_in_cidr?(address, &1))
|
||||
_other -> false
|
||||
end
|
||||
end
|
||||
|
||||
def trusted_proxy_request?(_conn, _trusted_cidrs), do: false
|
||||
|
||||
defp transport_options(tls) do
|
||||
[]
|
||||
|> maybe_put_opt(:keyfile, tls.keyfile)
|
||||
|> maybe_put_opt(:certfile, tls.certfile)
|
||||
|> maybe_put_opt(:cacertfile, tls.cacertfile)
|
||||
|> maybe_put_opt(:otp_app, tls.otp_app)
|
||||
|> maybe_put_opt(:cipher_suite, tls.cipher_suite)
|
||||
end
|
||||
|
||||
defp mutual_tls_options(%{mode: :mutual, cacertfile: nil}) do
|
||||
[
|
||||
verify: :verify_peer,
|
||||
fail_if_no_peer_cert: true,
|
||||
cacerts: system_cacerts()
|
||||
]
|
||||
end
|
||||
|
||||
defp mutual_tls_options(%{mode: :mutual}) do
|
||||
[
|
||||
verify: :verify_peer,
|
||||
fail_if_no_peer_cert: true
|
||||
]
|
||||
end
|
||||
|
||||
defp mutual_tls_options(_tls), do: []
|
||||
|
||||
defp configure_server_tls(options) do
|
||||
case Plug.SSL.configure(options) do
|
||||
{:ok, configured} -> configured
|
||||
{:error, message} -> raise ArgumentError, "invalid listener TLS config: #{message}"
|
||||
end
|
||||
end
|
||||
|
||||
defp authorize_proxy_identity(conn, proxy_headers, pins, trusted_proxy?) do
|
||||
cond do
|
||||
not trusted_proxy? and proxy_headers.required ->
|
||||
{:error, :proxy_tls_identity_required}
|
||||
|
||||
not trusted_proxy? ->
|
||||
{:ok, nil}
|
||||
|
||||
true ->
|
||||
conn
|
||||
|> proxy_identity(proxy_headers)
|
||||
|> authorize_proxy_identity_value(proxy_headers.required, pins)
|
||||
end
|
||||
end
|
||||
|
||||
defp authorize_proxy_identity_value(nil, true, _pins),
|
||||
do: {:error, :proxy_tls_identity_required}
|
||||
|
||||
defp authorize_proxy_identity_value(nil, false, _pins), do: {:ok, nil}
|
||||
|
||||
defp authorize_proxy_identity_value(%{verified?: false}, _required, _pins) do
|
||||
{:error, :proxy_tls_identity_unverified}
|
||||
end
|
||||
|
||||
defp authorize_proxy_identity_value(identity, _required, pins) do
|
||||
case verify_pins(identity, pins, :proxy_tls_identity_pin_mismatch) do
|
||||
:ok -> {:ok, identity}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
defp proxy_identity(conn, proxy_headers) do
|
||||
cert_sha256 = header_value(conn, proxy_headers.cert_sha256_header)
|
||||
spki_sha256 = header_value(conn, proxy_headers.spki_sha256_header)
|
||||
subject = header_value(conn, proxy_headers.subject_header)
|
||||
verified? = verified_header?(conn, proxy_headers)
|
||||
|
||||
if cert_sha256 || spki_sha256 || subject do
|
||||
%{
|
||||
source: :proxy,
|
||||
verified?: verified?,
|
||||
cert_sha256: cert_sha256,
|
||||
spki_sha256: spki_sha256,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp verified_header?(conn, proxy_headers) do
|
||||
case header_value(conn, proxy_headers.verify_header) do
|
||||
nil ->
|
||||
false
|
||||
|
||||
value ->
|
||||
normalized = String.downcase(String.trim(value))
|
||||
normalized in Enum.map(proxy_headers.verified_values, &String.downcase/1)
|
||||
end
|
||||
end
|
||||
|
||||
defp socket_identity(conn) do
|
||||
case Plug.Conn.get_peer_data(conn) do
|
||||
%{ssl_cert: cert_der} when is_binary(cert_der) ->
|
||||
certificate_identity(cert_der, :socket)
|
||||
|
||||
_other ->
|
||||
nil
|
||||
end
|
||||
rescue
|
||||
_error -> nil
|
||||
end
|
||||
|
||||
defp certificate_identity(cert_der, source) do
|
||||
%{
|
||||
source: source,
|
||||
verified?: true,
|
||||
cert_sha256: Base.encode64(:crypto.hash(:sha256, cert_der)),
|
||||
spki_sha256: spki_pin(cert_der),
|
||||
subject: certificate_subject(cert_der)
|
||||
}
|
||||
end
|
||||
|
||||
defp certificate_subject(cert_der) do
|
||||
cert = :public_key.pkix_decode_cert(cert_der, :otp)
|
||||
|
||||
cert
|
||||
|> otp_certificate(:tbsCertificate)
|
||||
|> otp_tbs_certificate(:subject)
|
||||
|> inspect()
|
||||
end
|
||||
|
||||
defp verify_pins(_identity, [], _reason), do: :ok
|
||||
|
||||
defp verify_pins(identity, pins, reason) do
|
||||
if Enum.any?(pins, &pin_matches?(identity, &1)) do
|
||||
:ok
|
||||
else
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp pin_matches?(identity, %{type: :cert_sha256, value: value}) do
|
||||
identity.cert_sha256 == value
|
||||
end
|
||||
|
||||
defp pin_matches?(identity, %{type: :spki_sha256, value: value}) do
|
||||
identity.spki_sha256 == value
|
||||
end
|
||||
|
||||
defp spki_pin(cert_der) do
|
||||
cert = :public_key.pkix_decode_cert(cert_der, :plain)
|
||||
spki = cert |> elem(1) |> elem(7)
|
||||
|
||||
:public_key.der_encode(:SubjectPublicKeyInfo, spki)
|
||||
|> then(&:crypto.hash(:sha256, &1))
|
||||
|> Base.encode64()
|
||||
end
|
||||
|
||||
defp header_value(conn, header) when is_binary(header) and header != "" do
|
||||
conn
|
||||
|> Plug.Conn.get_req_header(header)
|
||||
|> List.first()
|
||||
end
|
||||
|
||||
defp header_value(_conn, _header), do: nil
|
||||
|
||||
defp default_mode(:https), do: :server
|
||||
defp default_mode(_scheme), do: :disabled
|
||||
|
||||
defp default_port(:https), do: 443
|
||||
defp default_port(_scheme), do: 80
|
||||
|
||||
defp parse_port(value, default) when is_binary(value) do
|
||||
case Integer.parse(String.trim(value)) do
|
||||
{port, ""} when port >= 0 -> port
|
||||
_other -> default
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_mode(:server, _default), do: :server
|
||||
defp normalize_mode("server", _default), do: :server
|
||||
defp normalize_mode(:mutual, _default), do: :mutual
|
||||
defp normalize_mode("mutual", _default), do: :mutual
|
||||
defp normalize_mode(:proxy_terminated, _default), do: :proxy_terminated
|
||||
defp normalize_mode("proxy_terminated", _default), do: :proxy_terminated
|
||||
defp normalize_mode(:disabled, _default), do: :disabled
|
||||
defp normalize_mode("disabled", _default), do: :disabled
|
||||
defp normalize_mode(_value, default), do: default
|
||||
|
||||
defp normalize_cipher_suite(:strong), do: :strong
|
||||
defp normalize_cipher_suite("strong"), do: :strong
|
||||
defp normalize_cipher_suite(:compatible), do: :compatible
|
||||
defp normalize_cipher_suite("compatible"), do: :compatible
|
||||
defp normalize_cipher_suite(_value), do: nil
|
||||
|
||||
defp normalize_proxy_headers(headers) when is_map(headers) do
|
||||
%{
|
||||
enabled: normalize_boolean(fetch_value(headers, :enabled), false),
|
||||
required: normalize_boolean(fetch_value(headers, :required), false),
|
||||
verify_header:
|
||||
normalize_optional_string(fetch_value(headers, :verify_header)) ||
|
||||
@default_proxy_headers.verify_header,
|
||||
verified_values:
|
||||
normalize_string_list(fetch_value(headers, :verified_values)) ++
|
||||
Enum.filter(
|
||||
@default_proxy_headers.verified_values,
|
||||
&(&1 not in normalize_string_list(fetch_value(headers, :verified_values)))
|
||||
),
|
||||
cert_sha256_header:
|
||||
normalize_optional_string(fetch_value(headers, :cert_sha256_header)) ||
|
||||
@default_proxy_headers.cert_sha256_header,
|
||||
spki_sha256_header:
|
||||
normalize_optional_string(fetch_value(headers, :spki_sha256_header)) ||
|
||||
@default_proxy_headers.spki_sha256_header,
|
||||
subject_header:
|
||||
normalize_optional_string(fetch_value(headers, :subject_header)) ||
|
||||
@default_proxy_headers.subject_header
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_proxy_headers(_headers), do: @default_proxy_headers
|
||||
|
||||
defp normalize_pins(pins) when is_list(pins) do
|
||||
Enum.flat_map(pins, fn
|
||||
%{type: type, value: value} ->
|
||||
case normalize_pin(type, value) do
|
||||
nil -> []
|
||||
pin -> [pin]
|
||||
end
|
||||
|
||||
%{"type" => type, "value" => value} ->
|
||||
case normalize_pin(type, value) do
|
||||
nil -> []
|
||||
pin -> [pin]
|
||||
end
|
||||
|
||||
_other ->
|
||||
[]
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_pins(_pins), do: []
|
||||
|
||||
defp normalize_pin(type, value) do
|
||||
with normalized_type when normalized_type in [:cert_sha256, :spki_sha256] <-
|
||||
normalize_pin_type(type),
|
||||
normalized_value when is_binary(normalized_value) and normalized_value != "" <-
|
||||
normalize_optional_string(value) do
|
||||
%{type: normalized_type, value: normalized_value}
|
||||
else
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_pin_type(:cert_sha256), do: :cert_sha256
|
||||
defp normalize_pin_type("cert_sha256"), do: :cert_sha256
|
||||
defp normalize_pin_type(:spki_sha256), do: :spki_sha256
|
||||
defp normalize_pin_type("spki_sha256"), do: :spki_sha256
|
||||
defp normalize_pin_type(_type), do: nil
|
||||
|
||||
defp maybe_put_opt(options, _key, nil), do: options
|
||||
defp maybe_put_opt(options, key, value), do: Keyword.put(options, key, value)
|
||||
|
||||
defp fetch_value(map, key) when is_map(map) do
|
||||
cond do
|
||||
Map.has_key?(map, key) ->
|
||||
Map.get(map, key)
|
||||
|
||||
is_atom(key) and Map.has_key?(map, Atom.to_string(key)) ->
|
||||
Map.get(map, Atom.to_string(key))
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_optional_string(value) when is_binary(value) and value != "", do: value
|
||||
defp normalize_optional_string(_value), do: nil
|
||||
|
||||
defp normalize_optional_atom(value) when is_atom(value), do: value
|
||||
defp normalize_optional_atom(_value), do: nil
|
||||
|
||||
defp normalize_boolean(value, _default) when is_boolean(value), do: value
|
||||
defp normalize_boolean(nil, default), do: default
|
||||
defp normalize_boolean(_value, default), do: default
|
||||
|
||||
defp normalize_string_list(values) when is_list(values) do
|
||||
Enum.filter(values, &(is_binary(&1) and &1 != ""))
|
||||
end
|
||||
|
||||
defp normalize_string_list(_values), do: []
|
||||
|
||||
defp system_cacerts do
|
||||
if function_exported?(:public_key, :cacerts_get, 0) do
|
||||
:public_key.cacerts_get()
|
||||
else
|
||||
[]
|
||||
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
|
||||
|
||||
[address] ->
|
||||
case :inet.parse_address(String.to_charlist(address)) do
|
||||
{:ok, {_, _, _, _} = ip} -> {ip, 32}
|
||||
{:ok, {_, _, _, _, _, _, _, _} = ip} -> {ip, 128}
|
||||
_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
|
||||
end
|
||||
@@ -123,6 +123,18 @@ defmodule Parrhesia.Web.ConnectionTest do
|
||||
Enum.find(decoded, fn frame -> List.first(frame) == "OK" end)
|
||||
end
|
||||
|
||||
test "connection state keeps transport identity metadata" do
|
||||
transport_identity = %{
|
||||
source: :socket,
|
||||
verified?: true,
|
||||
spki_sha256: "client-spki-pin"
|
||||
}
|
||||
|
||||
state = connection_state(transport_identity: transport_identity)
|
||||
|
||||
assert state.transport_identity == transport_identity
|
||||
end
|
||||
|
||||
test "listener can require NIP-42 for reads and writes" do
|
||||
listener =
|
||||
listener(%{
|
||||
|
||||
@@ -107,6 +107,91 @@ defmodule Parrhesia.Web.RouterTest do
|
||||
assert allowed_conn.status == 200
|
||||
end
|
||||
|
||||
test "GET /relay accepts proxy-asserted TLS identity from trusted proxies" do
|
||||
test_listener =
|
||||
listener(%{
|
||||
transport: %{
|
||||
scheme: :http,
|
||||
tls: %{
|
||||
mode: :proxy_terminated,
|
||||
proxy_headers: %{enabled: true, required: true}
|
||||
}
|
||||
},
|
||||
proxy: %{trusted_cidrs: ["10.0.0.0/8"], honor_x_forwarded_for: true}
|
||||
})
|
||||
|
||||
conn =
|
||||
conn(:get, "/relay")
|
||||
|> put_req_header("accept", "application/nostr+json")
|
||||
|> put_req_header("x-parrhesia-client-cert-verified", "true")
|
||||
|> put_req_header("x-parrhesia-client-spki-sha256", "proxy-spki-pin")
|
||||
|> Plug.Test.put_peer_data(%{
|
||||
address: {10, 1, 2, 3},
|
||||
port: 443,
|
||||
ssl_cert: nil
|
||||
})
|
||||
|> route_conn(test_listener)
|
||||
|
||||
assert conn.status == 200
|
||||
end
|
||||
|
||||
test "GET /relay rejects missing proxy-asserted TLS identity when required" do
|
||||
test_listener =
|
||||
listener(%{
|
||||
transport: %{
|
||||
scheme: :http,
|
||||
tls: %{
|
||||
mode: :proxy_terminated,
|
||||
proxy_headers: %{enabled: true, required: true}
|
||||
}
|
||||
},
|
||||
proxy: %{trusted_cidrs: ["10.0.0.0/8"], honor_x_forwarded_for: true}
|
||||
})
|
||||
|
||||
conn =
|
||||
conn(:get, "/relay")
|
||||
|> put_req_header("accept", "application/nostr+json")
|
||||
|> Plug.Test.put_peer_data(%{
|
||||
address: {10, 1, 2, 3},
|
||||
port: 443,
|
||||
ssl_cert: nil
|
||||
})
|
||||
|> route_conn(test_listener)
|
||||
|
||||
assert conn.status == 403
|
||||
assert conn.resp_body == "forbidden"
|
||||
end
|
||||
|
||||
test "GET /relay rejects proxy-asserted TLS identity when the pin mismatches" do
|
||||
test_listener =
|
||||
listener(%{
|
||||
transport: %{
|
||||
scheme: :http,
|
||||
tls: %{
|
||||
mode: :proxy_terminated,
|
||||
client_pins: [%{type: :spki_sha256, value: "expected-spki-pin"}],
|
||||
proxy_headers: %{enabled: true, required: true}
|
||||
}
|
||||
},
|
||||
proxy: %{trusted_cidrs: ["10.0.0.0/8"], honor_x_forwarded_for: true}
|
||||
})
|
||||
|
||||
conn =
|
||||
conn(:get, "/relay")
|
||||
|> put_req_header("accept", "application/nostr+json")
|
||||
|> put_req_header("x-parrhesia-client-cert-verified", "true")
|
||||
|> put_req_header("x-parrhesia-client-spki-sha256", "wrong-spki-pin")
|
||||
|> Plug.Test.put_peer_data(%{
|
||||
address: {10, 1, 2, 3},
|
||||
port: 443,
|
||||
ssl_cert: nil
|
||||
})
|
||||
|> route_conn(test_listener)
|
||||
|
||||
assert conn.status == 403
|
||||
assert conn.resp_body == "forbidden"
|
||||
end
|
||||
|
||||
test "POST /management requires authorization" do
|
||||
conn =
|
||||
conn(:post, "/management", JSON.encode!(%{"method" => "ping", "params" => %{}}))
|
||||
|
||||
343
test/parrhesia/web/tls_e2e_test.exs
Normal file
343
test/parrhesia/web/tls_e2e_test.exs
Normal file
@@ -0,0 +1,343 @@
|
||||
defmodule Parrhesia.Web.TLSE2ETest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Ecto.Adapters.SQL.Sandbox
|
||||
alias Parrhesia.Repo
|
||||
alias Parrhesia.Sync.Transport.WebSockexClient
|
||||
alias Parrhesia.TestSupport.TLSCerts
|
||||
alias Parrhesia.Web.Endpoint
|
||||
|
||||
setup do
|
||||
:ok = Sandbox.checkout(Repo)
|
||||
Sandbox.mode(Repo, {:shared, self()})
|
||||
|
||||
tmp_dir =
|
||||
Path.join(
|
||||
System.tmp_dir!(),
|
||||
"parrhesia_tls_e2e_#{System.unique_integer([:positive, :monotonic])}"
|
||||
)
|
||||
|
||||
File.mkdir_p!(tmp_dir)
|
||||
|
||||
on_exit(fn ->
|
||||
Sandbox.mode(Repo, :manual)
|
||||
_ = File.rm_rf(tmp_dir)
|
||||
end)
|
||||
|
||||
{:ok, tmp_dir: tmp_dir}
|
||||
end
|
||||
|
||||
test "HTTPS listener serves NIP-11 and reloads certificate files from disk", %{tmp_dir: tmp_dir} do
|
||||
ca = TLSCerts.create_ca!(tmp_dir, "reload")
|
||||
server_a = TLSCerts.issue_server_cert!(tmp_dir, ca, "reload-server-a")
|
||||
server_b = TLSCerts.issue_server_cert!(tmp_dir, ca, "reload-server-b")
|
||||
|
||||
active_certfile = Path.join(tmp_dir, "active-server.cert.pem")
|
||||
active_keyfile = Path.join(tmp_dir, "active-server.key.pem")
|
||||
File.cp!(server_a.certfile, active_certfile)
|
||||
File.cp!(server_a.keyfile, active_keyfile)
|
||||
|
||||
port = free_port()
|
||||
endpoint_name = unique_name("TLSEndpointReload")
|
||||
listener_id = :reload_tls
|
||||
|
||||
start_supervised!(
|
||||
{Endpoint,
|
||||
name: endpoint_name,
|
||||
listeners: %{
|
||||
listener_id =>
|
||||
listener(listener_id, port, %{
|
||||
transport: %{
|
||||
scheme: :https,
|
||||
tls: %{
|
||||
mode: :server,
|
||||
certfile: active_certfile,
|
||||
keyfile: active_keyfile,
|
||||
cipher_suite: :compatible
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
)
|
||||
|
||||
assert_eventually(fn ->
|
||||
case nip11_request(port, ca.certfile) do
|
||||
{:ok, 200} -> true
|
||||
_other -> false
|
||||
end
|
||||
end)
|
||||
|
||||
first_fingerprint = server_cert_fingerprint(port)
|
||||
assert first_fingerprint == TLSCerts.cert_sha256!(server_a.certfile)
|
||||
|
||||
File.cp!(server_b.certfile, active_certfile)
|
||||
File.cp!(server_b.keyfile, active_keyfile)
|
||||
|
||||
assert :ok = Endpoint.reload_listener(endpoint_name, listener_id)
|
||||
|
||||
assert_eventually(fn ->
|
||||
server_cert_fingerprint(port) == TLSCerts.cert_sha256!(server_b.certfile)
|
||||
end)
|
||||
end
|
||||
|
||||
test "mutual TLS requires a client certificate and enforces optional client pins", %{
|
||||
tmp_dir: tmp_dir
|
||||
} do
|
||||
ca = TLSCerts.create_ca!(tmp_dir, "mutual")
|
||||
server = TLSCerts.issue_server_cert!(tmp_dir, ca, "mutual-server")
|
||||
allowed_client = TLSCerts.issue_client_cert!(tmp_dir, ca, "allowed-client")
|
||||
other_client = TLSCerts.issue_client_cert!(tmp_dir, ca, "other-client")
|
||||
allowed_pin = TLSCerts.spki_pin!(allowed_client.certfile)
|
||||
|
||||
port = free_port()
|
||||
|
||||
start_supervised!(
|
||||
{Endpoint,
|
||||
name: unique_name("TLSEndpointMutual"),
|
||||
listeners: %{
|
||||
mutual_tls:
|
||||
listener(:mutual_tls, port, %{
|
||||
transport: %{
|
||||
scheme: :https,
|
||||
tls: %{
|
||||
mode: :mutual,
|
||||
certfile: server.certfile,
|
||||
keyfile: server.keyfile,
|
||||
cacertfile: ca.certfile,
|
||||
client_pins: [%{type: :spki_sha256, value: allowed_pin}]
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
)
|
||||
|
||||
assert {:error, _reason} = nip11_request(port, ca.certfile)
|
||||
|
||||
assert {:ok, 403} =
|
||||
nip11_request(
|
||||
port,
|
||||
ca.certfile,
|
||||
certfile: other_client.certfile,
|
||||
keyfile: other_client.keyfile
|
||||
)
|
||||
|
||||
assert {:ok, 200} =
|
||||
nip11_request(
|
||||
port,
|
||||
ca.certfile,
|
||||
certfile: allowed_client.certfile,
|
||||
keyfile: allowed_client.keyfile
|
||||
)
|
||||
end
|
||||
|
||||
test "WSS relay accepts a pinned server certificate", %{tmp_dir: tmp_dir} do
|
||||
ca = TLSCerts.create_ca!(tmp_dir, "wss")
|
||||
server = TLSCerts.issue_server_cert!(tmp_dir, ca, "wss-server")
|
||||
server_pin = TLSCerts.spki_pin!(server.certfile)
|
||||
port = free_port()
|
||||
|
||||
start_supervised!(
|
||||
{Endpoint,
|
||||
name: unique_name("TLSEndpointWSS"),
|
||||
listeners: %{
|
||||
wss_tls:
|
||||
listener(:wss_tls, port, %{
|
||||
transport: %{
|
||||
scheme: :https,
|
||||
tls: %{
|
||||
mode: :server,
|
||||
certfile: server.certfile,
|
||||
keyfile: server.keyfile
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
)
|
||||
|
||||
server_config = %{
|
||||
url: "wss://localhost:#{port}/relay",
|
||||
tls: %{
|
||||
mode: :required,
|
||||
hostname: "localhost",
|
||||
pins: [%{type: :spki_sha256, value: server_pin}]
|
||||
}
|
||||
}
|
||||
|
||||
assert {:ok, websocket} =
|
||||
WebSockexClient.connect(
|
||||
self(),
|
||||
server_config,
|
||||
websocket_opts: [ssl_options: websocket_ssl_options(ca.certfile)]
|
||||
)
|
||||
|
||||
assert_receive {:sync_transport, ^websocket, :connected, _metadata}, 5_000
|
||||
|
||||
assert :ok = WebSockexClient.send_json(websocket, ["COUNT", "tls-sub", %{"kinds" => [1]}])
|
||||
|
||||
assert_receive {:sync_transport, ^websocket, :frame, ["COUNT", "tls-sub", payload]}, 5_000
|
||||
assert is_map(payload)
|
||||
assert Map.has_key?(payload, "count")
|
||||
end
|
||||
|
||||
test "WSS relay rejects a mismatched pinned server certificate", %{tmp_dir: tmp_dir} do
|
||||
ca = TLSCerts.create_ca!(tmp_dir, "wss-mismatch")
|
||||
server = TLSCerts.issue_server_cert!(tmp_dir, ca, "wss-mismatch-server")
|
||||
wrong_server = TLSCerts.issue_server_cert!(tmp_dir, ca, "wss-mismatch-other")
|
||||
wrong_pin = TLSCerts.spki_pin!(wrong_server.certfile)
|
||||
port = free_port()
|
||||
|
||||
start_supervised!(
|
||||
{Endpoint,
|
||||
name: unique_name("TLSEndpointWSSMismatch"),
|
||||
listeners: %{
|
||||
wss_tls_mismatch:
|
||||
listener(:wss_tls_mismatch, port, %{
|
||||
transport: %{
|
||||
scheme: :https,
|
||||
tls: %{
|
||||
mode: :server,
|
||||
certfile: server.certfile,
|
||||
keyfile: server.keyfile
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
)
|
||||
|
||||
server_config = %{
|
||||
url: "wss://localhost:#{port}/relay",
|
||||
tls: %{
|
||||
mode: :required,
|
||||
hostname: "localhost",
|
||||
pins: [%{type: :spki_sha256, value: wrong_pin}]
|
||||
}
|
||||
}
|
||||
|
||||
assert {:error, %WebSockex.ConnError{original: {:tls_alert, {:handshake_failure, _reason}}}} =
|
||||
WebSockexClient.connect(
|
||||
self(),
|
||||
server_config,
|
||||
websocket_opts: [ssl_options: websocket_ssl_options(ca.certfile)]
|
||||
)
|
||||
end
|
||||
|
||||
defp listener(id, port, overrides) do
|
||||
deep_merge(
|
||||
%{
|
||||
id: id,
|
||||
enabled: true,
|
||||
bind: %{ip: {127, 0, 0, 1}, port: port},
|
||||
transport: %{scheme: :http, tls: %{mode: :disabled}},
|
||||
proxy: %{trusted_cidrs: [], honor_x_forwarded_for: true},
|
||||
network: %{allow_all: true},
|
||||
features: %{
|
||||
nostr: %{enabled: true},
|
||||
admin: %{enabled: true},
|
||||
metrics: %{enabled: false, access: %{allow_all: true}, auth_token: nil}
|
||||
},
|
||||
auth: %{nip42_required: false, nip98_required_for_admin: true},
|
||||
baseline_acl: %{read: [], write: []},
|
||||
bandit_options: []
|
||||
},
|
||||
overrides
|
||||
)
|
||||
end
|
||||
|
||||
defp nip11_request(port, cacertfile, opts \\ []) do
|
||||
transport_opts =
|
||||
[
|
||||
mode: :binary,
|
||||
verify: :verify_peer,
|
||||
cacertfile: String.to_charlist(cacertfile),
|
||||
server_name_indication: ~c"localhost",
|
||||
customize_hostname_check: [
|
||||
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
|
||||
]
|
||||
]
|
||||
|> maybe_put_file_opt(:certfile, Keyword.get(opts, :certfile))
|
||||
|> maybe_put_file_opt(:keyfile, Keyword.get(opts, :keyfile))
|
||||
|
||||
case Req.get(
|
||||
url: "https://localhost:#{port}/relay",
|
||||
headers: [{"accept", "application/nostr+json"}],
|
||||
decode_body: false,
|
||||
connect_options: [transport_opts: transport_opts]
|
||||
) do
|
||||
{:ok, %Req.Response{status: status}} -> {:ok, status}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp server_cert_fingerprint(port) do
|
||||
{:ok, socket} =
|
||||
:ssl.connect(
|
||||
~c"127.0.0.1",
|
||||
port,
|
||||
[verify: :verify_none, active: false, server_name_indication: ~c"localhost"],
|
||||
5_000
|
||||
)
|
||||
|
||||
{:ok, cert_der} = :ssl.peercert(socket)
|
||||
:ok = :ssl.close(socket)
|
||||
|
||||
Base.encode64(:crypto.hash(:sha256, cert_der))
|
||||
end
|
||||
|
||||
defp ca_certs(certfile) do
|
||||
certfile
|
||||
|> File.read!()
|
||||
|> :public_key.pem_decode()
|
||||
|> Enum.map(&elem(&1, 1))
|
||||
end
|
||||
|
||||
defp maybe_put_file_opt(options, _key, nil), do: options
|
||||
|
||||
defp maybe_put_file_opt(options, key, value) do
|
||||
Keyword.put(options, key, String.to_charlist(value))
|
||||
end
|
||||
|
||||
defp websocket_ssl_options(cacertfile) do
|
||||
[
|
||||
cacerts: ca_certs(cacertfile),
|
||||
server_name_indication: ~c"localhost",
|
||||
customize_hostname_check: [
|
||||
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
|
||||
]
|
||||
]
|
||||
end
|
||||
|
||||
defp deep_merge(left, right) when is_map(left) and is_map(right) do
|
||||
Map.merge(left, right, fn _key, left_value, right_value ->
|
||||
if is_map(left_value) and is_map(right_value) do
|
||||
deep_merge(left_value, right_value)
|
||||
else
|
||||
right_value
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp free_port do
|
||||
{:ok, socket} = :gen_tcp.listen(0, [:binary, active: false, packet: :raw, reuseaddr: true])
|
||||
{:ok, port} = :inet.port(socket)
|
||||
:ok = :gen_tcp.close(socket)
|
||||
port
|
||||
end
|
||||
|
||||
defp unique_name(prefix) do
|
||||
:"#{prefix}_#{System.unique_integer([:positive, :monotonic])}"
|
||||
end
|
||||
|
||||
defp assert_eventually(fun, attempts \\ 40)
|
||||
defp assert_eventually(_fun, 0), do: flunk("condition was not met in time")
|
||||
|
||||
defp assert_eventually(fun, attempts) do
|
||||
if fun.() do
|
||||
:ok
|
||||
else
|
||||
receive do
|
||||
after
|
||||
50 -> assert_eventually(fun, attempts - 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user