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.
4.3 KiB
4.3 KiB
erldistpy roadmap
Phases below are sized to land as discrete commits. Each phase ends with
make all green and a real-VM interop test where applicable.
Phase 0 — Repo bones ✅
- LICENSE, README, .gitignore, Makefile, pyproject.toml
- Package skeleton, ruff config matches unfeed
make bootstrap test lintworks on a fresh checkout
Phase 1 — ETF codec ✅
- Subset of External Term Format we need for gen_call to Elixir: small_int, int, big_int, atom_utf8 (legacy atom_ext on decode), binary, nil, list, tuple (small + large), pid (new_pid), ref (newer_reference)
- Booleans and
Noneride as atomstrue/false/nil - Golden vectors decoded from real
term_to_binary/1output - Round-trip tests for the encoder
- Generator script committed at
docs/etf_vectors.erl
Phase 2 — EPMD client ✅
EPMD (Erlang Port Mapper Daemon) maps node names to TCP ports.
- Synchronous TCP client
PORT_PLEASE2_REQ(122) →EpmdInfo(name, port, node_type, protocol, hi_ver, lo_ver, extra)- Returns
Nonecleanly on unregistered node, raisesEpmdErroron socket failures - Tests: recorded byte streams from real EPMD + live integration that
spawns its own
erl -snameand tears it down
Phase 3 — Distribution handshake ✅
handshake(sock, our_name=..., cookie=...)drives the v6 dance: send_name(N) / recv_status(s) / recv_challenge(N) / send_challenge_reply(r) / recv_challenge_ack(a)- MD5 cookie digest, cross-checked against
erlang:md5/1output - Flag set in
erldistpy/flags.pyadvertises OTP 23+ compatibility - Tests:
- Frame builders/parsers as pure functions
- Cookie digest against an Erlang-computed reference
- Live handshake against
erl -sname ... -setcookie ... - Wrong-cookie test confirms peer rejection surfaces correctly
Newer SHA-256 digest (DFLAG_MANDATORY_25_DIGEST) deferred — landed only if we hit a peer that requires it.
Phase 4 — Distribution channel ✅
Channelwraps the post-handshake socket: 4-byte length frames, pass-through byte ('p'), control tuple + optional payload termsend_reg_send(from_pid, to_name, payload)helper for the common caserecv_message()skips ticks transparently;recv_raw()exposes them for callers that need tick awareness- Tick keepalive via
send_tick()— caller drives the timer for now (background ticker lands in Phase 7 alongside unfeed integration) - Tests:
- Pure encode/decode round-trips
- Socket-pair tests for send/recv framing, tick handling
- Live end-to-end against an
erlnode with a registered echo process: EPMD → handshake → REG_SEND → recv reply, payload matches - Survives an outbound tick before the reply
Phase 5 — gen_call convenience layer ✅
Node(our_name, peer_name, cookie)lazy-connects: EPMD lookup + TCP connect + v6 handshake on constructionNode.call(target_name, request, timeout=5.0)implements the$gen_callprotocol:caller -> {'$gen_call', {FromPid, Ref}, Request} (REG_SEND) server -> {Ref, Reply} (SEND)- Unique Ref per call, matched on receive — protocol violations
surface as
CallProtocolError, timeouts asCallTimeout - Tests:
{ping, X} -> {pong, X},{add, A, B} -> {ok, A+B}, sequential calls without crosstalk, timeout against a slow responder, drop-on-the-floor against an unknown registered name, Ref uniqueness across the Node lifetime
Phase 6 — TLS dist ✅
make_dist_tls_context(cert=, key=, ca=)helper builds anssl.SSLContexttuned for OTP defaults (verify_peer, TLSv1.2 min)Node(..., tls_context=ctx)wraps the post-EPMD socket in TLS before the dist handshake- Auto-selects
frame_size=4for the handshake when TLS is in use (the non-obvious quirk:inet_tls_distuses{packet, 4}on the SSL socket, where plaininet_tcp_distuses{packet, 2}during handshake) - Live tests: generate CA + SAN-bearing certs with openssl, spawn
erl -proto_dist inet_tls, drive a fullNode.call()through the tunnel. Negative tests confirm: plaintext connection fails, wrong-CA client cert fails.
Phase 7 — unfeed integration
ErlangDistTransportimplementing unfeed'sWalletTransportProtocol- Maps
/v1/health,/v1/address, ... to gen_call requests unfeeddeploys with a config switch:wallet_transport = http | erldist- Cutover after a soak period on staging