Packages

Aerospike driver for Elixir with an OTP-native cluster runtime

Current section

Files

Jump to
aerospike_driver lib aerospike transport tls.ex
Raw

lib/aerospike/transport/tls.ex

defmodule Aerospike.Transport.Tls do
@moduledoc """
TLS (`:ssl`) implementation of `Aerospike.Cluster.NodeTransport`.
The only real difference between this transport and
`Aerospike.Transport.Tcp` is how the socket is opened: `connect/3`
performs a plain TCP handshake and immediately upgrades the socket via
`:ssl.connect/3` with the caller-supplied verification / cert / key
opts. Once the TLS handshake completes the connection handle is an
`Aerospike.Transport.Tcp{}` struct whose `socket_mod` is `:ssl` rather
than `:gen_tcp`, so every post-connect operation — framing, auth,
compression, telemetry, stream framing — is shared with the plaintext
path. The other callbacks (`command/4`, `command_stream/4`, `info/2`,
`stream_open/4`, `stream_read/2`, `stream_close/1`, `close/1`,
`login/2`) therefore delegate straight to `Aerospike.Transport.Tcp`.
Callers select TLS by setting `transport: Aerospike.Transport.Tls` on
`Aerospike.start_link/1`. Three deployment shapes are supported:
* **Standard TLS** — server certificate verified against a CA bundle,
no client cert. Set `:tls_cacertfile` and `:tls_name`.
* **mTLS** — client presents a cert and key in addition to verifying
the server. Set `:tls_certfile` / `:tls_keyfile` alongside the CA
bundle.
* **PKI** — the server accepts the client cert as auth; `:user` /
`:password` may be omitted. Same opt shape as mTLS; auth is a
server-side configuration concern, not a transport one.
## `connect/3` options
Every option lives in the `opts` keyword list. Keys that `Transport.Tcp`
also accepts (`:connect_timeout_ms`, `:info_timeout`, `:node_name`,
`:user`, `:password`, `:session_token`, TCP tuning knobs) flow through
unchanged — the TCP handshake uses them before the TLS upgrade, and the
login RPC runs on top of the upgraded socket if credentials are set.
TLS-specific keys:
* `:tls_name` — string compared against the server certificate's
subject / SAN. When set, enables Server Name Indication (SNI) and
hostname verification. When absent, falls back to the `host`
argument passed to `connect/3`; callers that connect by IP must
set `:tls_name` explicitly because OTP's default `:verify_peer`
rejects an IP as a host name.
* `:tls_cacertfile` — path to a PEM-encoded CA bundle used to verify
the server certificate. Required when `:tls_verify` is
`:verify_peer` (the default).
* `:tls_certfile` / `:tls_keyfile` — PEM-encoded client certificate
and private key. Required for mTLS and PKI; omitted for standard
one-way TLS.
* `:tls_verify` — `:verify_peer` (default) or `:verify_none`. The
latter is test-only and logs a `:warning` at connect time.
* `:tls_opts` — extra keyword list passed through to `:ssl.connect/3`
verbatim after the plan-level keys above have been translated.
Use for rarely-tuned knobs (cipher suites, depth, CRL files) that
the plan does not expose as first-class options.
## Security notes
OTP does not verify hostnames by default even with `:verify_peer`
the caller must supply `:server_name_indication` and a `:customize_hostname_check`
configuration. This module builds both from `:tls_name` so a caller
who sets the CA bundle and the expected name automatically gets
hostname verification. Callers who need stricter verification (CRL,
OCSP, pinning) pass extra opts through `:tls_opts`.
"""
@behaviour Aerospike.Cluster.NodeTransport
require Logger
alias Aerospike.Error
alias Aerospike.Transport.Tcp
# `:ssl` and `:public_key` ship with OTP but are not always loaded by
# the Elixir compiler's xref step on small hosts. They are listed in
# `extra_applications` (`mix.exs`) so the release starts them at
# boot; this silences the compile-time warning without masking real
# typos.
@compile {:no_warn_undefined, [:ssl, :public_key]}
@default_connect_timeout_ms 5_000
@typedoc """
TLS connection option accepted by `connect/3`.
Includes the plaintext TCP options documented by
`Aerospike.Transport.Tcp` plus TLS verification and certificate keys.
"""
@type connect_option ::
Tcp.connect_option()
| {:tls_name, String.t() | nil}
| {:tls_cacertfile, Path.t() | nil}
| {:tls_certfile, Path.t() | nil}
| {:tls_keyfile, Path.t() | nil}
| {:tls_verify, :verify_peer | :verify_none}
| {:tls_opts, keyword()}
@typedoc "Keyword list accepted by `connect/3`."
@type connect_opts :: [connect_option()]
@doc """
Opens a TCP connection, upgrades it with TLS, and returns a transport handle.
TLS-specific options are documented in the module docs. After the handshake,
command framing and optional auth use `Aerospike.Transport.Tcp`. See
`t:connect_option/0` for supported keys.
"""
@impl true
@spec connect(String.t(), :inet.port_number(), connect_opts()) ::
{:ok, Tcp.conn()} | {:error, Error.t()}
def connect(host, port, opts \\ []) when is_binary(host) and is_integer(port) do
connect_timeout_ms = Keyword.get(opts, :connect_timeout_ms, @default_connect_timeout_ms)
tcp_opts = build_tcp_opts(opts, connect_timeout_ms)
ssl_opts = build_ssl_opts(host, opts)
with {:ok, tcp_socket} <-
tcp_connect(host, port, tcp_opts, connect_timeout_ms),
{:ok, ssl_socket} <- ssl_upgrade(tcp_socket, ssl_opts, connect_timeout_ms, host, port) do
conn = Tcp.wrap_ssl_socket(ssl_socket, opts)
Tcp.maybe_login_after_handshake(conn, opts, host, port)
end
end
@doc """
Closes the TLS socket.
"""
@impl true
@spec close(Tcp.conn()) :: :ok
defdelegate close(conn), to: Tcp
@doc """
Sends one or more info commands over TLS and returns the decoded response map.
"""
@impl true
@spec info(Tcp.conn(), [String.t()]) ::
{:ok, %{String.t() => String.t()}} | {:error, Error.t()}
defdelegate info(conn, commands), to: Tcp
@doc """
Sends one pre-encoded command frame over TLS and returns one decoded response body.
Accepts `:use_compression`, `:message_type`, and `:attempt` in `opts`.
"""
@impl true
@spec command(Tcp.conn(), iodata(), non_neg_integer(), Tcp.command_opts()) ::
{:ok, binary()} | {:error, Error.t()}
defdelegate command(conn, request, deadline_ms, opts), to: Tcp
@doc """
Sends one pre-encoded command frame over TLS and reads a bounded multi-frame response.
Accepts the same options as `command/4`.
"""
@impl true
@spec command_stream(Tcp.conn(), iodata(), non_neg_integer(), Tcp.command_opts()) ::
{:ok, binary()} | {:error, Error.t()}
defdelegate command_stream(conn, request, deadline_ms, opts), to: Tcp
@doc """
Sends a streaming TLS request and returns a stream handle for incremental reads.
Accepts `:use_compression` and `:attempt` in `opts`.
"""
@impl true
@spec stream_open(Tcp.conn(), iodata(), non_neg_integer(), Tcp.stream_opts()) ::
{:ok, Tcp.stream()} | {:error, Error.t()}
defdelegate stream_open(conn, request, deadline_ms, opts), to: Tcp
@doc """
Reads the next frame from a TLS stream.
"""
@impl true
@spec stream_read(Tcp.stream(), non_neg_integer()) ::
{:ok, binary()} | :done | {:error, Error.t()}
defdelegate stream_read(stream, deadline_ms), to: Tcp
@doc """
Closes a TLS stream handle.
"""
@impl true
@spec stream_close(Tcp.stream()) :: :ok
defdelegate stream_close(stream), to: Tcp
@doc """
Runs the admin-protocol login or authenticate handshake over TLS.
See `t:Aerospike.Cluster.NodeTransport.login_opts/0` for supported keys.
"""
@impl true
@spec login(Tcp.conn(), Aerospike.Cluster.NodeTransport.login_opts()) ::
{:ok, Aerospike.Cluster.NodeTransport.login_reply()} | {:error, Error.t()}
defdelegate login(conn, opts), to: Tcp
# Base TCP opts only — every TLS-relevant option is applied by
# `:ssl.connect/3`, not the underlying TCP socket. We still apply the
# same send-buffer / recv-buffer / keepalive / nodelay tuning knobs the
# plaintext transport exposes, because they apply to the kernel socket
# regardless of whether it has been upgraded.
defp build_tcp_opts(opts, connect_timeout_ms) do
base = [:binary, {:active, false}, {:packet, :raw}, {:send_timeout, connect_timeout_ms}]
base
|> maybe_put_bool(opts, :tcp_nodelay, :nodelay, true)
|> maybe_put_bool(opts, :tcp_keepalive, :keepalive, true)
|> maybe_put_size(opts, :tcp_sndbuf, :sndbuf)
|> maybe_put_size(opts, :tcp_rcvbuf, :recbuf)
end
defp maybe_put_bool(acc, opts, public_key, inet_key, default) do
case Keyword.get(opts, public_key, default) do
value when is_boolean(value) -> acc ++ [{inet_key, value}]
other -> raise ArgumentError, bool_error(public_key, other)
end
end
defp maybe_put_size(acc, opts, public_key, inet_key) do
case Keyword.get(opts, public_key) do
nil ->
acc
value when is_integer(value) and value > 0 ->
acc ++ [{inet_key, value}]
other ->
raise ArgumentError, size_error(public_key, other)
end
end
# Translates the plan-level TLS keys into `:ssl.connect/3` options.
# OTP defaults to `:verify_none`, which silently accepts forged certs;
# we flip the default to `:verify_peer` so every TLS caller that does
# not opt out gets real verification. `:tls_verify: :verify_none`
# requires an explicit opt-out and logs a `:warning` at connect time.
defp build_ssl_opts(host, opts) do
verify = Keyword.get(opts, :tls_verify, :verify_peer)
name = Keyword.get(opts, :tls_name, host)
cacertfile = Keyword.get(opts, :tls_cacertfile)
certfile = Keyword.get(opts, :tls_certfile)
keyfile = Keyword.get(opts, :tls_keyfile)
extra = Keyword.get(opts, :tls_opts, [])
if verify == :verify_none do
Logger.warning(
"Aerospike.Transport.Tls: :tls_verify is :verify_none — " <>
"server certificate will not be validated. Only acceptable in tests."
)
end
[]
|> put_verify(verify)
|> put_sni_and_hostname_check(name, verify)
|> put_cacertfile(cacertfile)
|> put_client_cert(certfile, keyfile)
|> Kernel.++(extra)
end
defp put_verify(acc, :verify_peer), do: acc ++ [{:verify, :verify_peer}]
defp put_verify(acc, :verify_none), do: acc ++ [{:verify, :verify_none}]
defp put_verify(_acc, other) do
raise ArgumentError,
"Aerospike.Transport.Tls: :tls_verify must be :verify_peer or :verify_none, " <>
"got #{inspect(other)}"
end
# SNI is always useful (lets the server pick the right cert); hostname
# verification only applies under `:verify_peer`. OTP's default peer
# verification accepts any cert signed by the trusted CA even if the
# name does not match — `customize_hostname_check` with
# `:https.match_fun/1` is what pins the subject / SAN check.
defp put_sni_and_hostname_check(acc, nil, _verify), do: acc
defp put_sni_and_hostname_check(acc, name, :verify_peer) when is_binary(name) do
acc ++
[
{:server_name_indication, to_charlist(name)},
{:customize_hostname_check,
[match_fun: :public_key.pkix_verify_hostname_match_fun(:https)]}
]
end
defp put_sni_and_hostname_check(acc, name, :verify_none) when is_binary(name) do
acc ++ [{:server_name_indication, to_charlist(name)}]
end
defp put_cacertfile(acc, nil), do: acc
defp put_cacertfile(acc, path) when is_binary(path) do
acc ++ [{:cacertfile, path}]
end
defp put_cacertfile(_acc, other) do
raise ArgumentError,
"Aerospike.Transport.Tls: :tls_cacertfile must be a path string, got #{inspect(other)}"
end
defp put_client_cert(acc, nil, nil), do: acc
defp put_client_cert(acc, certfile, keyfile)
when is_binary(certfile) and is_binary(keyfile) do
acc ++ [{:certfile, certfile}, {:keyfile, keyfile}]
end
defp put_client_cert(_acc, certfile, keyfile) do
raise ArgumentError,
"Aerospike.Transport.Tls: :tls_certfile and :tls_keyfile must both be strings or " <>
"both be absent, got certfile=#{inspect(certfile)} keyfile=#{inspect(keyfile)}"
end
defp ssl_upgrade(tcp_socket, ssl_opts, timeout_ms, host, port) do
case :ssl.connect(tcp_socket, ssl_opts, timeout_ms) do
{:ok, ssl_socket} ->
{:ok, ssl_socket}
{:error, reason} ->
_ = :gen_tcp.close(tcp_socket)
{:error,
%Error{
code: :connection_error,
message:
"TLS handshake failed connecting to #{host}:#{port}: #{format_ssl_reason(reason)}"
}}
end
end
defp tcp_connect(host, port, tcp_opts, connect_timeout_ms) do
case :gen_tcp.connect(to_charlist(host), port, tcp_opts, connect_timeout_ms) do
{:ok, socket} ->
{:ok, socket}
{:error, reason} ->
{:error,
%Error{
code: :connection_error,
message: "failed to connect to #{host}:#{port}: #{format_ssl_reason(reason)}"
}}
end
end
defp bool_error(key, value) do
"Aerospike.Transport.Tls: #{inspect(key)} must be a boolean, got #{inspect(value)}"
end
defp size_error(key, value) do
"Aerospike.Transport.Tls: #{inspect(key)} must be a positive integer or nil, " <>
"got #{inspect(value)}"
end
defp format_ssl_reason({:tls_alert, {alert, detail}}) when is_atom(alert) do
"tls_alert #{alert}: #{detail}"
end
defp format_ssl_reason({:options, reason}) do
"invalid ssl options: #{inspect(reason)}"
end
defp format_ssl_reason(:timeout), do: "timeout"
defp format_ssl_reason(reason), do: inspect(reason)
end