tests/test_node_otp26: portal-match TLS config + long node names + container stdout capture

Iterates on the docker-based repro infrastructure while diagnosing the
portal@unsandbox.com 'peer closed after 0/4 bytes' failure:

  - _write_ssl_config_portal_match() emits an ssl_dist.config with the
    exact options portal runs: permissive verify_fun (accepts bad_cert),
    versions [tlsv1.3, tlsv1.2], secure_renegotiate, server_name_indication
    disabled. Same shape as /opt/unsandbox/certs/inet_tls.conf.

  - Elixir docker peer now boots with --name (long FQDN, like prod)
    instead of --sname (short). Production portal is portal@unsandbox.com
    so the dist driver's routing path is different from short-name peers.

  - Elixir GenTarget now logs init + handle_call + handle_info. Fixture
    redirects container stdout to a file; test prints it on
    pass-or-fail so we can see whether the peer received our gen_call.

  - elixir image bumped to 1.16-otp-25 (portal runs OTP 25.3.2.5 / erts
    13.2.2.5, NOT OTP 26 — discovered via the portal release's bundled
    erts version).

Despite matching every dimension I can find (OTP version, TLS dist
config, Elixir GenServer wrapping, long node names), the test still
passes locally — so the production failure is something specific to
the live portal beam state, not a general protocol or version issue.
This commit is contained in:
russell@unturf.com 2026-06-17 09:32:17 -04:00
parent ca022a8f0d
commit b716f1487e
No known key found for this signature in database

View file

@ -279,6 +279,42 @@ def _write_ssl_config(workdir: Path, certs_in_container: dict[str, str]) -> str:
return str(cfg)
def _write_ssl_config_portal_match(workdir: Path, certs_in_container: dict[str, str]) -> str:
"""Match production portal's /opt/unsandbox/certs/inet_tls.conf exactly:
permissive verify_fun (accepts bad_cert), TLS 1.2/1.3 only, secure
renegotiate. This is the shape the production failure runs under."""
cfg = workdir / "ssl_dist_portal.config"
permissive_verify_fun = (
"{verify_fun, {fun(_,{bad_cert, _}, UserState) -> {valid, UserState}; "
" (_,{extension, _}, UserState) -> {unknown, UserState}; "
" (_, valid, UserState) -> {valid, UserState}; "
" (_, valid_peer, UserState) -> {valid, UserState} "
" end, []}}"
)
body = (
"[{server, "
f'[{{certfile, "{certs_in_container["server_cert"]}"}}, '
f'{{keyfile, "{certs_in_container["server_key"]}"}}, '
f'{{cacertfile, "{certs_in_container["ca"]}"}}, '
"{verify, verify_peer}, "
"{fail_if_no_peer_cert, true}, "
f"{permissive_verify_fun}, "
"{secure_renegotiate, true}, "
"{versions, ['tlsv1.3', 'tlsv1.2']}]}, "
"{client, "
f'[{{certfile, "{certs_in_container["server_cert"]}"}}, '
f'{{keyfile, "{certs_in_container["server_key"]}"}}, '
f'{{cacertfile, "{certs_in_container["ca"]}"}}, '
"{verify, verify_peer}, "
"{server_name_indication, disable}, "
f"{permissive_verify_fun}, "
"{secure_renegotiate, true}, "
"{versions, ['tlsv1.3', 'tlsv1.2']}]}]."
)
cfg.write_text(body)
return str(cfg)
@pytest.fixture(scope="module")
def otp26_tls_peer(tmp_path_factory):
"""OTP 26 + TLS dist peer in Docker — the production combo.
@ -401,14 +437,29 @@ defmodule GenTarget do
def start_link, do: GenServer.start_link(__MODULE__, %{}, name: :gen_target)
def init(state), do: {:ok, state}
def init(state) do
IO.puts("[gen_target] init")
{:ok, state}
end
def handle_call({:ping, x}, _from, state), do: {:reply, {:pong, x}, state}
def handle_call({:add, a, b}, _from, state), do: {:reply, {:ok, a + b}, state}
def handle_call(other, _from, state), do: {:reply, {:error, {:bad_request, other}}, state}
def handle_call(msg, from, state) do
IO.puts("[gen_target] handle_call msg=#{inspect(msg)} from=#{inspect(from)}")
reply = case msg do
{:ping, x} -> {:pong, x}
{:add, a, b} -> {:ok, a + b}
other -> {:error, {:bad_request, other}}
end
{:reply, reply, state}
end
def handle_info(msg, state) do
IO.puts("[gen_target] handle_info msg=#{inspect(msg)}")
{:noreply, state}
end
end
{:ok, _} = GenTarget.start_link()
IO.puts("[gen_target] registered: #{inspect(Process.whereis(:gen_target))}")
Process.sleep(:infinity)
"""
@ -441,13 +492,22 @@ def otp26_elixir_tls_peer(tmp_path_factory):
container_certs = {
k: v.replace(str(workdir), "/certs") for k, v in host_certs.items()
}
_write_ssl_config(workdir, container_certs)
ssl_config_container = "/certs/ssl_dist.config"
# Use portal's exact inet_tls.conf shape (permissive verify_fun,
# TLS 1.2/1.3 only, secure_renegotiate) so the test reproduces the
# exact wire-level conditions of the production failure.
_write_ssl_config_portal_match(workdir, container_certs)
ssl_config_container = "/certs/ssl_dist_portal.config"
boot_script = workdir / "boot.exs"
boot_script.write_text(ELIXIR_GENSERVER_BOOT)
container_name = f"erldistpy-elixir-tls-{int(time.time())}"
container_log = workdir / "container.log"
# Long node names (--name) match production. Short names (--sname)
# would route differently through the dist driver and may not
# reproduce the same failure mode.
long_node_name = f"{ELIXIR_TLS_SNAME}@127.0.0.1"
proc = subprocess.Popen(
[
@ -455,15 +515,15 @@ def otp26_elixir_tls_peer(tmp_path_factory):
"--name", container_name,
"--network", "host",
"-v", f"{workdir}:/certs:ro",
"elixir:1.16-otp-26",
"elixir:1.16-otp-25",
"elixir",
"--sname", ELIXIR_TLS_SNAME,
"--name", long_node_name,
"--cookie", ELIXIR_TLS_COOKIE,
"--erl", f"-proto_dist inet_tls -ssl_dist_optfile {ssl_config_container}",
"/certs/boot.exs",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdout=open(container_log, "w"),
stderr=subprocess.STDOUT,
)
deadline = time.monotonic() + 25.0
@ -480,7 +540,7 @@ def otp26_elixir_tls_peer(tmp_path_factory):
proc.wait(timeout=3)
pytest.skip(f"Elixir TLS docker node {ELIXIR_TLS_SNAME} did not register")
yield ELIXIR_TLS_SNAME, ELIXIR_TLS_COOKIE, host_certs
yield ELIXIR_TLS_SNAME, ELIXIR_TLS_COOKIE, host_certs, container_log
subprocess.run(["docker", "rm", "-f", container_name], capture_output=True)
proc.terminate()
@ -498,18 +558,26 @@ def test_call_elixir_genserver_over_tls_otp26(otp26_elixir_tls_peer):
:gen_server.reply/2 which sends via :erlang.send/2 same path as
Wallet.Bridge in production.
"""
sname, cookie, certs = otp26_elixir_tls_peer
sname, cookie, certs, container_log = otp26_elixir_tls_peer
ctx = make_dist_tls_context(
cert=certs["client_cert"],
key=certs["client_key"],
ca=certs["ca"],
)
with Node(
our_name="erldistpy_test_elixir_tls@localhost",
peer_name=sname,
cookie=cookie,
tls_context=ctx,
) as n:
assert n.tls is True
reply = n.call("gen_target", (Atom("ping"), 7), timeout=5.0)
assert reply == (Atom("pong"), 7)
try:
with Node(
our_name="erldistpy_test_elixir_tls@localhost",
peer_name=sname,
cookie=cookie,
tls_context=ctx,
) as n:
assert n.tls is True
reply = n.call("gen_target", (Atom("ping"), 7), timeout=5.0)
assert reply == (Atom("pong"), 7)
finally:
# Whether pass or fail, dump container output for diagnosis.
try:
text = container_log.read_text()
print(f"=== container stdout ===\n{text}\n=== end ===")
except FileNotFoundError:
pass