···11+repos:
22+ - repo: local
33+ hooks:
44+ - id: dune-format
55+ name: Auto format with dune
66+ entry: dune fmt --auto
77+ language: system
88+ files: \.(ml|mli|mll|mly)$
99+ stages: [pre-commit]
1010+ pass_filenames: false
1111+1212+ - id: remove-claude-attribution
1313+ name: Remove Claude attribution from commit message
1414+ entry: python3 .hooks/remove-claude-lines.py
1515+ language: system
1616+ stages: [commit-msg]
+21
LICENSE.md
···11+MIT License
22+33+Copyright (c) 2025 Thomas Gazagnaire
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+59
README.md
···11+# mdns
22+33+mDNS/DNS-SD service discovery (RFC 6762/6763) for OCaml.
44+55+## Overview
66+77+mdns provides Multicast DNS for DNS-based Service Discovery as specified in RFC 6762 and RFC 6763. It uses Eio for async networking and the dns library for query encoding.
88+99+This implementation handles raw domain names to support DNS-SD service instance names containing spaces (e.g., "My Device._http._tcp.local").
1010+1111+## Features
1212+1313+- **RFC 6762/6763 compliant**: Implements mDNS for DNS-SD
1414+- **Record types**: PTR, SRV, TXT, A, AAAA
1515+- **Eio-based**: Modern async networking with Eio
1616+- **Space-safe**: Handles service names with spaces
1717+- **Fuzz tested**: Parser tested with Crowbar
1818+1919+## Installation
2020+2121+```
2222+opam install mdns
2323+```
2424+2525+## Usage
2626+2727+```ocaml
2828+(* Discover HTTP services on the local network *)
2929+Eio_main.run @@ fun env ->
3030+Eio.Switch.run @@ fun sw ->
3131+let name = Domain_name.of_string_exn "_http._tcp.local" in
3232+let responses = Mdns.query
3333+ ~sw ~net:(Eio.Stdenv.net env)
3434+ ~clock:(Eio.Stdenv.clock env)
3535+ ~timeout:2.0 name
3636+in
3737+let merged = Mdns.merge responses in
3838+List.iter (fun (_, instance) ->
3939+ Format.printf "Found: %a@." Domain_name.pp instance
4040+) merged.ptrs
4141+```
4242+4343+## API
4444+4545+- `Mdns.query ~sw ~net ~clock ~timeout name` - Send mDNS query and collect responses
4646+- `Mdns.merge responses` - Combine multiple responses
4747+- `Mdns.parse buf` - Parse a raw mDNS packet
4848+- `Mdns.encode_query name` - Encode an mDNS query packet
4949+5050+## Related Work
5151+5252+- [ocaml-dns](https://github.com/mirage/ocaml-dns) - DNS library used for query encoding. Provides full DNS client/server but doesn't handle mDNS multicast or DNS-SD service names with spaces.
5353+- [charrua](https://github.com/mirage/charrua) - DHCP implementation for MirageOS, often used alongside mDNS for network discovery.
5454+5555+This library provides a focused mDNS/DNS-SD implementation using Eio for modern async networking.
5656+5757+## License
5858+5959+MIT License. See [LICENSE.md](LICENSE.md) for details.
+27
dune-project
···11+(lang dune 3.0)
22+33+(name mdns)
44+55+(generate_opam_files true)
66+77+(license MIT)
88+(authors "Thomas Gazagnaire <thomas@gazagnaire.org>")
99+(maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>")
1010+(homepage "https://tangled.org/@gazagnaire.org/ocaml-mdns")
1111+(bug_reports "https://tangled.org/@gazagnaire.org/ocaml-mdns/issues")
1212+1313+(package
1414+ (name mdns)
1515+ (synopsis "mDNS/DNS-SD service discovery (RFC 6763)")
1616+ (description
1717+ "A pure OCaml implementation of mDNS (Multicast DNS) for DNS-SD service \
1818+ discovery as specified in RFC 6763. Uses Eio for async networking and \
1919+ the dns library for query encoding.")
2020+ (depends
2121+ (ocaml (>= 4.08))
2222+ (eio (>= 1.0))
2323+ (dns (>= 9.0))
2424+ (ipaddr (>= 5.0))
2525+ (domain-name (>= 0.4))
2626+ (alcotest :with-test)
2727+ (crowbar :with-test)))
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Thomas Gazagnaire. All rights reserved.
33+ SPDX-License-Identifier: MIT
44+ ---------------------------------------------------------------------------*)
55+66+open Crowbar
77+88+(* Test that parse never crashes on arbitrary input *)
99+let test_parse_crash_safety buf =
1010+ (match Mdns.parse buf with Some _ -> () | None -> ());
1111+ check true
1212+1313+(* Test that merge is associative *)
1414+let test_merge_associative buf1 buf2 buf3 =
1515+ let r1 = match Mdns.parse buf1 with Some r -> r | None -> Mdns.empty in
1616+ let r2 = match Mdns.parse buf2 with Some r -> r | None -> Mdns.empty in
1717+ let r3 = match Mdns.parse buf3 with Some r -> r | None -> Mdns.empty in
1818+ let left = Mdns.merge [ Mdns.merge [ r1; r2 ]; r3 ] in
1919+ let right = Mdns.merge [ r1; Mdns.merge [ r2; r3 ] ] in
2020+ (* Check same number of records (order may differ) *)
2121+ if List.length left.ptrs <> List.length right.ptrs then
2222+ fail "merge not associative (ptrs)"
2323+ else if List.length left.addrs <> List.length right.addrs then
2424+ fail "merge not associative (addrs)"
2525+ else check true
2626+2727+(* Test that empty is identity for merge *)
2828+let test_merge_identity buf =
2929+ match Mdns.parse buf with
3030+ | None -> check true
3131+ | Some r ->
3232+ let merged = Mdns.merge [ r; Mdns.empty ] in
3333+ if List.length r.ptrs <> List.length merged.ptrs then
3434+ fail "empty not identity for merge (ptrs)"
3535+ else if List.length r.addrs <> List.length merged.addrs then
3636+ fail "empty not identity for merge (addrs)"
3737+ else check true
3838+3939+(* Test that parse produces valid response structure *)
4040+let test_parse_valid_structure buf =
4141+ match Mdns.parse buf with
4242+ | None -> check true
4343+ | Some r ->
4444+ (* All lists should be accessible without crash *)
4545+ let _ = List.length r.ptrs in
4646+ let _ = List.length r.srvs in
4747+ let _ = List.length r.txts in
4848+ let _ = List.length r.addrs in
4949+ let _ = List.length r.addrs6 in
5050+ check true
5151+5252+let () =
5353+ add_test ~name:"mdns: parse crash safety" [ bytes ] test_parse_crash_safety;
5454+ add_test ~name:"mdns: merge associative" [ bytes; bytes; bytes ]
5555+ test_merge_associative;
5656+ add_test ~name:"mdns: merge identity" [ bytes ] test_merge_identity;
5757+ add_test ~name:"mdns: parse valid structure" [ bytes ] test_parse_valid_structure
···11+(** mDNS discovery for DNS-SD (RFC 6763).
22+33+ Uses ocaml-dns for query encoding. Response parsing handles raw domain
44+ names since the dns library's Ptr.t enforces hostname validation which
55+ breaks DNS-SD service instance names containing spaces. *)
66+77+let multicast_addr = "224.0.0.251"
88+let port = 5353
99+1010+type response = {
1111+ ptrs : ([ `raw ] Domain_name.t * [ `raw ] Domain_name.t) list;
1212+ srvs : ([ `raw ] Domain_name.t * int * [ `raw ] Domain_name.t) list;
1313+ txts : ([ `raw ] Domain_name.t * string list) list;
1414+ addrs : ([ `raw ] Domain_name.t * Ipaddr.V4.t) list;
1515+ addrs6 : ([ `raw ] Domain_name.t * Ipaddr.V6.t) list;
1616+}
1717+1818+let empty = { ptrs = []; srvs = []; txts = []; addrs = []; addrs6 = [] }
1919+2020+(* Binary helpers *)
2121+let get_u16 buf off = (Char.code buf.[off] lsl 8) lor Char.code buf.[off + 1]
2222+2323+(* Collect labels from a DNS name, following compression pointers *)
2424+let collect_labels buf off =
2525+ let len = String.length buf in
2626+ let rec loop off followed acc =
2727+ (* Bounds check: need at least 1 byte to read length/pointer *)
2828+ if off < 0 || off >= len || followed > 10 then acc
2929+ else
3030+ match Char.code buf.[off] with
3131+ | 0 -> acc
3232+ | b when b land 0xc0 = 0xc0 ->
3333+ (* Compression pointer: need 2 bytes *)
3434+ if off + 2 > len then acc
3535+ else loop (get_u16 buf off land 0x3fff) (followed + 1) acc
3636+ | b ->
3737+ (* Label: need 1 + b bytes *)
3838+ if off + 1 + b > len then acc
3939+ else loop (off + 1 + b) followed (String.sub buf (off + 1) b :: acc)
4040+ in
4141+ List.rev (loop off 0 [])
4242+4343+let domain_of_labels labels =
4444+ match Domain_name.of_strings labels with
4545+ | Ok name -> name
4646+ | Error _ -> Domain_name.root
4747+4848+(* Skip past a DNS name, return new offset. Returns len on invalid input. *)
4949+let skip_name buf off =
5050+ let len = String.length buf in
5151+ let rec loop off followed =
5252+ (* Bounds check and recursion limit *)
5353+ if off < 0 || off >= len || followed > 10 then len
5454+ else
5555+ match Char.code buf.[off] with
5656+ | 0 -> off + 1
5757+ | b when b land 0xc0 = 0xc0 ->
5858+ (* Compression pointer: skip 2 bytes, don't follow *)
5959+ if off + 2 > len then len else off + 2
6060+ | b ->
6161+ (* Label: need 1 + b bytes to exist *)
6262+ if off + 1 + b > len then len else loop (off + 1 + b) (followed + 1)
6363+ in
6464+ loop off 0
6565+6666+(* Parse TXT record strings with bounds checking *)
6767+let parse_txt buf off limit =
6868+ let len = String.length buf in
6969+ let rec loop off acc =
7070+ (* Bounds check: off must be valid and within limit *)
7171+ if off < 0 || off >= limit || off >= len then List.rev acc
7272+ else
7373+ let slen = Char.code buf.[off] in
7474+ (* Check string data is within both limit and buffer *)
7575+ if off + 1 + slen <= limit && off + 1 + slen <= len then
7676+ loop (off + 1 + slen) (String.sub buf (off + 1) slen :: acc)
7777+ else List.rev acc
7878+ in
7979+ loop off []
8080+8181+(* Generate random 16-bit value for query ID *)
8282+let random_u16 () = Random.int 0x10000
8383+8484+(* Create mDNS query using dns library *)
8585+let make_query name =
8686+ let header = (random_u16 (), Dns.Packet.Flags.empty) in
8787+ Dns.Packet.create header
8888+ (Dns.Packet.Question.create name Dns.Rr_map.Ptr)
8989+ `Query
9090+9191+let encode_query name =
9292+ let pkt = make_query name in
9393+ fst (Dns.Packet.encode `Udp pkt)
9494+9595+(* Parse mDNS response *)
9696+let parse buf =
9797+ let len = String.length buf in
9898+ if len < 12 then None
9999+ else
100100+ let qdcount = get_u16 buf 4 in
101101+ let rr_count = get_u16 buf 6 + get_u16 buf 8 + get_u16 buf 10 in
102102+ let off = ref 12 in
103103+ (* Skip question section with bounds checking *)
104104+ for _ = 1 to qdcount do
105105+ let off' = skip_name buf !off in
106106+ (* Need 4 more bytes for qtype/qclass after name *)
107107+ if off' + 4 <= len then off := off' + 4
108108+ else off := len (* Invalid, skip to end *)
109109+ done;
110110+ let ptrs = ref [] and srvs = ref [] and txts = ref [] in
111111+ let addrs = ref [] and addrs6 = ref [] in
112112+ for _ = 1 to rr_count do
113113+ (* Check we have space for name + 10 byte RR header *)
114114+ if !off < len then begin
115115+ let name = domain_of_labels (collect_labels buf !off) in
116116+ let off' = skip_name buf !off in
117117+ (* Verify off' + 10 <= len before reading RR header *)
118118+ if off' + 10 <= len then begin
119119+ let typ = get_u16 buf off' in
120120+ let rdlen = get_u16 buf (off' + 8) in
121121+ let rdata = off' + 10 in
122122+ off := rdata + rdlen;
123123+ (* Verify rdata region is within bounds *)
124124+ if rdata + rdlen <= len then
125125+ match typ with
126126+ | 1 when rdlen = 4 ->
127127+ let ip = Ipaddr.V4.of_int32 (String.get_int32_be buf rdata) in
128128+ addrs := (name, ip) :: !addrs
129129+ | 12 ->
130130+ ptrs :=
131131+ (name, domain_of_labels (collect_labels buf rdata)) :: !ptrs
132132+ | 16 -> txts := (name, parse_txt buf rdata (rdata + rdlen)) :: !txts
133133+ | 28 when rdlen = 16 ->
134134+ let hi = String.get_int64_be buf rdata in
135135+ let lo = String.get_int64_be buf (rdata + 8) in
136136+ addrs6 := (name, Ipaddr.V6.of_int64 (hi, lo)) :: !addrs6
137137+ | 33 when rdlen >= 6 ->
138138+ let port = get_u16 buf (rdata + 4) in
139139+ let target = domain_of_labels (collect_labels buf (rdata + 6)) in
140140+ srvs := (name, port, target) :: !srvs
141141+ | _ -> ()
142142+ end
143143+ else off := len (* Invalid RR header, abort *)
144144+ end
145145+ done;
146146+ Some
147147+ {
148148+ ptrs = !ptrs;
149149+ srvs = !srvs;
150150+ txts = !txts;
151151+ addrs = !addrs;
152152+ addrs6 = !addrs6;
153153+ }
154154+155155+(* Query and collect responses *)
156156+let query ~sw ~net ~clock ~timeout name =
157157+ let query_buf = encode_query name in
158158+ let sock = Eio.Net.datagram_socket ~sw net `UdpV4 in
159159+ let ip =
160160+ Eio.Net.Ipaddr.of_raw
161161+ (Ipaddr.V4.to_octets (Ipaddr.V4.of_string_exn multicast_addr))
162162+ in
163163+ let dest = `Udp (ip, port) in
164164+ Eio.Net.send sock ~dst:dest [ Cstruct.of_string query_buf ];
165165+ let responses = ref [] in
166166+ let recv_buf = Cstruct.create 4096 in
167167+ let deadline = Eio.Time.now clock +. timeout in
168168+ while Eio.Time.now clock < deadline do
169169+ match
170170+ Eio.Time.with_timeout clock
171171+ (deadline -. Eio.Time.now clock)
172172+ (fun () ->
173173+ let _, len = Eio.Net.recv sock recv_buf in
174174+ Ok (Cstruct.to_string (Cstruct.sub recv_buf 0 len)))
175175+ with
176176+ | Ok buf -> (
177177+ match parse buf with Some r -> responses := r :: !responses | None -> ())
178178+ | Error _ -> ()
179179+ done;
180180+ !responses
181181+182182+(* Merge multiple responses *)
183183+let merge rs =
184184+ List.fold_left
185185+ (fun acc r ->
186186+ {
187187+ ptrs = r.ptrs @ acc.ptrs;
188188+ srvs = r.srvs @ acc.srvs;
189189+ txts = r.txts @ acc.txts;
190190+ addrs = r.addrs @ acc.addrs;
191191+ addrs6 = r.addrs6 @ acc.addrs6;
192192+ })
193193+ empty rs
+67
lib/mdns.mli
···11+(** mDNS/DNS-SD service discovery (RFC 6763).
22+33+ This module implements Multicast DNS for DNS-based Service Discovery.
44+ It uses the dns library for query encoding and handles raw domain names
55+ to support DNS-SD service instance names containing spaces.
66+77+ {2 References}
88+ - {{:https://datatracker.ietf.org/doc/html/rfc6762} RFC 6762 - Multicast DNS}
99+ - {{:https://datatracker.ietf.org/doc/html/rfc6763} RFC 6763 - DNS-Based Service Discovery} *)
1010+1111+(** {1 Types} *)
1212+1313+type response = {
1414+ ptrs : ([ `raw ] Domain_name.t * [ `raw ] Domain_name.t) list;
1515+ (** PTR records: service type -> service instance *)
1616+ srvs : ([ `raw ] Domain_name.t * int * [ `raw ] Domain_name.t) list;
1717+ (** SRV records: service instance, port, target host *)
1818+ txts : ([ `raw ] Domain_name.t * string list) list;
1919+ (** TXT records: service instance -> key=value pairs *)
2020+ addrs : ([ `raw ] Domain_name.t * Ipaddr.V4.t) list;
2121+ (** A records: hostname -> IPv4 address *)
2222+ addrs6 : ([ `raw ] Domain_name.t * Ipaddr.V6.t) list;
2323+ (** AAAA records: hostname -> IPv6 address *)
2424+}
2525+(** Parsed mDNS response containing discovered services and addresses. *)
2626+2727+val empty : response
2828+(** Empty response with no records. *)
2929+3030+(** {1 Low-level Operations} *)
3131+3232+val encode_query : [ `raw ] Domain_name.t -> string
3333+(** [encode_query name] creates an mDNS query packet for the given service type.
3434+ For example, to discover HTTP services: [encode_query "_http._tcp.local"]. *)
3535+3636+val parse : string -> response option
3737+(** [parse buf] attempts to parse an mDNS response packet.
3838+ Returns [None] if the packet is malformed. *)
3939+4040+(** {1 High-level Query} *)
4141+4242+val query :
4343+ sw:Eio.Switch.t ->
4444+ net:_ Eio.Net.t ->
4545+ clock:_ Eio.Time.clock ->
4646+ timeout:float ->
4747+ [ `raw ] Domain_name.t ->
4848+ response list
4949+(** [query ~sw ~net ~clock ~timeout name] sends an mDNS query and collects
5050+ responses until [timeout] seconds have elapsed.
5151+5252+ @param sw Eio switch for resource management
5353+ @param net Eio network capability
5454+ @param clock Eio clock for timeout
5555+ @param timeout Maximum time to wait for responses in seconds
5656+ @param name Service type to query (e.g., "_http._tcp.local") *)
5757+5858+val merge : response list -> response
5959+(** [merge responses] combines multiple responses into a single response. *)
6060+6161+(** {1 Constants} *)
6262+6363+val multicast_addr : string
6464+(** mDNS multicast address: "224.0.0.251" *)
6565+6666+val port : int
6767+(** mDNS port: 5353 *)
+35
mdns.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "mDNS/DNS-SD service discovery (RFC 6763)"
44+description:
55+ "A pure OCaml implementation of mDNS (Multicast DNS) for DNS-SD service discovery as specified in RFC 6763. Uses Eio for async networking and the dns library for query encoding."
66+maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"]
77+authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"]
88+license: "MIT"
99+homepage: "https://tangled.org/@gazagnaire.org/ocaml-mdns"
1010+bug-reports: "https://tangled.org/@gazagnaire.org/ocaml-mdns/issues"
1111+depends: [
1212+ "dune" {>= "3.0"}
1313+ "ocaml" {>= "4.08"}
1414+ "eio" {>= "1.0"}
1515+ "dns" {>= "9.0"}
1616+ "ipaddr" {>= "5.0"}
1717+ "domain-name" {>= "0.4"}
1818+ "alcotest" {with-test}
1919+ "crowbar" {with-test}
2020+ "odoc" {with-doc}
2121+]
2222+build: [
2323+ ["dune" "subst"] {dev}
2424+ [
2525+ "dune"
2626+ "build"
2727+ "-p"
2828+ name
2929+ "-j"
3030+ jobs
3131+ "@install"
3232+ "@runtest" {with-test}
3333+ "@doc" {with-doc}
3434+ ]
3535+]