erldistpy/docs/ROADMAP.md
russell@unturf.com 271d712237
phase 6: TLS dist via inet_tls_dist
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.
2026-06-16 12:06:07 -04:00

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 lint works 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 None ride as atoms true / false / nil
  • Golden vectors decoded from real term_to_binary/1 output
  • 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 None cleanly on unregistered node, raises EpmdError on socket failures
  • Tests: recorded byte streams from real EPMD + live integration that spawns its own erl -sname and 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/1 output
  • Flag set in erldistpy/flags.py advertises 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

  • Channel wraps the post-handshake socket: 4-byte length frames, pass-through byte ('p'), control tuple + optional payload term
  • send_reg_send(from_pid, to_name, payload) helper for the common case
  • recv_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 erl node 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 construction
  • Node.call(target_name, request, timeout=5.0) implements the $gen_call protocol:
    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 as CallTimeout
  • 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 an ssl.SSLContext tuned 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=4 for the handshake when TLS is in use (the non-obvious quirk: inet_tls_dist uses {packet, 4} on the SSL socket, where plain inet_tcp_dist uses {packet, 2} during handshake)
  • Live tests: generate CA + SAN-bearing certs with openssl, spawn erl -proto_dist inet_tls, drive a full Node.call() through the tunnel. Negative tests confirm: plaintext connection fails, wrong-CA client cert fails.

Phase 7 — unfeed integration

  • ErlangDistTransport implementing unfeed's WalletTransport Protocol
  • Maps /v1/health, /v1/address, ... to gen_call requests
  • unfeed deploys with a config switch: wallet_transport = http | erldist
  • Cutover after a soak period on staging