:ssl can run its TLS connections over a SmolNet stack. SmolNet.Inet.Tcp
and SmolNet.Inet6.Tcp, the callback modules behind :gen_tcp, also work as
:ssl's transport, for clients and servers. :ssl does the TLS: the
handshake, the encryption, and certificate verification. SmolNet carries the
bytes.
The socket under each TLS connection is an ordinary SmolNet TCP socket, so the
options, limits, and errors in Using :gen_tcp apply to it.
Before you start
:ssl is an OTP application and must be running before it is used. List
:ssl in extra_applications in mix.exs, or start it with
Application.ensure_all_started/1, as the example below does.
A SmolNet stack must be running too, with a link that carries its packets.
The example uses SmolNet.Loopback, as the :gen_tcp guide's loopback
example does, so the client and the server share one stack and need no
network.
A complete loopback example
This example makes a throwaway certificate authority and a server certificate
with :public_key.pkix_test_data/1, then runs a TLS echo over one loopback
stack. It needs nothing beyond OTP:
{:ok, _apps} = Application.ensure_all_started(:ssl)
{:ok, _link, stack} =
SmolNet.Loopback.start_link(
addresses: [{{127, 0, 0, 1}, 8}, {{0, 0, 0, 0, 0, 0, 0, 1}, 128}]
)
# A CA, and a server certificate it signs for this host's name.
key = [key: {:namedCurve, :secp256r1}, digest: :sha256]
chain = %{root: key, intermediates: [], peer: key}
%{server_config: server_config, client_config: client_config} =
:public_key.pkix_test_data(%{server_chain: chain, client_chain: chain})
transport = [
:inet,
{:cb_info, {SmolNet.Inet.Tcp, :tcp, :tcp_closed, :tcp_error}},
{:smolnet_stack, stack},
:binary,
{:active, false}
]
server_tls = [cert: server_config[:cert], key: server_config[:key]]
client_tls = [
verify: :verify_peer,
cacerts: client_config[:cacerts],
server_name_indication: :net_adm.localhost()
]
{:ok, listener} =
:ssl.listen(8443, [{:ip, {127, 0, 0, 1}} | transport] ++ server_tls)
server =
Task.async(fn ->
{:ok, accepted} = :ssl.transport_accept(listener, 5_000)
{:ok, tls} = :ssl.handshake(accepted, 5_000)
{:ok, request} = :ssl.recv(tls, 0, 5_000)
:ok = :ssl.send(tls, ["echo: ", request])
:ok = :ssl.close(tls)
end)
{:ok, client} =
:ssl.connect({127, 0, 0, 1}, 8443, transport ++ client_tls, 5_000)
:ok = :ssl.send(client, "hello")
{:ok, "echo: hello"} = :ssl.recv(client, 0, 5_000)
:ok = Task.await(server, 5_000)
:ok = :ssl.close(client)
:ok = :ssl.close(listener)
:ok = SmolNet.stop_stack(stack)pkix_test_data/1 names the server certificate after this host, as
:net_adm.localhost/0 returns it, so the client asks for that name. The
client verifies the certificate against the throwaway CA, as a real client
verifies one against a real CA; see
Verifying the server.
The examples in the sections below continue from this one, as if its last line, which stops the stack, had not run yet. The one exception is in Verifying the server, which needs a real network.
Transport options
:ssl takes its transport through the cb_info option. Use the callback for
the address family, with the matching family option:
| Family | cb_info | Family option |
|---|---|---|
| IPv4 | {SmolNet.Inet.Tcp, :tcp, :tcp_closed, :tcp_error} | :inet |
| IPv6 | {SmolNet.Inet6.Tcp, :tcp, :tcp_closed, :tcp_error} | :inet6 |
The three atoms are the tags of the transport's active-mode messages, the same
as :gen_tcp's. :ssl receives those messages itself; the process that owns
a TLS connection receives :ssl's messages instead.
The transport options go in the same list as the TLS options. :ssl keeps the
options it knows, and passes the rest, such as :smolnet_stack, the family,
:ip, and :nodelay, to the transport. An option that neither supports
returns {:error, :einval}. :ssl handles :mode, :active, and :packet
itself, on the decrypted data.
Over IPv6, only the family and the callback change:
transport6 = [
:inet6,
{:cb_info, {SmolNet.Inet6.Tcp, :tcp, :tcp_closed, :tcp_error}},
{:smolnet_stack, stack},
:binary,
{:active, false}
]
{:ok, listener6} =
:ssl.listen(8443, [{:ip, {0, 0, 0, 0, 0, 0, 0, 1}} | transport6] ++ server_tls)
server =
Task.async(fn ->
{:ok, accepted} = :ssl.transport_accept(listener6, 5_000)
{:ok, tls} = :ssl.handshake(accepted, 5_000)
{:error, :closed} = :ssl.recv(tls, 0, 5_000)
end)
{:ok, client6} =
:ssl.connect({0, 0, 0, 0, 0, 0, 0, 1}, 8443, transport6 ++ client_tls, 5_000)
:ok = :ssl.close(client6)
{:error, :closed} = Task.await(server, 5_000)
:ok = :ssl.close(listener6)Verifying the server
Certificate verification is :ssl's job, not SmolNet's. SmolNet carries the
handshake's bytes and never looks at a certificate, so configure verification
as for :ssl over the host's network. A client of a public server verifies
it against the operating system's trusted CAs. This example needs a stack
whose link reaches that server, rather than the loopback stack above, with
transport naming that stack:
public_tls = [
verify: :verify_peer,
cacerts: :public_key.cacerts_get(),
server_name_indication: ~c"example.com",
customize_hostname_check: [
match_fun: :public_key.pkix_verify_hostname_match_fun(:https)
]
]
# A placeholder: example.com's address, resolved as your network requires.
server_address = {192, 0, 2, 10}
{:ok, tls} = :ssl.connect(server_address, 443, transport ++ public_tls, 5_000)verify: :verify_peerchecks the server's certificate chain againstcacerts. It is the default for an:sslclient since OTP 26, which then refuses to connect withoutcacertsorcacertfile.:public_key.cacerts_get/0returns the operating system's trusted CAs.server_name_indicationnames the server.:sslsends the name in the handshake and checks the certificate against it. Without it,:sslchecks the certificate against the address, which a certificate rarely names.customize_hostname_checkwith the:httpsmatch function accepts wildcard certificates, such as one for*.example.com, as browsers do.
Connect to an address. SmolNet's transport does not resolve names, so
:ssl.connect/4 with a host name returns {:error, :einval}. Resolve the
name as your network requires, and give it to :ssl as
server_name_indication.
Servers
:ssl.listen/2 opens a SmolNet listener through the transport, with the
transport options, such as :ip and :backlog, that :gen_tcp.listen/2
takes. Port 0 picks a free port, and :ssl.sockname/1 reports it:
{:ok, listener} =
:ssl.listen(0, [{:ip, {127, 0, 0, 1}}, {:backlog, 16} | transport] ++ server_tls)
{:ok, {{127, 0, 0, 1}, port}} = :ssl.sockname(listener)A server accepts in two steps. :ssl.transport_accept/2 accepts a TCP
connection, and :ssl.handshake/2 runs the TLS handshake on it. A server
with many clients accepts in one process and hands each accepted socket to a
process of its own, with :ssl.controlling_process/2, to run the handshake,
so that a slow handshake does not hold up the next accept.
Closing the listener with :ssl.close/1 closes the SmolNet listener.
Connections already accepted stay open.
Upgrading a connected socket
A :gen_tcp socket already connected over SmolNet can become a TLS
connection, for a protocol that starts in the clear and then switches, such as
SMTP's STARTTLS. Upgrade the client side with :ssl.connect/3 and the server
side with :ssl.handshake/3, and pass cb_info in their options, as for a
new connection:
cb_info = {SmolNet.Inet.Tcp, :tcp, :tcp_closed, :tcp_error}
tcp_options = [
{:tcp_module, SmolNet.Inet.Tcp},
{:smolnet_stack, stack},
:inet,
:binary,
{:active, false}
]
{:ok, tcp_listener} = :gen_tcp.listen(2525, [{:ip, {127, 0, 0, 1}} | tcp_options])
server =
Task.async(fn ->
{:ok, socket} = :gen_tcp.accept(tcp_listener, 5_000)
{:ok, "STARTTLS\r\n"} = :gen_tcp.recv(socket, 10, 5_000)
:ok = :gen_tcp.send(socket, "GO\r\n")
{:ok, tls} = :ssl.handshake(socket, [{:cb_info, cb_info} | server_tls], 5_000)
{:ok, request} = :ssl.recv(tls, 0, 5_000)
:ok = :ssl.send(tls, ["echo: ", request])
:ok = :ssl.close(tls)
end)
{:ok, socket} = :gen_tcp.connect({127, 0, 0, 1}, 2525, tcp_options, 5_000)
:ok = :gen_tcp.send(socket, "STARTTLS\r\n")
{:ok, "GO\r\n"} = :gen_tcp.recv(socket, 4, 5_000)
{:ok, tls} = :ssl.connect(socket, [{:cb_info, cb_info} | client_tls], 5_000)
:ok = :ssl.send(tls, "hello")
{:ok, "echo: hello"} = :ssl.recv(tls, 0, 5_000)
:ok = Task.await(server, 5_000)
:ok = :ssl.close(tls)
:ok = :gen_tcp.close(tcp_listener)Once upgraded, the socket belongs to :ssl. Use it only through the TLS
connection that :ssl returned.
Active mode, options, and closing
A TLS connection has an active mode of its own, with the same values as
:gen_tcp's: false, true, :once, or a count. Set it with
:ssl.setopts/2. The owner then receives {:ssl, tls, data},
{:ssl_closed, tls}, {:ssl_error, tls, reason}, and, when a count runs
out, {:ssl_passive, tls}.
Socket options that :ssl does not handle itself pass through to SmolNet,
with the meanings and limits that Using :gen_tcp gives them.
They include nodelay, keepalive, buffer, recbuf, sndbuf, and
send_timeout. Set them when connecting or listening, and read them with
:ssl.getopts/2. All but recbuf and sndbuf can also be changed later
with :ssl.setopts/2:
server =
Task.async(fn ->
{:ok, accepted} = :ssl.transport_accept(listener, 5_000)
{:ok, tls} = :ssl.handshake(accepted, 5_000)
:ok = :ssl.send(tls, "hello")
:ssl.recv(tls, 0, 5_000)
end)
{:ok, tls} =
:ssl.connect({127, 0, 0, 1}, port, transport ++ [nodelay: true] ++ client_tls, 5_000)
:ok = :ssl.setopts(tls, keepalive: true, active: :once)
{:ok, options} = :ssl.getopts(tls, [:nodelay, :keepalive])
[keepalive: true, nodelay: true] = Enum.sort(options)
receive do
{:ssl, ^tls, "hello"} -> :ok
after
5_000 -> exit(:timeout)
end
:ok = :ssl.close(tls)
{:error, :closed} = Task.await(server, 5_000)recbuf and sndbuf are fixed when the socket is created, and
:ssl.setopts/2 refuses them with
{:error, {:options, {:socket_options, [recbuf: 4096], :einval}}}.
:ssl.close/1 sends TLS's close_notify alert and closes the SmolNet socket.
The peer's :ssl.recv/3 then returns {:error, :closed}, as above. A TLS
connection also closes when its owner exits.
Errors and timeouts
Connecting returns the transport's errors, such as {:error, :econnrefused}
when nothing listens on the port, or {:error, :enetunreach} when the stack
has no route. A failed handshake returns :ssl's own
{:error, {:tls_alert, {alert, description}}}. The timeout given to
:ssl.connect/4 applies to the TCP connection, and again to the handshake.
A receive that times out returns {:error, :timeout} and leaves the
connection open.
Once a connection is up, :ssl does not pass on the transport's reason. When
the SmolNet socket underneath fails, :ssl ends the TLS connection as if the
peer had closed it: a pending or later :ssl.recv/3 or :ssl.send/2 returns
{:error, :closed}, and an active connection's owner receives
{:ssl_closed, tls}. That covers:
- A reset from the peer, where
:gen_tcpwould report:econnreset. - A peer that vanishes while data is outstanding. After 924.6 s without an
answer, the user timeout fails the socket with
:etimedout. - A peer that vanishes from an idle connection. Nothing notices unless
keepaliveis on: then, after 2 hours of silence and 9 unanswered probes 75 s apart, the socket fails with:etimedout.:sslsends nothing on an idle connection itself, so a protocol that needs to notice sooner sends its own pings. - A stack that fails, or whose link dies under
link_down: :stop, where:gen_tcpwould report:enetdown. - A stack stopped with
SmolNet.stop_stack/1, where:gen_tcpwould report:closed.
The :gen_tcp guide describes these timers and failures. To
tell a stack's loss from a peer's, watch the stack with SmolNet.monitor/1.
{:error, :econnrefused} =
:ssl.connect({127, 0, 0, 1}, 9, transport ++ client_tls, 5_000)
server =
Task.async(fn ->
{:ok, accepted} = :ssl.transport_accept(listener, 5_000)
{:ok, tls} = :ssl.handshake(accepted, 5_000)
:ok = :ssl.send(tls, "ready")
:ssl.recv(tls, 0, 5_000)
end)
{:ok, tls} = :ssl.connect({127, 0, 0, 1}, port, transport ++ client_tls, 5_000)
{:ok, "ready"} = :ssl.recv(tls, 0, 5_000)
# A receive timeout leaves the connection open.
{:error, :timeout} = :ssl.recv(tls, 0, 100)
# Stopping the stack ends both TLS connections.
:ok = SmolNet.stop_stack(stack)
{:error, :closed} = :ssl.recv(tls, 0, 5_000)
{:error, :closed} = Task.await(server, 5_000)Memory
A SmolNet socket's process hibernates after 5 s without work, which drops
what its last transfer left on its heap. :ssl's own connection processes do
not, by default, so an idle TLS connection keeps its last records alive for
as long as it idles. For many long-lived, mostly idle connections, set
:ssl's hibernate_after option, in milliseconds, at both ends:
server_tls = [{:hibernate_after, 5_000} | server_tls]
client_tls = [{:hibernate_after, 5_000} | client_tls]Each hibernation costs a garbage collection. Choose a time well above the gaps within an exchange, so that a busy connection never pays for it.