|
| 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