Skip to content

Commit 9f28e83

Browse files
committed
Add support for mdns name resolution
Signed-off-by: Paul Guyot <pguyot@kallisys.net>
1 parent 32de311 commit 9f28e83

File tree

6 files changed

+554
-8
lines changed

6 files changed

+554
-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: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
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+
{ok, _RRecords, <<_, _/binary>> = Rest} ->
184+
{error, {extra_bytes_in_dns_message, Rest}};
185+
{error, _} = ErrorT0 ->
186+
ErrorT0
187+
end;
188+
{error, _} = ErrorT1 ->
189+
ErrorT1
190+
end;
191+
parse_dns_message(Other) ->
192+
{error, {invalid_dns_header, Other}}.
193+
194+
parse_dns_questions(Message, Count, Data) ->
195+
parse_dns_questions(Message, Count, Data, []).
196+
197+
parse_dns_questions(_Message, 0, Data, Acc) ->
198+
{ok, lists:reverse(Acc), Data};
199+
parse_dns_questions(Message, N, Data, Acc) ->
200+
case parse_dns_name(Message, Data) of
201+
{ok, {QName, <<QType:16, UnicastResponse:1, QClass:15, Tail/binary>>}} ->
202+
parse_dns_questions(Message, N - 1, Tail, [
203+
#dns_question{
204+
qname = QName,
205+
qtype = QType,
206+
unicast_response = UnicastResponse,
207+
qclass = QClass
208+
}
209+
| Acc
210+
]);
211+
{ok, _} ->
212+
{error, {invalid_question, Data}};
213+
{error, _} = ErrorT ->
214+
ErrorT
215+
end.
216+
217+
parse_dns_rrecords(Message, Count, Data) ->
218+
parse_dns_rrecords(Message, Count, Data, []).
219+
220+
parse_dns_rrecords(_Message, 0, Data, Acc) ->
221+
{ok, lists:reverse(Acc), Data};
222+
parse_dns_rrecords(Message, N, Data, Acc) ->
223+
case parse_dns_name(Message, Data) of
224+
{ok,
225+
{Name,
226+
<<Type:16, _CacheFlush:1, Class:15, TTL:32, RDLength:16, RData:RDLength/binary,
227+
Tail/binary>>}} ->
228+
parse_dns_rrecords(Message, N - 1, Tail, [
229+
#dns_rrecord{name = Name, type = Type, class = Class, ttl = TTL, rdata = RData}
230+
| Acc
231+
]);
232+
{ok, _} ->
233+
{error, {invalid_rrecord, Data}};
234+
{error, _} = ErrorT ->
235+
ErrorT
236+
end.
237+
238+
parse_dns_name(Message, Data) ->
239+
parse_dns_name(Message, Data, []).
240+
241+
parse_dns_name(_Message, <<0, Tail/binary>>, Acc) ->
242+
{ok, {lists:reverse(Acc), Tail}};
243+
parse_dns_name(Message, <<3:2, Ptr:14, Tail/binary>>, Acc) when byte_size(Message) > Ptr ->
244+
{_, PtrBin} = split_binary(Message, Ptr),
245+
case parse_dns_name(Message, PtrBin, Acc) of
246+
{ok, {Name, _OtherTail}} -> {ok, {Name, Tail}};
247+
{error, _} = ErrorT -> ErrorT
248+
end;
249+
parse_dns_name(Message, <<N, Name:N/binary, Rest/binary>>, Acc) when N < 64 ->
250+
parse_dns_name(Message, Rest, [Name | Acc]);
251+
parse_dns_name(_Message, Other, _Acc) ->
252+
{error, {invalid_name, Other}}.
253+
254+
% Ignore messages from self.
255+
process_datagram(#state{self_addr = SelfAddr}, SelfAddr, _Data) ->
256+
ok;
257+
process_datagram(#state{} = State, From, Data) ->
258+
case parse_dns_message(Data) of
259+
{ok, #dns_message{
260+
id = ID, qr = ?DNS_QR_QUERY, opcode = ?DNS_OPCODE_STANDARD_QUERY, questions = Questions
261+
}} ->
262+
lists:foreach(
263+
fun(Question) ->
264+
process_question(State, From, ID, Question)
265+
end,
266+
Questions
267+
);
268+
{ok, _} ->
269+
ok;
270+
{error, _} ->
271+
ok
272+
end.
273+
274+
process_question(
275+
#state{name = Name, socket = Socket, self_addr = SelfAddr, ttl = TTL},
276+
From,
277+
ID,
278+
#dns_question{
279+
qname = [Hostname, Domain],
280+
qtype = ?DNS_TYPE_A,
281+
unicast_response = UnicastResponse,
282+
qclass = ?DNS_CLASS_IN
283+
} = Question
284+
) ->
285+
case
286+
string:to_lower(binary_to_list(Domain)) =:= "local" andalso
287+
string:to_lower(binary_to_list(Hostname)) =:= string:to_lower(binary_to_list(Name))
288+
of
289+
true ->
290+
% This is our name.
291+
{IP1, IP2, IP3, IP4} = maps:get(addr, SelfAddr),
292+
Answer = #dns_message{
293+
id = ID,
294+
qr = ?DNS_QR_REPLY,
295+
aa = 1,
296+
opcode = ?DNS_OPCODE_STANDARD_QUERY,
297+
questions = [Question],
298+
answers = [
299+
#dns_rrecord{
300+
name = [Name, <<"local">>],
301+
type = ?DNS_TYPE_A,
302+
class = ?DNS_CLASS_IN,
303+
ttl = TTL,
304+
rdata = <<IP1, IP2, IP3, IP4>>
305+
}
306+
]
307+
},
308+
AnswerBin = serialize_dns_message(Answer),
309+
case UnicastResponse of
310+
?MDNS_SEND_UNICAST_RESPONSE ->
311+
socket:sendto(Socket, AnswerBin, From);
312+
?MDNS_SEND_BROADCAST_RESPONSE ->
313+
socket:sendto(Socket, AnswerBin, #{
314+
family => inet, addr => ?MDNS_MULTICAST_ADDR, port => ?MDNS_PORT
315+
})
316+
end;
317+
false ->
318+
ok
319+
end;
320+
process_question(_State, _From, _ID, _DNSQuestion) ->
321+
ok.
322+
323+
serialize_dns_message(#dns_message{
324+
id = ID,
325+
qr = QR,
326+
opcode = Opcode,
327+
aa = AA,
328+
questions = Questions,
329+
answers = Answers,
330+
authority_rr = AuthorityRR,
331+
additional_rr = AdditionalRR
332+
}) ->
333+
QuestionsBin = [serialize_dns_question(Question) || Question <- Questions],
334+
RRecordsBin = [
335+
serialize_dns_rrecord(RRecord)
336+
|| RRecord <- Answers ++ AuthorityRR ++ AdditionalRR
337+
],
338+
list_to_binary([
339+
<<ID:16, QR:1, Opcode:4, AA:1, 0:1, 0:1, 0:1, 0:3, 0:4, (length(Questions)):16,
340+
(length(Answers)):16, (length(AuthorityRR)):16, (length(AdditionalRR)):16>>,
341+
QuestionsBin,
342+
RRecordsBin
343+
]).
344+
345+
serialize_dns_question(#dns_question{qname = Name, qtype = QType, qclass = QClass}) ->
346+
NameBin = serialize_dns_name(Name),
347+
<<NameBin/binary, QType:16, QClass:16>>.
348+
349+
serialize_dns_rrecord(#dns_rrecord{
350+
name = Name, type = Type, class = Class, ttl = TTL, rdata = RData
351+
}) ->
352+
NameBin = serialize_dns_name(Name),
353+
<<NameBin/binary, Type:16, Class:16, TTL:32, (byte_size(RData)):16, RData/binary>>.
354+
355+
serialize_dns_name(Name) ->
356+
serialize_dns_name(Name, []).
357+
358+
serialize_dns_name([], Acc) ->
359+
list_to_binary(lists:reverse([<<0>> | Acc]));
360+
serialize_dns_name([Name | Tail], Acc) ->
361+
serialize_dns_name(Tail, [<<(byte_size(Name)), Name/binary>> | Acc]).

0 commit comments

Comments
 (0)