make_dist_tls_context() builds an ssl.SSLContext tuned for OTP defaults
(verify_peer, mTLS, TLSv1.2 minimum). Node accepts tls_context= and
wraps the TCP socket in TLS before the v6 handshake runs.
Critical quirk found by experimentation: inet_tls_dist uses {packet, 4}
on the SSL socket during the handshake. Plain inet_tcp_dist uses
{packet, 2} for handshake then switches to {packet, 4} post-nodeup.
handshake() now takes a frame_size= kwarg (2 or 4); Node auto-selects 4
whenever tls_context is supplied.
Cert requirements (found by experimentation against Erlang E2E):
- CA cert with basicConstraints CA:TRUE
- Leaf certs with SAN including the dist hostname (and localhost)
- extendedKeyUsage covering both serverAuth and clientAuth
Tests:
- make_dist_tls_context unit tests
- Live: spawn erl -proto_dist inet_tls with SAN-bearing certs,
Node.call(gen_target, {ping, 99}) round-trips through the tunnel
- Live negative: plaintext connection to TLS-only peer must fail
- Live negative: client cert from a different CA must fail
115 tests green across 5 consecutive runs, lint clean.
53 lines
2 KiB
Python
53 lines
2 KiB
Python
"""TLS helper for distribution over ``inet_tls_dist``.
|
|
|
|
Erlang's ``inet_tls_dist`` runs the distribution protocol inside a
|
|
mutual-TLS tunnel. The wire flow is mostly unchanged after the tunnel
|
|
exists — EPMD still hands out the listener port in plaintext, then the
|
|
TCP connect to that port is immediately wrapped in TLS, then the v6
|
|
handshake runs over the wrapped socket.
|
|
|
|
One non-obvious quirk worth knowing: ``inet_tls_dist`` sets
|
|
``{packet, 4}`` on the SSL socket pre-nodeup (plain ``inet_tcp_dist``
|
|
uses ``{packet, 2}`` for the handshake then switches to ``{packet, 4}``
|
|
post-nodeup). Our :func:`erldistpy.handshake.handshake` takes a
|
|
``frame_size=`` kwarg; :class:`erldistpy.node.Node` passes ``4``
|
|
automatically whenever a ``tls_context`` is supplied.
|
|
|
|
This module exposes one helper, :func:`make_dist_tls_context`, which
|
|
builds an :class:`ssl.SSLContext` tuned for the OTP server's defaults
|
|
(verify_peer, fail_if_no_peer_cert). Pass it into :class:`Node` via the
|
|
``tls_context`` kwarg.
|
|
|
|
Secrets discipline: ``cert`` / ``key`` / ``ca`` are file *paths*. We
|
|
never read PEM contents into Python — :class:`ssl.SSLContext` loads
|
|
them through OpenSSL directly. Caller's job to keep ``key`` readable
|
|
only by the running process.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ssl
|
|
|
|
|
|
def make_dist_tls_context(
|
|
*,
|
|
cert: str,
|
|
key: str,
|
|
ca: str,
|
|
check_hostname: bool = False,
|
|
minimum_version: ssl.TLSVersion = ssl.TLSVersion.TLSv1_2,
|
|
) -> ssl.SSLContext:
|
|
"""Build an SSLContext for a TLS-dist client.
|
|
|
|
Defaults match the OTP server side (which verifies the client cert
|
|
and requires one). ``check_hostname=False`` because dist nodes are
|
|
identified by their cookie + cert chain, not by SNI hostname; flip
|
|
on if your CA pins per-node CNs.
|
|
"""
|
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
|
ctx.check_hostname = check_hostname
|
|
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
ctx.minimum_version = minimum_version
|
|
ctx.load_cert_chain(certfile=cert, keyfile=key)
|
|
ctx.load_verify_locations(cafile=ca)
|
|
return ctx
|