diff --git a/tests/test_node_otp26.py b/tests/test_node_otp26.py index ed3e629..b5ad7ae 100644 --- a/tests/test_node_otp26.py +++ b/tests/test_node_otp26.py @@ -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