diff --git a/.github/workflows/esp32-build.yaml b/.github/workflows/esp32-build.yaml index 35e32d685e..da6f684651 100644 --- a/.github/workflows/esp32-build.yaml +++ b/.github/workflows/esp32-build.yaml @@ -257,6 +257,22 @@ jobs: idf.py set-target ${{matrix.esp-idf-target}} idf.py build + - name: Start local servers for qemu network tests + # test_socket and test_ssl connect to the runner (10.0.2.2 from inside + # qemu SLIRP) instead of an external site: CI egress is slow and lossy + # enough that external connections pile up in lwIP until the board OOMs. + if: matrix.esp-idf-target != 'esp32p4' + working-directory: ./src/platforms/esp32/test/ + run: | + set -e + nohup escript local_test_servers.escript --certdir /tmp > /tmp/local_test_servers.log 2>&1 & + for i in $(seq 1 20); do + grep -q listening /tmp/local_test_servers.log && break + sleep 0.5 + done + cat /tmp/local_test_servers.log + grep -q listening /tmp/local_test_servers.log || { echo "local test servers failed to start"; exit 1; } + - name: Run ESP32 tests using qemu with memory checks build # TODO: remove the following exclusion when ESP32P4 support is added to espressif/qemu if: matrix.esp-idf-target != 'esp32p4' diff --git a/src/platforms/esp32/test/README.md b/src/platforms/esp32/test/README.md index 8b715cc16f..a96b063cfb 100644 --- a/src/platforms/esp32/test/README.md +++ b/src/platforms/esp32/test/README.md @@ -12,6 +12,16 @@ AtomVM provides two paths for testing "on device", the locally run QEMU emulator Instructions for running the tests [on QEMU are documented here](https://doc.atomvm.org/main/build-instructions.html#running-tests-for-esp32). +The network tests (`test_socket`, `test_ssl`) connect to servers on the QEMU +SLIRP host (10.0.2.2) instead of an external site. Start them before running +pytest: + +```shell +escript local_test_servers.escript --certdir /tmp & +``` + +(They bind ports 80, 443 and 53, so this may require root on Linux.) + # Wokwi CI simulator testing Wokwi CI is a commercial cloud CI, see [wokwi-ci/getting-started](https://docs.wokwi.com/wokwi-ci/getting-started), running it locally requires you to obtain a `WOKWI_CLI_TOKEN` [Get token](https://wokwi.com/dashboard/ci) and usage fees may apply in the future - AtomVM uses it through the [pytest-embedded-wokwi](https://github.com/espressif/pytest-embedded/tree/main/pytest-embedded-wokwi) integration. diff --git a/src/platforms/esp32/test/local_test_servers.escript b/src/platforms/esp32/test/local_test_servers.escript new file mode 100644 index 0000000000..fd918ef1ec --- /dev/null +++ b/src/platforms/esp32/test/local_test_servers.escript @@ -0,0 +1,139 @@ +#!/usr/bin/env escript +%% -*- erlang -*- +%% +%% This file is part of AtomVM. +%% +%% Copyright 2026 Paul Guyot +%% +%% Licensed under the Apache License, Version 2.0 (the "License"); +%% you may not use this file except in compliance with the License. +%% You may obtain a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, software +%% distributed under the License is distributed on an "AS IS" BASIS, +%% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +%% See the License for the specific language governing permissions and +%% limitations under the License. +%% +%% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later +%% + +%% Local endpoints for the qemu network tests (test_socket, test_ssl). +%% +%% The qemu guest reaches this host at 10.0.2.2 (SLIRP user networking), so +%% test_socket and test_ssl talk to these servers instead of an external site: +%% CI runner egress is slow and lossy enough that external connections pile up +%% in lwIP until the board runs out of memory. +%% +%% - port 80: answers any request with an HTTP/1.1 301, like http://github.com +%% - port 443: TLS (self-signed, clients don't verify), answers HTTP/1.1 200 +%% - port 53: minimal UDP DNS responder, so the UDP test needs no real resolver +%% +%% Usage: escript local_test_servers.escript [--certdir DIR] + +main(Args) -> + CertDir = cert_dir(Args), + {ok, _} = application:ensure_all_started(ssl), + {ok, HTTPListen} = gen_tcp:listen(80, [binary, {active, false}, {reuseaddr, true}]), + {ok, TLSListen} = ssl:listen(443, [binary, {active, false}, {reuseaddr, true} | tls_opts(CertDir)]), + {ok, DNSSocket} = gen_udp:open(53, [binary, {active, false}, {reuseaddr, true}]), + spawn_link(fun() -> http_loop(HTTPListen) end), + spawn_link(fun() -> tls_loop(TLSListen) end), + io:format("listening on 0.0.0.0:80 (http), 0.0.0.0:443 (tls) and 0.0.0.0:53 (dns)~n"), + dns_loop(DNSSocket). + +cert_dir(["--certdir", Dir | _]) -> Dir; +cert_dir([_ | Rest]) -> cert_dir(Rest); +cert_dir([]) -> "/tmp". + +%% A self-signed RSA server certificate the client accepts without validation +%% (authmode none). Generated with openssl, like the retired python server. +tls_opts(CertDir) -> + Cert = filename:join(CertDir, "cert.pem"), + Key = filename:join(CertDir, "key.pem"), + case filelib:is_file(Cert) andalso filelib:is_file(Key) of + true -> + ok; + false -> + Command = lists:flatten(io_lib:format( + "openssl req -x509 -newkey rsa:2048 -nodes -keyout ~ts -out ~ts" + " -days 30 -subj /CN=10.0.2.2 2>&1", + [Key, Cert] + )), + Output = os:cmd(Command), + filelib:is_file(Cert) andalso filelib:is_file(Key) orelse + fail("openssl could not generate a certificate: ~ts", [Output]) + end, + [{certfile, Cert}, {keyfile, Key}]. + +http_loop(Listen) -> + {ok, Socket} = gen_tcp:accept(Listen), + try + handle_http(Socket) + catch + _:_ -> ok + end, + http_loop(Listen). + +handle_http(Socket) -> + _ = gen_tcp:recv(Socket, 0, 10000), + ok = gen_tcp:send(Socket, [ + <<"HTTP/1.1 301 Moved Permanently\r\n">>, + <<"Content-Length: 0\r\n">>, + <<"Location: https://10.0.2.2/\r\n">>, + <<"Connection: close\r\n\r\n">> + ]), + gen_tcp:close(Socket). + +tls_loop(Listen) -> + case ssl:transport_accept(Listen, 30000) of + {ok, Transport} -> + try + {ok, Socket} = ssl:handshake(Transport, 10000), + handle_tls(Socket) + catch + _:_ -> ok + end; + _ -> + ok + end, + tls_loop(Listen). + +handle_tls(Socket) -> + Body = <<"ok">>, + _ = ssl:recv(Socket, 0, 10000), + ok = ssl:send(Socket, [ + <<"HTTP/1.1 200 OK\r\n">>, + <<"Content-Type: text/plain\r\n">>, + [<<"Content-Length: ">>, integer_to_binary(byte_size(Body)), <<"\r\n">>], + <<"Connection: close\r\n\r\n">>, + Body + ]), + %% Let the client send its close_notify first: closing right after the + %% reply can turn into an RST that races the client's shutdown. + drain_tls(Socket), + ssl:close(Socket). + +drain_tls(Socket) -> + case ssl:recv(Socket, 0, 5000) of + {ok, _} -> drain_tls(Socket); + _ -> ok + end. + +dns_loop(Socket) -> + case gen_udp:recv(Socket, 0) of + {ok, {Address, Port, <>}} -> + %% The guest only checks the transaction id and the QR bit, so echo + %% the id and rewrite the header flags to a response (QR=1, RD=1, + %% RA=1) without synthesizing answer records. + ok = gen_udp:send(Socket, Address, Port, <>); + _ -> + ok + end, + dns_loop(Socket). + +fail(Format, Args) -> + io:format(standard_error, Format ++ "~n", Args), + halt(1). diff --git a/src/platforms/esp32/test/main/test_erl_sources/test_socket.erl b/src/platforms/esp32/test/main/test_erl_sources/test_socket.erl index 0648951e6e..0c30b8c1ce 100644 --- a/src/platforms/esp32/test/main/test_erl_sources/test_socket.erl +++ b/src/platforms/esp32/test/main/test_erl_sources/test_socket.erl @@ -39,7 +39,11 @@ test_tcp_client(Active, BinaryOpt) -> {proto, tcp}, {connect, true}, {controlling_process, self()}, - {address, "github.com"}, + % The test harness (local_test_servers.escript) answers on the qemu SLIRP + % host with the same 301 an http://github.com request would get: + % these tests only run under qemu (CONFIG_ETH_USE_OPENETH) and CI + % runner egress is too unreliable to reach the real site. + {address, "10.0.2.2"}, {port, 80}, {active, Active}, {buffer, 512}, @@ -148,8 +152,10 @@ test_udp(Active, QueryID) -> ], ok = call(Socket, {init, Params}, 30000), {ok, {MyIPAddr, _Port}} = call(Socket, {sockname}), + % local_test_servers.escript answers this DNS query on the SLIRP host + % (10.0.2.2:53), keeping the UDP path off the runner's real resolver. ok = - case call(Socket, {sendto, {1, 1, 1, 1}, 53, ?UDP_QUERY(QueryID)}) of + case call(Socket, {sendto, {10, 0, 2, 2}, 53, ?UDP_QUERY(QueryID)}) of % generic_unix socket driver {ok, _Len} -> ok; % esp32 socket driver @@ -162,8 +168,8 @@ test_udp(Active, QueryID) -> true -> ok = receive - % {udp, Socket, {{1,1,1,1}, 53, <>}} -> ok; % not supported yet - {udp, _WrappedSocket, {1, 1, 1, 1}, 53, <>} when + % {udp, Socket, {{10,0,2,2}, 53, <>}} -> ok; % not supported yet + {udp, _WrappedSocket, {10, 0, 2, 2}, 53, <>} when B band 16#80 =:= 16#80 -> ok; @@ -183,8 +189,8 @@ test_udp(Active, QueryID) -> false -> ok = case call(Socket, {recvfrom, 512, 30000}) of - % {ok, {{1,1,1,1}, 53, <>}} -> ok; - {ok, {{1, 1, 1, 1}, 53, <>}} when + % {ok, {{10,0,2,2}, 53, <>}} -> ok; + {ok, {{10, 0, 2, 2}, 53, <>}} when B band 16#80 =:= 16#80 -> ok; diff --git a/src/platforms/esp32/test/main/test_erl_sources/test_ssl.erl b/src/platforms/esp32/test/main/test_erl_sources/test_ssl.erl index 0401fc89f8..df97785665 100644 --- a/src/platforms/esp32/test/main/test_erl_sources/test_ssl.erl +++ b/src/platforms/esp32/test/main/test_erl_sources/test_ssl.erl @@ -26,15 +26,20 @@ start() -> Entropy = ssl:nif_entropy_init(), CtrDrbg = ssl:nif_ctr_drbg_init(), ok = ssl:nif_ctr_drbg_seed(CtrDrbg, Entropy, <<"AtomVM">>), - % Get address of github.com + % Exercise getaddrinfo through the SLIRP DNS forwarder (10.0.2.3, answered + % by the qemu host's resolver). {ok, Results} = net:getaddrinfo_nif("github.com", undefined), - [TCPAddr | _] = [ + [_TCPAddr | _] = [ Addr || #{addr := #{addr := Addr}, type := stream, protocol := tcp, family := inet} <- Results ], - % Connect to github.com:443 + % Connect to the TLS server the test harness (local_test_servers.escript) runs + % on the qemu SLIRP host: this test only runs under qemu + % (CONFIG_ETH_USE_OPENETH) and CI runner egress is too unreliable to + % reach an external site. The server answers like https://github.com + % would (HTTP/1.1), with a self-signed certificate (authmode is none). {ok, Socket} = socket:open(inet, stream, tcp), - ok = socket:connect(Socket, #{family => inet, addr => TCPAddr, port => 443}), + ok = socket:connect(Socket, #{family => inet, addr => {10, 0, 2, 2}, port => 443}), % Initialize SSL Socket and config SSLContext = ssl:nif_init(), ok = ssl:nif_set_bio(SSLContext, Socket),