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
13 changes: 13 additions & 0 deletions .github/workflows/esp32-build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,19 @@ jobs:
idf.py set-target ${{matrix.esp-idf-target}}
idf.py build

- name: Start local servers for qemu network tests
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).
13 changes: 7 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 @@ -33,13 +33,14 @@ start() ->
ok = test_connect_to_unreachable_recovers(),
0.

% requires local_test_servers.escript
test_tcp_client(Active, BinaryOpt) ->
Socket = open_port({spawn, "socket"}, []),
Params = [
{proto, tcp},
{connect, true},
{controlling_process, self()},
{address, "github.com"},
{address, "10.0.2.2"},
{port, 80},
{active, Active},
{buffer, 512},
Expand Down Expand Up @@ -149,7 +150,7 @@ test_udp(Active, QueryID) ->
ok = call(Socket, {init, Params}, 30000),
{ok, {MyIPAddr, _Port}} = call(Socket, {sockname}),
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 +163,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 -- uncomment after bitstring is merged
{udp, _WrappedSocket, {10, 0, 2, 2}, 53, <<QueryID:16, B, _/binary>>} when
B band 16#80 =:= 16#80
->
ok;
Expand All @@ -183,8 +184,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; % not supported yet -- uncomment after bitstring is merged
{ok, {{10, 0, 2, 2}, 53, <<QueryID:16, B, _/binary>>}} when
B band 16#80 =:= 16#80
->
ok;
Expand Down
7 changes: 3 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 @@ -21,20 +21,19 @@
-module(test_ssl).
-export([start/0]).

% requires local_test_servers.escript
start() ->
% start SSL
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
{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
{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
Loading