Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/esp32-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
10 changes: 10 additions & 0 deletions src/platforms/esp32/test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
139 changes: 139 additions & 0 deletions src/platforms/esp32/test/local_test_servers.escript
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env escript
%% -*- erlang -*-
%%
%% This file is part of AtomVM.
%%
%% Copyright 2026 Paul Guyot <pguyot@kallisys.net>
%%
%% 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, <<Id:2/binary, _Flags:2/binary, Rest/binary>>}} ->
%% 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, <<Id/binary, 16#81, 16#80, Rest/binary>>);
_ ->
ok
end,
dns_loop(Socket).

fail(Format, Args) ->
io:format(standard_error, Format ++ "~n", Args),
halt(1).
18 changes: 12 additions & 6 deletions src/platforms/esp32/test/main/test_erl_sources/test_socket.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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
Expand All @@ -162,8 +168,8 @@ test_udp(Active, QueryID) ->
true ->
ok =
receive
% {udp, Socket, {{1,1,1,1}, 53, <<QueryID:16, 1:1, _:7, _/binary>>}} -> ok; % not supported yet
{udp, _WrappedSocket, {1, 1, 1, 1}, 53, <<QueryID:16, B, _/binary>>} when
% {udp, Socket, {{10,0,2,2}, 53, <<QueryID:16, 1:1, _:7, _/binary>>}} -> ok; % not supported yet
{udp, _WrappedSocket, {10, 0, 2, 2}, 53, <<QueryID:16, B, _/binary>>} when
B band 16#80 =:= 16#80
->
ok;
Expand All @@ -183,8 +189,8 @@ test_udp(Active, QueryID) ->
false ->
ok =
case call(Socket, {recvfrom, 512, 30000}) of
% {ok, {{1,1,1,1}, 53, <<QueryID:16, 1:1, _:7, _/binary>>}} -> ok;
{ok, {{1, 1, 1, 1}, 53, <<QueryID:16, B, _/binary>>}} when
% {ok, {{10,0,2,2}, 53, <<QueryID:16, 1:1, _:7, _/binary>>}} -> ok;
{ok, {{10, 0, 2, 2}, 53, <<QueryID:16, B, _/binary>>}} when
B band 16#80 =:= 16#80
->
ok;
Expand Down
13 changes: 9 additions & 4 deletions src/platforms/esp32/test/main/test_erl_sources/test_ssl.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down