From 1fe6f4da52556da6b9b066a225d0ff3bf29ed2d8 Mon Sep 17 00:00:00 2001 From: Paul Guyot Date: Wed, 25 Dec 2024 21:43:17 +0100 Subject: [PATCH] Add `erl_epmd` Signed-off-by: Paul Guyot --- CHANGELOG.md | 1 + libs/estdlib/src/CMakeLists.txt | 1 + libs/estdlib/src/erl_epmd.erl | 316 ++++++++++++++++++++++++++++++ tests/libs/estdlib/CMakeLists.txt | 1 + tests/libs/estdlib/test_epmd.erl | 125 ++++++++++++ tests/libs/estdlib/tests.erl | 1 + 6 files changed, 445 insertions(+) create mode 100644 libs/estdlib/src/erl_epmd.erl create mode 100644 tests/libs/estdlib/test_epmd.erl diff --git a/CHANGELOG.md b/CHANGELOG.md index c635d9ec3..a152a2618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `socket:getopt/2` - Added `atomvm:subprocess/4` to perform pipe/fork/execve on POSIX platforms - Added `externalterm_to_term_with_roots` to efficiently preserve roots when allocating memory for external terms. +- Added `erl_epmd` client implementation to epmd using `socket` module ### Changed diff --git a/libs/estdlib/src/CMakeLists.txt b/libs/estdlib/src/CMakeLists.txt index d6485125e..7c7a7e049 100644 --- a/libs/estdlib/src/CMakeLists.txt +++ b/libs/estdlib/src/CMakeLists.txt @@ -28,6 +28,7 @@ set(ERLANG_MODULES calendar code crypto + erl_epmd erts_debug ets gen_event diff --git a/libs/estdlib/src/erl_epmd.erl b/libs/estdlib/src/erl_epmd.erl new file mode 100644 index 000000000..cc8cd3e67 --- /dev/null +++ b/libs/estdlib/src/erl_epmd.erl @@ -0,0 +1,316 @@ +% +% This file is part of AtomVM. +% +% Copyright 2024 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 +% + +%%----------------------------------------------------------------------------- +%% @doc An implementation of the Erlang/OTP erl_epmd interface. +%% +%% This module implements a strict subset of the Erlang/OTP erl_epmd +%% interface. +%% @end +%%----------------------------------------------------------------------------- +-module(erl_epmd). + +% API +-export([ + start_link/0, + stop/0, + port_please/2, + register_node/2, + names/1 +]). + +% gen_server +-behaviour(gen_server). +-export([ + init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2, + code_change/3 +]). + +-record(state, {socket = undefined}). + +-define(EPMD_PORT, 4369). +-define(TIMEOUT, 5000). + +-define(NAMES_REQ, 110). +-define(ALIVE2_X_RESP, 118). +-define(PORT2_RESP, 119). +-define(ALIVE2_REQ, 120). +-define(ALIVE2_RESP, 121). +-define(PORT_PLEASE2_REQ, 122). + +-define(TCP_INET4_PROTOCOL, 0). +-define(ERLANG_NODE_TYPE, 77). +-define(VERSION, 6). + +-record(receive_port2_resp, { + port_no :: non_neg_integer(), + highest_version :: non_neg_integer(), + lowest_version :: non_neg_integer() +}). + +-record(alive2_resp, { + creation :: non_neg_integer() +}). + +%% @doc Start EPMD client +-spec start_link() -> {ok, pid()}. +start_link() -> + gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). + +%% @doc Stop EPMD client- +-spec stop() -> ok. +stop() -> + gen_server:call(?MODULE, stop, infinity). + +%% @param Name name of the node to get the port of +%% @param Host host of the node to get the port of +%% @doc Get the port and version of a node on a given host. +%% This function will connect to epmd on the host. +-spec port_please(Name :: iodata(), Host :: inet:hostname() | inet:ip_address()) -> + {port, inet:port_number(), non_neg_integer()} | noport. +port_please(Name, Host) -> + case inet:getaddr(Host, inet) of + {ok, IP} -> + {ok, Socket} = socket:open(inet, stream, tcp), + case socket:connect(Socket, #{addr => IP, port => ?EPMD_PORT, family => inet}) of + ok -> + NameBin = iolist_to_binary(Name), + Result = + case send_request(Socket, <>) of + {ok, #receive_port2_resp{ + port_no = PortNo, + highest_version = HighVersion, + lowest_version = LowVersion + }} when HighVersion >= ?VERSION andalso LowVersion =< ?VERSION -> + {port, PortNo, ?VERSION}; + {ok, #receive_port2_resp{port_no = PortNo}} -> + {port, PortNo, 0}; + {ok, _Unexpected} -> + noport; + {error, _} -> + noport + end, + ok = socket:close(Socket), + Result; + {error, _} -> + noport + end; + {error, _} -> + noport + end. + +%% @param Host the host to connect to +%% @return a list of names and ports of registered nodes +%% @doc Get the names and ports of all registered nodes +%% This function will connect to epmd on localhost. +-spec names(Host :: inet:hostname() | inet:ip_address()) -> + {ok, [{string(), inet:port_number()}]} | {error, any()}. +names(Host) -> + case inet:getaddr(Host, inet) of + {ok, IP} -> + {ok, Socket} = socket:open(inet, stream, tcp), + case socket:connect(Socket, #{addr => IP, port => ?EPMD_PORT, family => inet}) of + ok -> + Result = + case socket:send(Socket, <<1:16, ?NAMES_REQ>>) of + ok -> + case socket:recv(Socket, 4, ?TIMEOUT) of + {ok, <>} -> + receive_names_loop(Socket, <<>>, []); + {ok, Unexpected} -> + {error, {unexpected, Unexpected}}; + {error, _} = ErrRecv -> + ErrRecv + end; + {error, _} = ErrSend -> + ErrSend + end, + ok = socket:close(Socket), + Result; + {error, _} = ErrConnect -> + ErrConnect + end; + {error, _} = ErrGetAddr -> + ErrGetAddr + end. + +receive_names_loop(Socket, AccBuffer, AccL) -> + case binary:split(AccBuffer, <<"\n">>) of + [AccBuffer] -> + case socket:recv(Socket, 0, ?TIMEOUT) of + {error, closed} when AccBuffer =:= <<>> -> {ok, lists:reverse(AccL)}; + {error, _} = ErrT -> ErrT; + {ok, Data} -> receive_names_loop(Socket, <>, AccL) + end; + [<<"name ", RestLine/binary>>, RestBuffer] -> + case binary:split(RestLine, <<" at port ">>) of + [NameBin, PortBin] -> + try binary_to_integer(PortBin) of + Port -> + receive_names_loop(Socket, RestBuffer, [ + {binary_to_list(NameBin), Port} | AccL + ]) + catch + error:badarg -> + {error, {unexpected, <<"name ", RestLine/binary>>}} + end; + [_] -> + {error, {unexpected, <<"name ", RestLine/binary>>}} + end; + [UnexpectedLine, _RestBuffer] -> + {error, {unexpected, UnexpectedLine}} + end. + +%% @param Name name to register +%% @param Port port to register +%% @doc Register to local epmd and get a creation number +-spec register_node(Name :: iodata(), Port :: inet:port_number()) -> + {ok, non_neg_integer()} | {error, any()}. +register_node(Name, Port) -> + gen_server:call(?MODULE, {register_node, Name, Port}, infinity). + +%% @hidden +init([]) -> + State = #state{}, + {ok, State}. + +%% @hidden +handle_call({register_node, _Name, _Port}, _From, #state{socket = Socket} = State) when + Socket =/= undefined +-> + {reply, {error, already_registered}, State}; +handle_call({register_node, Name, Port}, _From, #state{} = State) -> + {ok, Socket} = socket:open(inet, stream, tcp), + case socket:connect(Socket, #{addr => {127, 0, 0, 1}, port => ?EPMD_PORT, family => inet}) of + ok -> + NameBin = iolist_to_binary(Name), + NameLen = byte_size(NameBin), + Packet = + <>, + case send_request(Socket, Packet) of + {ok, #alive2_resp{creation = Creation}} -> + {reply, {ok, Creation}, State#state{socket = Socket}}; + {error, _} = RequestErr -> + socket:close(Socket), + {reply, RequestErr, State} + end; + {error, _} = ConnectErr -> + socket:close(Socket), + {reply, ConnectErr, State} + end; +handle_call(stop, _From, State) -> + {stop, shutdown, ok, State}. + +%% @hidden +handle_cast(_Message, State) -> + {noreply, State}. + +%% @hidden +handle_info(_Message, State) -> + {noreply, State}. + +%% @hidden +terminate(_Reason, #state{socket = Socket}) -> + case Socket of + undefined -> ok; + _ -> socket:close(Socket) + end, + ok. + +%% @hidden +code_change(_OldVsn, State, _Extra) -> + {ok, State}. + +send_request(Socket, Request) -> + RequestSize = byte_size(Request), + case socket:send(Socket, <<(RequestSize):16, Request/binary>>) of + ok -> + case socket:recv(Socket, 1, ?TIMEOUT) of + {ok, <>} -> + receive_port2_resp(Socket); + {ok, <>} -> + receive_alive2_x_resp(Socket); + {ok, <>} -> + receive_alive2_resp(Socket); + {error, _} = ErrorRecv2 -> + ErrorRecv2 + end; + {error, _} = ErrorSend -> + ErrorSend + end. + +receive_port2_resp(Socket) -> + case socket:recv(Socket, 1, ?TIMEOUT) of + {ok, <<0>>} -> + case socket:recv(Socket, 10, ?TIMEOUT) of + {ok, + <>} -> + case socket:recv(Socket, NameLen + 2, ?TIMEOUT) of + {ok, <<_Name:NameLen/binary, ExtraLen:16>>} -> + case ExtraLen of + 0 -> + {ok, #receive_port2_resp{ + port_no = PortNo, + highest_version = HighestVersion, + lowest_version = LowestVersion + }}; + N -> + case socket:recv(Socket, N, ?TIMEOUT) of + {ok, _ExtraData} -> + {ok, #receive_port2_resp{ + port_no = PortNo, + highest_version = HighestVersion, + lowest_version = LowestVersion + }}; + {error, _} = ErrT1 -> + ErrT1 + end + end; + {error, _} = ErrT2 -> + ErrT2 + end; + {error, _} = ErrT3 -> + ErrT3 + end; + {ok, <>} -> + {error, N}; + {error, _} = ErrT4 -> + ErrT4 + end. + +receive_alive2_x_resp(Socket) -> + case socket:recv(Socket, 5, ?TIMEOUT) of + {ok, <<0, Creation:32>>} -> {ok, #alive2_resp{creation = Creation}}; + {ok, <>} -> {error, Err}; + {error, _} = ErrT -> ErrT + end. + +receive_alive2_resp(Socket) -> + case socket:recv(Socket, 5, ?TIMEOUT) of + {ok, <<0, Creation:16>>} -> {ok, #alive2_resp{creation = Creation}}; + {ok, <>} -> {error, Err}; + {error, _} = ErrT -> ErrT + end. diff --git a/tests/libs/estdlib/CMakeLists.txt b/tests/libs/estdlib/CMakeLists.txt index 7b52500d3..331f81463 100644 --- a/tests/libs/estdlib/CMakeLists.txt +++ b/tests/libs/estdlib/CMakeLists.txt @@ -26,6 +26,7 @@ set(ERLANG_MODULES test_apply test_binary test_calendar + test_epmd test_gen_event test_gen_server test_gen_statem diff --git a/tests/libs/estdlib/test_epmd.erl b/tests/libs/estdlib/test_epmd.erl new file mode 100644 index 000000000..1ae15dff1 --- /dev/null +++ b/tests/libs/estdlib/test_epmd.erl @@ -0,0 +1,125 @@ +% +% This file is part of AtomVM. +% +% Copyright 2024 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 +% + +-module(test_epmd). + +-export([test/0]). + +test() -> + case start_epmd() of + ok -> + ok = test_client(), + ok = test_two_clients(); + {error, not_found} -> + ok + end, + ok. + +start_epmd() -> + case erlang:system_info(machine) of + "BEAM" -> + % Assume epmd is always in PATH + _ = os:cmd("epmd -d"), + ok; + "ATOM" -> + {ok, _WhichPid, StdoutFd} = atomvm:subprocess( + "/bin/sh", ["sh", "-c", "epmd -d 2>&1"], [], [stdout] + ), + case atomvm:posix_read(StdoutFd, 256) of + {ok, <<"sh: epmd: command not found\n">>} -> + ok = atomvm:posix_close(StdoutFd), + {error, not_found}; + {ok, Output} -> + [_, <<"daemon =", _/binary>>] = binary:split(Output, <<"epmd running - ">>), + ok = atomvm:posix_close(StdoutFd), + ok + end + end. + +test_client() -> + {ok, Pid1} = erl_epmd:start_link(), + ok = + case erl_epmd:port_please("test_epmd", "host.invalid") of + noport -> + ok; + {error, nxdomain} -> + "BEAM" = erlang:system_info(machine), + true = erlang:system_info(otp_release) =< "22", + ok + end, + noport = erl_epmd:port_please("test_epmd", "localhost"), + {ok, Creation1} = erl_epmd:register_node("test_epmd", 12345), + {port, 12345, Version} = erl_epmd:port_please("test_epmd", "localhost"), + 6 = Version, + {error, already_registered} = erl_epmd:register_node("test_epmd", 12345), + {error, already_registered} = erl_epmd:register_node("test_epmd_new", 12346), + {ok, Names} = erl_epmd:names("localhost"), + true = lists:member({"test_epmd", 12345}, Names), + MonitorRef1 = monitor(process, Pid1), + unlink(Pid1), + erl_epmd:stop(), + shutdown = + receive + {'DOWN', MonitorRef1, process, Pid1, Reason1} -> Reason1 + after 5000 -> timeout + end, + + {ok, Pid2} = erl_epmd:start_link(), + noport = erl_epmd:port_please("test_epmd", "localhost"), + {ok, Creation2} = erl_epmd:register_node("test_epmd", 12345), + true = Creation1 =/= Creation2, + MonitorRef2 = monitor(process, Pid2), + unlink(Pid2), + erl_epmd:stop(), + shutdown = + receive + {'DOWN', MonitorRef2, process, Pid2, Reason2} -> Reason2 + after 5000 -> timeout + end, + + ok. + +test_two_clients() -> + {ok, Pid1} = erl_epmd:start_link(), + {ok, _Creation1} = erl_epmd:register_node("test_epmd_1", 12345), + unregister(erl_epmd), + {ok, Pid2} = erl_epmd:start_link(), + {ok, _Creation2} = erl_epmd:register_node("test_epmd_2", 12346), + {ok, Names} = erl_epmd:names("localhost"), + true = lists:member({"test_epmd_1", 12345}, Names), + true = lists:member({"test_epmd_2", 12346}, Names), + unlink(Pid2), + MonitorRef2 = monitor(process, Pid2), + erl_epmd:stop(), + shutdown = + receive + {'DOWN', MonitorRef2, process, Pid2, Reason2} -> Reason2 + after 5000 -> timeout + end, + register(erl_epmd, Pid1), + MonitorRef1 = monitor(process, Pid1), + unlink(Pid1), + erl_epmd:stop(), + shutdown = + receive + {'DOWN', MonitorRef1, process, Pid1, Reason1} -> Reason1 + after 5000 -> timeout + end, + ok. diff --git a/tests/libs/estdlib/tests.erl b/tests/libs/estdlib/tests.erl index 2092a540e..f8041e808 100644 --- a/tests/libs/estdlib/tests.erl +++ b/tests/libs/estdlib/tests.erl @@ -47,6 +47,7 @@ get_tests(_OTPVersion) -> test_apply, test_lists, test_calendar, + test_epmd, test_gen_event, test_gen_server, test_gen_statem,