Add listener TLS support and pinning tests
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user