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

102 lines
4.3 KiB
Markdown

# 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