Skip to content

Commit 40a2424

Browse files
committed
Add support for mdns name resolution
Signed-off-by: Paul Guyot <pguyot@kallisys.net>
1 parent a613c12 commit 40a2424

File tree

6 files changed

+506
-8
lines changed

6 files changed

+506
-8
lines changed

libs/eavmlib/src/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ set(ERLANG_MODULES
3737
json_encoder
3838
ledc
3939
logger_manager
40+
mdns
4041
network
4142
network_fsm
4243
pico

libs/eavmlib/src/mdns.erl

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
%
2+
% This file is part of AtomVM.
3+
%
4+
% Copyright 2025 Paul Guyot <pguyot@kallisys.net>
5+
%
6+
% Licensed under the Apache License, Version 2.0 (the "License");
7+
% you may not use this file except in compliance with the License.
8+
% You may obtain a copy of the License at
9+
%
10+
% http://www.apache.org/licenses/LICENSE-2.0
11+
%
12+
% Unless required by applicable law or agreed to in writing, software
13+
% distributed under the License is distributed on an "AS IS" BASIS,
14+
% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
% See the License for the specific language governing permissions and
16+
% limitations under the License.
17+
%
18+
% SPDX-License-Identifier: Apache-2.0 OR LGPL-2.1-or-later
19+
%
20+
21+
-module(mdns).
22+
23+
-export([
24+
start_link/1,
25+
stop/1
26+
]).
27+
28+
% gen_server API
29+
-export([
30+
init/1,
31+
handle_call/3,
32+
handle_cast/2,
33+
handle_info/2,
34+
terminate/2
35+
]).
36+
37+
% unit test exports
38+
-export([
39+
parse_dns_message/1,
40+
parse_dns_name/2,
41+
serialize_dns_message/1,
42+
serialize_dns_name/1
43+
]).
44+
45+
-define(MDNS_PORT, 5353).
46+
-define(MDNS_MULTICAST_ADDR, {224, 0, 0, 251}).
47+
-define(DEFAULT_TTL, 900).
48+
49+
-type config() :: #{hostname := iodata(), interface := inet:ip4_address(), ttl => pos_integer()}.
50+
51+
%% @doc Start mdns server and resolve `Hostname'.local
52+
-spec start_link(Config :: config()) ->
53+
{ok, pid()} | {error, Reason :: term()}.
54+
start_link(Config) ->
55+
gen_server:start_link(?MODULE, Config, []).
56+
57+
%% @doc Stop mdns responder
58+
-spec stop(Pid :: pid()) -> ok.
59+
stop(Pid) ->
60+
gen_server:stop(Pid).
61+
62+
%%
63+
%% gen_server callbacks
64+
%%
65+
66+
-record(state, {
67+
socket :: any(),
68+
name :: binary(),
69+
select_ref :: reference() | undefined,
70+
self_addr :: map(),
71+
ttl :: pos_integer()
72+
}).
73+
74+
%% @hidden
75+
init(Config) ->
76+
Interface = maps:get(interface, Config),
77+
Hostname = maps:get(hostname, Config),
78+
TTL = maps:get(ttl, Config, ?DEFAULT_TTL),
79+
{ok, Socket} = socket:open(inet, dgram, udp),
80+
ok = socket:setopt(Socket, {socket, reuseaddr}, true),
81+
ok = socket:setopt(Socket, {ip, add_membership}, #{
82+
multiaddr => ?MDNS_MULTICAST_ADDR, interface => Interface
83+
}),
84+
% With esp-idf 5.4, we need to bind the socket to ANY, binding to
85+
% interface doesn't work.
86+
% TODO: investigate and maybe file a bug about it
87+
SelfAddrAny = #{
88+
family => inet,
89+
port => ?MDNS_PORT,
90+
addr => {0, 0, 0, 0}
91+
},
92+
ok = socket:bind(Socket, SelfAddrAny),
93+
SelfAddr = maps:put(addr, Interface, SelfAddrAny),
94+
State0 = #state{
95+
socket = Socket,
96+
name = iolist_to_binary(Hostname),
97+
select_ref = undefined,
98+
self_addr = SelfAddr,
99+
ttl = TTL
100+
},
101+
State1 = socket_recvfrom(State0),
102+
{ok, State1}.
103+
104+
%% @hidden
105+
handle_call(_Msg, _From, State) ->
106+
{noreply, State}.
107+
108+
%% @hidden
109+
handle_cast(_Msg, State) ->
110+
{noreply, State}.
111+
112+
%% @hidden
113+
handle_info({'$socket', Socket, select, Ref}, #state{socket = Socket, select_ref = Ref} = State) ->
114+
NewState = socket_recvfrom(State),
115+
{noreply, NewState}.
116+
117+
%% @hidden
118+
terminate(_Reason, _State) ->
119+
ok.
120+
121+
socket_recvfrom(#state{socket = Socket} = State) ->
122+
case socket:recvfrom(Socket, 0, nowait) of
123+
{select, {select_info, recvfrom, Ref}} ->
124+
State#state{select_ref = Ref};
125+
{ok, {From, Data}} ->
126+
process_datagram(State, From, Data),
127+
socket_recvfrom(State)
128+
end.
129+
130+
-define(DNS_TYPE_A, 1).
131+
-define(DNS_CLASS_IN, 1).
132+
-define(DNS_OPCODE_STANDARD_QUERY, 0).
133+
-define(DNS_QR_QUERY, 0).
134+
-define(DNS_QR_REPLY, 1).
135+
-define(MDNS_SEND_BROADCAST_RESPONSE, 0).
136+
-define(MDNS_SEND_UNICAST_RESPONSE, 1).
137+
138+
-record(dns_question, {
139+
qname :: [unicode:latin1_binary()],
140+
qtype :: non_neg_integer(),
141+
unicast_response :: ?MDNS_SEND_BROADCAST_RESPONSE | ?MDNS_SEND_UNICAST_RESPONSE,
142+
qclass :: non_neg_integer()
143+
}).
144+
145+
-record(dns_rrecord, {
146+
name :: [unicode:latin1_binary()],
147+
type :: non_neg_integer(),
148+
class :: non_neg_integer(),
149+
ttl :: non_neg_integer(),
150+
rdata :: binary()
151+
}).
152+
153+
-record(dns_message, {
154+
id :: non_neg_integer(),
155+
qr :: ?DNS_QR_QUERY | ?DNS_QR_REPLY,
156+
opcode :: 0..15,
157+
aa :: 0..1,
158+
questions = [] :: [#dns_question{}],
159+
answers = [] :: [#dns_rrecord{}],
160+
authority_rr = [] :: [#dns_rrecord{}],
161+
additional_rr = [] :: [#dns_rrecord{}]
162+
}).
163+
parse_dns_message(
164+
<<ID:16, QR:1, Opcode:4, AA:1, _TC:1, _RD:1, _RA:1, 0:3, _RCode:4, QDCount:16, ANCount:16,
165+
NSCount:16, ARCount:16, Tail/binary>> = Message
166+
) ->
167+
case parse_dns_questions(Message, QDCount, Tail) of
168+
{ok, Questions, Rest} ->
169+
case parse_dns_rrecords(Message, ANCount + NSCount + ARCount, Rest) of
170+
{ok, RRecords0, <<>>} ->
171+
{AnswersRecords, RRecords1} = lists:split(ANCount, RRecords0),
172+
{AuthorityRecords, AdditionalRecords} = lists:split(NSCount, RRecords1),
173+
{ok, #dns_message{
174+
id = ID,
175+
qr = QR,
176+
aa = AA,
177+
opcode = Opcode,
178+
questions = Questions,
179+
answers = AnswersRecords,
180+
authority_rr = AuthorityRecords,
181+
additional_rr = AdditionalRecords
182+
}};
183+
{error, _} = ErrorT0 ->
184+
ErrorT0
185+
end;
186+
{error, _} = ErrorT1 ->
187+
ErrorT1
188+
end.
189+
190+
parse_dns_questions(Message, Count, Data) ->
191+
parse_dns_questions(Message, Count, Data, []).
192+
193+
parse_dns_questions(_Message, 0, Data, Acc) ->
194+
{ok, lists:reverse(Acc), Data};
195+
parse_dns_questions(Message, N, Data, Acc) ->
196+
case parse_dns_name(Message, Data) of
197+
{ok, {QName, <<QType:16, UnicastResponse:1, QClass:15, Tail/binary>>}} ->
198+
parse_dns_questions(Message, N - 1, Tail, [
199+
#dns_question{
200+
qname = QName,
201+
qtype = QType,
202+
unicast_response = UnicastResponse,
203+
qclass = QClass
204+
}
205+
| Acc
206+
]);
207+
{ok, _} ->
208+
{error, {invalid_question, Data}};
209+
{error, _} = ErrorT ->
210+
ErrorT
211+
end.
212+
213+
parse_dns_rrecords(Message, Count, Data) ->
214+
parse_dns_rrecords(Message, Count, Data, []).
215+
216+
parse_dns_rrecords(_Message, 0, Data, Acc) ->
217+
{ok, lists:reverse(Acc), Data};
218+
parse_dns_rrecords(Message, N, Data, Acc) ->
219+
case parse_dns_name(Message, Data) of
220+
{ok,
221+
{Name,
222+
<<Type:16, _CacheFlush:1, Class:15, TTL:32, RDLength:16, RData:RDLength/binary,
223+
Tail/binary>>}} ->
224+
parse_dns_rrecords(Message, N - 1, Tail, [
225+
#dns_rrecord{name = Name, type = Type, class = Class, ttl = TTL, rdata = RData}
226+
| Acc
227+
]);
228+
{ok, _} ->
229+
{error, {invalid_rrecord, Data}};
230+
{error, _} = ErrorT ->
231+
ErrorT
232+
end.
233+
234+
parse_dns_name(Message, Data) ->
235+
parse_dns_name(Message, Data, []).
236+
237+
parse_dns_name(_Message, <<0, Tail/binary>>, Acc) ->
238+
{ok, {lists:reverse(Acc), Tail}};
239+
parse_dns_name(Message, <<3:2, Ptr:14, Tail/binary>>, Acc) when byte_size(Message) > Ptr ->
240+
{_, PtrBin} = split_binary(Message, Ptr),
241+
case parse_dns_name(Message, PtrBin, Acc) of
242+
{ok, {Name, _OtherTail}} -> {ok, {Name, Tail}};
243+
{error, _} = ErrorT -> ErrorT
244+
end;
245+
parse_dns_name(Message, <<N, Name:N/binary, Rest/binary>>, Acc) when N < 64 ->
246+
parse_dns_name(Message, Rest, [Name | Acc]);
247+
parse_dns_name(_Message, Other, _Acc) ->
248+
{error, {invalid_name, Other}}.
249+
250+
% Ignore messages from self.
251+
process_datagram(#state{self_addr = SelfAddr}, SelfAddr, _Data) ->
252+
ok;
253+
process_datagram(#state{} = State, From, Data) ->
254+
case parse_dns_message(Data) of
255+
{ok, #dns_message{
256+
id = ID, qr = ?DNS_QR_QUERY, opcode = ?DNS_OPCODE_STANDARD_QUERY, questions = Questions
257+
}} ->
258+
lists:foreach(
259+
fun(Question) ->
260+
process_question(State, From, ID, Question)
261+
end,
262+
Questions
263+
);
264+
{ok, _} ->
265+
ok;
266+
{error, _} ->
267+
ok
268+
end.
269+
270+
process_question(
271+
#state{name = Name, socket = Socket, self_addr = SelfAddr, ttl = TTL},
272+
From,
273+
ID,
274+
#dns_question{
275+
qname = [Hostname, Domain],
276+
qtype = ?DNS_TYPE_A,
277+
unicast_response = UnicastResponse,
278+
qclass = ?DNS_CLASS_IN
279+
} = Question
280+
) ->
281+
case
282+
string:to_lower(binary_to_list(Domain)) =:= "local" andalso
283+
string:to_lower(binary_to_list(Hostname)) =:= string:to_lower(binary_to_list(Name))
284+
of
285+
true ->
286+
% This is our name.
287+
{IP1, IP2, IP3, IP4} = maps:get(addr, SelfAddr),
288+
Answer = #dns_message{
289+
id = ID,
290+
qr = ?DNS_QR_REPLY,
291+
aa = 1,
292+
opcode = ?DNS_OPCODE_STANDARD_QUERY,
293+
questions = [Question],
294+
answers = [
295+
#dns_rrecord{
296+
name = [Name, <<"local">>],
297+
type = ?DNS_TYPE_A,
298+
class = ?DNS_CLASS_IN,
299+
ttl = TTL,
300+
rdata = <<IP1, IP2, IP3, IP4>>
301+
}
302+
]
303+
},
304+
AnswerBin = serialize_dns_message(Answer),
305+
case UnicastResponse of
306+
?MDNS_SEND_UNICAST_RESPONSE ->
307+
socket:sendto(Socket, AnswerBin, From);
308+
?MDNS_SEND_BROADCAST_RESPONSE ->
309+
socket:sendto(Socket, AnswerBin, #{
310+
family => inet, addr => ?MDNS_MULTICAST_ADDR, port => ?MDNS_PORT
311+
})
312+
end;
313+
false ->
314+
ok
315+
end;
316+
process_question(_State, _From, _ID, _DNSQuestion) ->
317+
ok.
318+
319+
serialize_dns_message(#dns_message{
320+
id = ID,
321+
qr = QR,
322+
opcode = Opcode,
323+
aa = AA,
324+
questions = Questions,
325+
answers = Answers,
326+
authority_rr = AuthorityRR,
327+
additional_rr = AdditionalRR
328+
}) ->
329+
QuestionsBin = [serialize_dns_question(Question) || Question <- Questions],
330+
RRecordsBin = [
331+
serialize_dns_rrecord(RRecord)
332+
|| RRecord <- Answers ++ AuthorityRR ++ AdditionalRR
333+
],
334+
list_to_binary([
335+
<<ID:16, QR:1, Opcode:4, AA:1, 0:1, 0:1, 0:1, 0:3, 0:4, (length(Questions)):16,
336+
(length(Answers)):16, (length(AuthorityRR)):16, (length(AdditionalRR)):16>>,
337+
QuestionsBin,
338+
RRecordsBin
339+
]).
340+
341+
serialize_dns_question(#dns_question{qname = Name, qtype = QType, qclass = QClass}) ->
342+
NameBin = serialize_dns_name(Name),
343+
<<NameBin/binary, QType:16, QClass:16>>.
344+
345+
serialize_dns_rrecord(#dns_rrecord{
346+
name = Name, type = Type, class = Class, ttl = TTL, rdata = RData
347+
}) ->
348+
NameBin = serialize_dns_name(Name),
349+
<<NameBin/binary, Type:16, Class:16, TTL:32, (byte_size(RData)):16, RData/binary>>.
350+
351+
serialize_dns_name(Name) ->
352+
serialize_dns_name(Name, []).
353+
354+
serialize_dns_name([], Acc) ->
355+
list_to_binary(lists:reverse([<<0>> | Acc]));
356+
serialize_dns_name([Name | Tail], Acc) ->
357+
serialize_dns_name(Tail, [<<(byte_size(Name)), Name/binary>> | Acc]).

0 commit comments

Comments
 (0)