diff --git a/libs/eavmlib/src/CMakeLists.txt b/libs/eavmlib/src/CMakeLists.txt index fb328613d..fd4e829e5 100644 --- a/libs/eavmlib/src/CMakeLists.txt +++ b/libs/eavmlib/src/CMakeLists.txt @@ -37,6 +37,7 @@ set(ERLANG_MODULES json_encoder ledc logger_manager + mdns network network_fsm pico diff --git a/libs/eavmlib/src/mdns.erl b/libs/eavmlib/src/mdns.erl new file mode 100644 index 000000000..061aa1876 --- /dev/null +++ b/libs/eavmlib/src/mdns.erl @@ -0,0 +1,361 @@ +% +% This file is part of AtomVM. +% +% Copyright 2025 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(mdns). + +-export([ + start_link/1, + stop/1 +]). + +% gen_server API +-export([ + init/1, + handle_call/3, + handle_cast/2, + handle_info/2, + terminate/2 +]). + +% unit test exports +-export([ + parse_dns_message/1, + parse_dns_name/2, + serialize_dns_message/1, + serialize_dns_name/1 +]). + +-define(MDNS_PORT, 5353). +-define(MDNS_MULTICAST_ADDR, {224, 0, 0, 251}). +-define(DEFAULT_TTL, 900). + +-type config() :: #{hostname := iodata(), interface := inet:ip4_address(), ttl => pos_integer()}. + +%% @doc Start mdns server and resolve `Hostname'.local +-spec start_link(Config :: config()) -> + {ok, pid()} | {error, Reason :: term()}. +start_link(Config) -> + gen_server:start_link(?MODULE, Config, []). + +%% @doc Stop mdns responder +-spec stop(Pid :: pid()) -> ok. +stop(Pid) -> + gen_server:stop(Pid). + +%% +%% gen_server callbacks +%% + +-record(state, { + socket :: any(), + name :: binary(), + select_ref :: reference() | undefined, + self_addr :: map(), + ttl :: pos_integer() +}). + +%% @hidden +init(Config) -> + Interface = maps:get(interface, Config), + Hostname = maps:get(hostname, Config), + TTL = maps:get(ttl, Config, ?DEFAULT_TTL), + {ok, Socket} = socket:open(inet, dgram, udp), + ok = socket:setopt(Socket, {socket, reuseaddr}, true), + ok = socket:setopt(Socket, {ip, add_membership}, #{ + multiaddr => ?MDNS_MULTICAST_ADDR, interface => Interface + }), + % With esp-idf 5.4, we need to bind the socket to ANY, binding to + % interface doesn't work. + % TODO: investigate and maybe file a bug about it + SelfAddrAny = #{ + family => inet, + port => ?MDNS_PORT, + addr => {0, 0, 0, 0} + }, + ok = socket:bind(Socket, SelfAddrAny), + SelfAddr = maps:put(addr, Interface, SelfAddrAny), + State0 = #state{ + socket = Socket, + name = iolist_to_binary(Hostname), + select_ref = undefined, + self_addr = SelfAddr, + ttl = TTL + }, + State1 = socket_recvfrom(State0), + {ok, State1}. + +%% @hidden +handle_call(_Msg, _From, State) -> + {noreply, State}. + +%% @hidden +handle_cast(_Msg, State) -> + {noreply, State}. + +%% @hidden +handle_info({'$socket', Socket, select, Ref}, #state{socket = Socket, select_ref = Ref} = State) -> + NewState = socket_recvfrom(State), + {noreply, NewState}. + +%% @hidden +terminate(_Reason, _State) -> + ok. + +socket_recvfrom(#state{socket = Socket} = State) -> + case socket:recvfrom(Socket, 0, nowait) of + {select, {select_info, recvfrom, Ref}} -> + State#state{select_ref = Ref}; + {ok, {From, Data}} -> + process_datagram(State, From, Data), + socket_recvfrom(State) + end. + +-define(DNS_TYPE_A, 1). +-define(DNS_CLASS_IN, 1). +-define(DNS_OPCODE_STANDARD_QUERY, 0). +-define(DNS_QR_QUERY, 0). +-define(DNS_QR_REPLY, 1). +-define(MDNS_SEND_BROADCAST_RESPONSE, 0). +-define(MDNS_SEND_UNICAST_RESPONSE, 1). + +-record(dns_question, { + qname :: [unicode:latin1_binary()], + qtype :: non_neg_integer(), + unicast_response :: ?MDNS_SEND_BROADCAST_RESPONSE | ?MDNS_SEND_UNICAST_RESPONSE, + qclass :: non_neg_integer() +}). + +-record(dns_rrecord, { + name :: [unicode:latin1_binary()], + type :: non_neg_integer(), + class :: non_neg_integer(), + ttl :: non_neg_integer(), + rdata :: binary() +}). + +-record(dns_message, { + id :: non_neg_integer(), + qr :: ?DNS_QR_QUERY | ?DNS_QR_REPLY, + opcode :: 0..15, + aa :: 0..1, + questions = [] :: [#dns_question{}], + answers = [] :: [#dns_rrecord{}], + authority_rr = [] :: [#dns_rrecord{}], + additional_rr = [] :: [#dns_rrecord{}] +}). +parse_dns_message( + <> = Message +) -> + case parse_dns_questions(Message, QDCount, Tail) of + {ok, Questions, Rest} -> + case parse_dns_rrecords(Message, ANCount + NSCount + ARCount, Rest) of + {ok, RRecords0, <<>>} -> + {AnswersRecords, RRecords1} = lists:split(ANCount, RRecords0), + {AuthorityRecords, AdditionalRecords} = lists:split(NSCount, RRecords1), + {ok, #dns_message{ + id = ID, + qr = QR, + aa = AA, + opcode = Opcode, + questions = Questions, + answers = AnswersRecords, + authority_rr = AuthorityRecords, + additional_rr = AdditionalRecords + }}; + {ok, _RRecords, <<_, _/binary>> = Rest} -> + {error, {extra_bytes_in_dns_message, Rest}}; + {error, _} = ErrorT0 -> + ErrorT0 + end; + {error, _} = ErrorT1 -> + ErrorT1 + end; +parse_dns_message(Other) -> + {error, {invalid_dns_header, Other}}. + +parse_dns_questions(Message, Count, Data) -> + parse_dns_questions(Message, Count, Data, []). + +parse_dns_questions(_Message, 0, Data, Acc) -> + {ok, lists:reverse(Acc), Data}; +parse_dns_questions(Message, N, Data, Acc) -> + case parse_dns_name(Message, Data) of + {ok, {QName, <>}} -> + parse_dns_questions(Message, N - 1, Tail, [ + #dns_question{ + qname = QName, + qtype = QType, + unicast_response = UnicastResponse, + qclass = QClass + } + | Acc + ]); + {ok, _} -> + {error, {invalid_question, Data}}; + {error, _} = ErrorT -> + ErrorT + end. + +parse_dns_rrecords(Message, Count, Data) -> + parse_dns_rrecords(Message, Count, Data, []). + +parse_dns_rrecords(_Message, 0, Data, Acc) -> + {ok, lists:reverse(Acc), Data}; +parse_dns_rrecords(Message, N, Data, Acc) -> + case parse_dns_name(Message, Data) of + {ok, + {Name, + <>}} -> + parse_dns_rrecords(Message, N - 1, Tail, [ + #dns_rrecord{name = Name, type = Type, class = Class, ttl = TTL, rdata = RData} + | Acc + ]); + {ok, _} -> + {error, {invalid_rrecord, Data}}; + {error, _} = ErrorT -> + ErrorT + end. + +parse_dns_name(Message, Data) -> + parse_dns_name(Message, Data, []). + +parse_dns_name(_Message, <<0, Tail/binary>>, Acc) -> + {ok, {lists:reverse(Acc), Tail}}; +parse_dns_name(Message, <<3:2, Ptr:14, Tail/binary>>, Acc) when byte_size(Message) > Ptr -> + {_, PtrBin} = split_binary(Message, Ptr), + case parse_dns_name(Message, PtrBin, Acc) of + {ok, {Name, _OtherTail}} -> {ok, {Name, Tail}}; + {error, _} = ErrorT -> ErrorT + end; +parse_dns_name(Message, <>, Acc) when N < 64 -> + parse_dns_name(Message, Rest, [Name | Acc]); +parse_dns_name(_Message, Other, _Acc) -> + {error, {invalid_name, Other}}. + +% Ignore messages from self. +process_datagram(#state{self_addr = SelfAddr}, SelfAddr, _Data) -> + ok; +process_datagram(#state{} = State, From, Data) -> + case parse_dns_message(Data) of + {ok, #dns_message{ + id = ID, qr = ?DNS_QR_QUERY, opcode = ?DNS_OPCODE_STANDARD_QUERY, questions = Questions + }} -> + lists:foreach( + fun(Question) -> + process_question(State, From, ID, Question) + end, + Questions + ); + {ok, _} -> + ok; + {error, _} -> + ok + end. + +process_question( + #state{name = Name, socket = Socket, self_addr = SelfAddr, ttl = TTL}, + From, + ID, + #dns_question{ + qname = [Hostname, Domain], + qtype = ?DNS_TYPE_A, + unicast_response = UnicastResponse, + qclass = ?DNS_CLASS_IN + } = Question +) -> + case + string:to_lower(binary_to_list(Domain)) =:= "local" andalso + string:to_lower(binary_to_list(Hostname)) =:= string:to_lower(binary_to_list(Name)) + of + true -> + % This is our name. + {IP1, IP2, IP3, IP4} = maps:get(addr, SelfAddr), + Answer = #dns_message{ + id = ID, + qr = ?DNS_QR_REPLY, + aa = 1, + opcode = ?DNS_OPCODE_STANDARD_QUERY, + questions = [Question], + answers = [ + #dns_rrecord{ + name = [Name, <<"local">>], + type = ?DNS_TYPE_A, + class = ?DNS_CLASS_IN, + ttl = TTL, + rdata = <> + } + ] + }, + AnswerBin = serialize_dns_message(Answer), + case UnicastResponse of + ?MDNS_SEND_UNICAST_RESPONSE -> + socket:sendto(Socket, AnswerBin, From); + ?MDNS_SEND_BROADCAST_RESPONSE -> + socket:sendto(Socket, AnswerBin, #{ + family => inet, addr => ?MDNS_MULTICAST_ADDR, port => ?MDNS_PORT + }) + end; + false -> + ok + end; +process_question(_State, _From, _ID, _DNSQuestion) -> + ok. + +serialize_dns_message(#dns_message{ + id = ID, + qr = QR, + opcode = Opcode, + aa = AA, + questions = Questions, + answers = Answers, + authority_rr = AuthorityRR, + additional_rr = AdditionalRR +}) -> + QuestionsBin = [serialize_dns_question(Question) || Question <- Questions], + RRecordsBin = [ + serialize_dns_rrecord(RRecord) + || RRecord <- Answers ++ AuthorityRR ++ AdditionalRR + ], + list_to_binary([ + <>, + QuestionsBin, + RRecordsBin + ]). + +serialize_dns_question(#dns_question{qname = Name, qtype = QType, qclass = QClass}) -> + NameBin = serialize_dns_name(Name), + <>. + +serialize_dns_rrecord(#dns_rrecord{ + name = Name, type = Type, class = Class, ttl = TTL, rdata = RData +}) -> + NameBin = serialize_dns_name(Name), + <>. + +serialize_dns_name(Name) -> + serialize_dns_name(Name, []). + +serialize_dns_name([], Acc) -> + list_to_binary(lists:reverse([<<0>> | Acc])); +serialize_dns_name([Name | Tail], Acc) -> + serialize_dns_name(Tail, [<<(byte_size(Name)), Name/binary>> | Acc]). diff --git a/libs/eavmlib/src/network.erl b/libs/eavmlib/src/network.erl index 52b21707c..5a8d8b7d8 100644 --- a/libs/eavmlib/src/network.erl +++ b/libs/eavmlib/src/network.erl @@ -38,10 +38,8 @@ -define(SERVER, ?MODULE). --type octet() :: 0..255. --type ipv4_address() :: {octet(), octet(), octet(), octet()}. -type ipv4_info() :: { - IPAddress :: ipv4_address(), NetMask :: ipv4_address(), Gateway :: ipv4_address() + IPAddress :: inet:ip4_address(), NetMask :: inet:ip4_address(), Gateway :: inet:ip4_address() }. -type ip_info() :: ipv4_info(). @@ -122,7 +120,7 @@ -type ap_started_config() :: {ap_started, fun(() -> term())}. -type ap_sta_connected_config() :: {sta_connected, fun((mac()) -> term())}. -type ap_sta_disconnected_config() :: {sta_disconnected, fun((mac()) -> term())}. --type ap_sta_ip_assigned_config() :: {sta_ip_assigned, fun((ipv4_address()) -> term())}. +-type ap_sta_ip_assigned_config() :: {sta_ip_assigned, fun((inet:ip4_address()) -> term())}. -type ap_config_property() :: ssid_config() | psk_config() @@ -143,7 +141,14 @@ | sntp_synchronized_config(). -type sntp_config() :: {sntp, [sntp_config_property()]}. --type network_config() :: [sta_config() | ap_config() | sntp_config()]. +-type mdns_hostname_config() :: {host, string() | binary()}. +-type mdns_ttl_config() :: {ttl, pos_integer()}. +-type mdns_config_property() :: + mdns_hostname_config() + | mdns_ttl_config(). +-type mdns_config() :: {mdns, [mdns_config_property()]}. + +-type network_config() :: [sta_config() | ap_config() | sntp_config() | mdns_config()]. -type db() :: integer(). @@ -151,7 +156,8 @@ config :: network_config(), port :: port(), ref :: reference(), - sta_ip_info :: ip_info() + sta_ip_info :: ip_info(), + mdns :: pid() | undefined }). %%----------------------------------------------------------------------------- @@ -365,9 +371,11 @@ handle_info({Ref, sta_beacon_timeout} = _Msg, #state{ref = Ref, config = Config} handle_info({Ref, sta_disconnected} = _Msg, #state{ref = Ref, config = Config} = State) -> maybe_sta_disconnected_callback(Config), {noreply, State}; -handle_info({Ref, {sta_got_ip, IpInfo}} = _Msg, #state{ref = Ref, config = Config} = State) -> +handle_info({Ref, {sta_got_ip, IpInfo}} = _Msg, #state{ref = Ref, config = Config} = State0) -> maybe_sta_got_ip_callback(Config, IpInfo), - {noreply, State#state{sta_ip_info = IpInfo}}; + State1 = State0#state{sta_ip_info = IpInfo}, + State2 = maybe_start_mdns(State1), + {noreply, State2}; handle_info({Ref, ap_started} = _Msg, #state{ref = Ref, config = Config} = State) -> maybe_ap_started_callback(Config), {noreply, State}; @@ -435,6 +443,28 @@ maybe_ap_sta_ip_assigned_callback(Config, Address) -> maybe_sntp_sync_callback(Config, TimeVal) -> maybe_callback1({synchronized, TimeVal}, proplists:get_value(sntp, Config)). +%% @private +maybe_start_mdns(#state{mdns = MDNSResponder} = State) when is_pid(MDNSResponder) -> + mdns:stop(MDNSResponder), + maybe_start_mdns(State#state{mdns = undefined}); +maybe_start_mdns(#state{config = Config, sta_ip_info = {InterfaceAddr, _, _}} = State) -> + case proplists:get_value(mdns, Config) of + undefined -> State; + MDNSConfig -> + MDNSMap0 = #{hostname => proplists:get_value(hostname, MDNSConfig), interface => InterfaceAddr}, + MDNSMap1 = case proplists:get_value(ttl, MDNSConfig) of + undefined -> MDNSMap0; + TTL -> MDNSMap0#{ttl => TTL} + end, + case mdns:start_link(MDNSMap1) of + {ok, Pid} -> + State#state{mdns = Pid}; + {error, _} -> + State + end + end. + + %% @private maybe_callback0(_Key, undefined) -> ok; diff --git a/tests/libs/eavmlib/CMakeLists.txt b/tests/libs/eavmlib/CMakeLists.txt index 71be02036..98de68e33 100644 --- a/tests/libs/eavmlib/CMakeLists.txt +++ b/tests/libs/eavmlib/CMakeLists.txt @@ -27,6 +27,7 @@ set(ERLANG_MODULES test_file test_ahttp_client test_http_server + test_mdns test_port test_timer_manager ) diff --git a/tests/libs/eavmlib/test_mdns.erl b/tests/libs/eavmlib/test_mdns.erl new file mode 100644 index 000000000..5ade0febb --- /dev/null +++ b/tests/libs/eavmlib/test_mdns.erl @@ -0,0 +1,152 @@ +% +% This file is part of AtomVM. +% +% Copyright 2025 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_mdns). +-include_lib("eunit/include/eunit.hrl"). + +-define(DNS_TYPE_A, 1). +-define(DNS_CLASS_IN, 1). +-define(DNS_OPCODE_STANDARD_QUERY, 0). +-define(DNS_QR_QUERY, 0). +-define(DNS_QR_REPLY, 1). +-define(MDNS_SEND_BROADCAST_RESPONSE, 0). +-define(MDNS_SEND_UNICAST_RESPONSE, 1). + +parse_dns_name_test_() -> + [ + ?_assertEqual( + {ok, {[<<"atomvm">>, <<"local">>], <<>>}}, + mdns:parse_dns_name(<<6, "atomvm", 5, "local", 0>>, <<6, "atomvm", 5, "local", 0>>) + ), + ?_assertEqual({ok, {[], <<42>>}}, mdns:parse_dns_name(<<0, 42>>, <<0, 42>>)), + ?_assertEqual({error, {invalid_name, <<42>>}}, mdns:parse_dns_name(<<42>>, <<42>>)), + ?_assertEqual({error, {invalid_name, <<>>}}, mdns:parse_dns_name(<<1, 42>>, <<1, 42>>)) + ]. + +parse_dns_message_test_() -> + [ + ?_assertMatch( + {ok, {dns_message, 0, 0, 0, 0, [], [], [], []}}, + mdns:parse_dns_message(<<0:16, 0:16, 0:16, 0:16, 0:16, 0:16>>) + ), + ?_assertMatch( + {ok, + {dns_message, 0, 0, 0, 0, + [{dns_question, [<<"atomvm">>, <<"local">>], ?DNS_TYPE_A, 1, ?DNS_CLASS_IN}], + [], [], []}}, + mdns:parse_dns_message( + <<0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 6, 97, 116, 111, 109, 118, 109, 5, 108, 111, + 99, 97, 108, 0, 0, 1, 128, 1>> + ) + ), + ?_assertMatch( + {ok, + {dns_message, 0, 0, 0, 0, + [{dns_question, [<<"atomvm">>, <<"local">>], ?DNS_TYPE_A, 0, ?DNS_CLASS_IN}], + [], [], []}}, + mdns:parse_dns_message( + <<0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 6, 97, 116, 111, 109, 118, 109, 5, 108, 111, + 99, 97, 108, 0, 0, 1, 0, 1>> + ) + ), + ?_assertMatch( + {ok, + {dns_message, 0, 0, 0, 0, + [ + {dns_question, [<<"_companion-link">>, <<"_tcp">>, <<"local">>], 12, 0, 1}, + {dns_question, [<<"_rdlink">>, <<"_tcp">>, <<"local">>], 12, 0, 1} + ], + [ + {dns_rrecord, [<<"_companion-link">>, <<"_tcp">>, <<"local">>], 12, 1, 4488, + <<6, 227, 131, 166, 227, 130, 186, 192, 12>>} + ], + [], []}}, + mdns:parse_dns_message( + <<0, 0, 0, 0, 0, 2, 0, 1, 0, 0, 0, 0, 15, 95, 99, 111, 109, 112, 97, 110, 105, 111, + 110, 45, 108, 105, 110, 107, 4, 95, 116, 99, 112, 5, 108, 111, 99, 97, 108, 0, + 0, 12, 0, 1, 7, 95, 114, 100, 108, 105, 110, 107, 192, 28, 0, 12, 0, 1, 192, 12, + 0, 12, 0, 1, 0, 0, 17, 136, 0, 9, 6, 227, 131, 166, 227, 130, 186, 192, 12>> + ) + ), + ?_assertMatch( + {error, {invalid_dns_header, <<>>}}, + mdns:parse_dns_message(<<>>) + ), + ?_assertMatch( + {error, {invalid_dns_header, <<0>>}}, + mdns:parse_dns_message(<<0>>) + ), + ?_assertMatch( + {error, {invalid_dns_header, <<0:16, 0, 255, 0:16, 0:16, 0:16, 0:16>>}}, + mdns:parse_dns_message(<<0:16, 0, 255, 0:16, 0:16, 0:16, 0:16>>) + ), + ?_assertMatch( + {error, {invalid_name, <<>>}}, + mdns:parse_dns_message(<<0:16, 0:16, 0:16, 0:16, 0:16, 1:16>>) + ), + ?_assertMatch( + {error, {invalid_name, <<>>}}, + mdns:parse_dns_message(<<0:16, 0:16, 1:16, 0:16, 0:16, 0:16>>) + ), + ?_assertMatch( + {error, {invalid_question, <<0>>}}, + mdns:parse_dns_message(<<0:16, 0:16, 1:16, 0:16, 0:16, 0:16, 0>>) + ), + ?_assertMatch( + {error, {invalid_name, <<1>>}}, + mdns:parse_dns_message(<<0:16, 0:16, 1:16, 0:16, 0:16, 0:16, 1>>) + ), + ?_assertMatch( + {error, {invalid_question, <<0, 0, 0>>}}, + mdns:parse_dns_message(<<0:16, 0:16, 1:16, 0:16, 0:16, 0:16, 0, 0:16>>) + ), + ?_assertMatch( + {error, {invalid_question, <<0, 0, 0, 0>>}}, + mdns:parse_dns_message(<<0:16, 0:16, 1:16, 0:16, 0:16, 0:16, 0, 0:16, 0>>) + ), + ?_assertMatch( + {error, {extra_bytes_in_dns_message, <<0>>}}, + mdns:parse_dns_message(<<0:16, 0:16, 1:16, 0:16, 0:16, 0:16, 0, 0:16, 0:16, 0>>) + ), + ?_assertMatch( + {error, {extra_bytes_in_dns_message, <<"*">>}}, + mdns:parse_dns_message(<<0:16, 0:16, 0:16, 0:16, 0:16, 0:16, 42>>) + ) + ]. + +serialize_dns_message_test_() -> + [ + ?_assertEqual( + <<0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 6, 97, 116, 111, 109, 118, 109, 5, 108, 111, 99, + 97, 108, 0, 0, 1, 0, 1>>, + mdns:serialize_dns_message( + {dns_message, 0, 0, 0, 0, + [{dns_question, [<<"atomvm">>, <<"local">>], ?DNS_TYPE_A, 1, ?DNS_CLASS_IN}], + [], [], []} + ) + ) + ]. + +serialize_dns_name_test_() -> + [ + ?_assertEqual( + <<6, "atomvm", 5, "local", 0>>, mdns:serialize_dns_name([<<"atomvm">>, <<"local">>]) + ) + ]. diff --git a/tests/libs/eavmlib/tests.erl b/tests/libs/eavmlib/tests.erl index 870b48c07..1eb41026b 100644 --- a/tests/libs/eavmlib/tests.erl +++ b/tests/libs/eavmlib/tests.erl @@ -27,6 +27,7 @@ start() -> test_dir, test_file, test_http_server, + test_mdns, test_port, test_timer_manager, test_ahttp_client