mte/unikernel/duniverse/ocaml-dns/app/odns.ml
2025-11-11 02:07:51 +01:00

433 lines
15 KiB
OCaml

(* odns client utility. *)
(* RFC 768 DNS over UDP *)
(* RFC 7766 DNS over TCP: https://tools.ietf.org/html/rfc7766 *)
(* RFC 6698 DANE: https://tools.ietf.org/html/rfc6698*)
let pp_zone ppf (domain,query_type,query_value) =
(* TODO dig also prints 'IN' after the TTL, we don't... *)
Fmt.string ppf
(Dns.Rr_map.text_b domain (Dns.Rr_map.B (query_type, query_value)))
let pp_zone_tlsa ppf (domain,ttl,(tlsa:Dns.Tlsa.t)) =
(* TODO this implementation differs a bit from Dns_map.text and tries to
follow the `dig` output to make it easier to port existing scripts *)
Fmt.pf ppf "%a.\t%ld\tIN\t%d\t%d\t%d\t%s"
Domain_name.pp domain
ttl
(Dns.Tlsa.cert_usage_to_int tlsa.cert_usage)
(Dns.Tlsa.selector_to_int tlsa.selector)
(Dns.Tlsa.matching_type_to_int tlsa.matching_type)
( (* this produces output similar to `dig`, splitting the hex string
in chunks of 56 chars (28 bytes): *)
let hex = Ohex.decode tlsa.data in
let hlen = String.length hex in
let rec loop acc = function
| n when n + 56 >= hlen ->
String.concat " " (List.rev (String.sub hex n (hlen-n)::acc))
|> String.uppercase_ascii
| n -> loop ((String.sub hex n 56)::acc) (n+56)
in loop [] 0)
let pp_nameserver ppf = function
| `Plaintext (ip, port) -> Fmt.pf ppf "TCP %a:%d" Ipaddr.pp ip port
| `Tls (tls_cfg, ip, port) ->
Fmt.pf ppf "TLS %a:%d%a" Ipaddr.pp ip port
Fmt.(option ~none:(any "") (append (any "#") Domain_name.pp))
((Tls.Config.of_client tls_cfg).Tls.Config.peer_name)
let do_a nameservers domains () =
let happy_eyeballs = Happy_eyeballs_lwt.create () in
let t = Dns_client_lwt.create ?nameservers happy_eyeballs in
let (_, ns) = Dns_client_lwt.nameservers t in
Logs.info (fun m -> m "querying NS %a for A records of %a"
pp_nameserver (List.hd ns) Fmt.(list ~sep:(any ", ") Domain_name.pp) domains);
let job =
Lwt_list.iter_p (fun domain ->
let open Lwt in
Logs.debug (fun m -> m "looking up %a" Domain_name.pp domain);
Dns_client_lwt.(getaddrinfo t A domain)
>|= function
| Ok (_ttl, addrs) when Ipaddr.V4.Set.is_empty addrs ->
(* handle empty response? *)
Logs.app (fun m -> m ";%a. IN %a"
Domain_name.pp domain
Dns.Rr_map.ppk (Dns.Rr_map.K A))
| Ok resp ->
Logs.app (fun m -> m "%a" pp_zone (domain, A, resp))
| Error (`Msg msg) ->
Logs.err (fun m -> m "Failed to lookup %a: %s\n"
Domain_name.pp domain msg)
) domains
in
match Lwt_main.run job with
| () -> Ok () (* TODO handle errors *)
let for_all_domains nameservers ~domains typ f =
(* [for_all_domains] is a utility function that lets us avoid duplicating
this block of code in all the subcommands.
We leave {!do_a} simple to provide a more readable example. *)
let happy_eyeballs = Happy_eyeballs_lwt.create () in
let t = Dns_client_lwt.create ?nameservers happy_eyeballs in
let _, ns = Dns_client_lwt.nameservers t in
Logs.info (fun m -> m "NS: %a" pp_nameserver (List.hd ns));
let open Lwt in
match
Lwt_main.run
(Lwt_list.iter_p
(fun domain ->
Dns_client_lwt.getaddrinfo t typ domain >|= function
| Error `Msg msg ->
Logs.err (fun m ->
m "Failed to lookup %a for %a: %s\n%!"
Dns.Rr_map.ppk (Dns.Rr_map.K typ)
Domain_name.pp domain msg) ;
()
| Ok x -> f domain x)
domains)
with
| () -> Ok () (* TODO catch failed jobs *)
let output_response typ domain resp =
Logs.app (fun m -> m "%a" pp_zone (domain, typ, resp))
let do_aaaa nameserver domains () =
for_all_domains nameserver ~domains Dns.Rr_map.Aaaa
(output_response Dns.Rr_map.Aaaa)
let do_mx nameserver domains () =
for_all_domains nameserver ~domains Dns.Rr_map.Mx
(output_response Dns.Rr_map.Mx)
let do_tlsa nameserver domains () =
for_all_domains nameserver ~domains Dns.Rr_map.Tlsa
(fun domain (ttl, tlsa_resp) ->
Dns.Rr_map.Tlsa_set.iter (fun tlsa ->
Logs.app (fun m -> m "%a" pp_zone_tlsa (domain, ttl, tlsa))
) tlsa_resp)
let do_txt nameserver domains () =
for_all_domains nameserver ~domains Dns.Rr_map.Txt
(fun _domain (ttl, txtset) ->
Dns.Rr_map.Txt_set.iter (fun txtrr ->
Logs.app (fun m -> m "%ld: @[<v>%s@]" ttl txtrr)
) txtset)
let do_any _nameserver _domains () =
(* TODO *)
Error (`Msg "ANY functionality is not present atm due to refactorings, come back later")
let do_dkim nameserver (selector:string) domains () =
let domains = List.map (fun original_domain ->
Domain_name.prepend_label_exn
(Domain_name.prepend_label_exn
(original_domain) "_domainkey") selector
) domains in
for_all_domains nameserver ~domains Dns.Rr_map.Txt
(fun _domain (_ttl, txtset) ->
Dns.Rr_map.Txt_set.iter (fun txt ->
Logs.app (fun m -> m "%s" txt)
) txtset)
let do_type nameserver typ domains () =
match Dns.Rr_map.of_int typ with
| Ok K k ->
for_all_domains nameserver ~domains k
(fun domain resp ->
Logs.app (fun m -> m "%a" pp_zone (domain, k, resp)))
| _ -> Error (`Msg "bad argument")
let do_loc nameserver domains () =
for_all_domains nameserver ~domains Dns.Rr_map.Loc
(output_response Dns.Rr_map.Loc)
open Cmdliner
let sdocs = Manpage.s_common_options
let setup_log =
let setup_log (style_renderer:Fmt.style_renderer option) level : unit =
Fmt_tty.setup_std_outputs ?style_renderer () ;
Logs.set_level level ;
Logs.set_reporter (Logs_fmt.reporter ())
in
Term.(const setup_log $ Fmt_cli.style_renderer ~docs:sdocs ()
$ Logs_cli.level ~docs:sdocs ())
let arg_ns : 'a Term.t =
let doc = "IP of nameserver to use" in
Arg.(value & opt (some Dns_cli.ip_c) None & info ~docv:"NS-IP" ~doc ["ns"])
let arg_port : 'a Term.t =
let doc = "Port of nameserver" in
Arg.(value & opt int 53 & info ~docv:"NS-PORT" ~doc ["ns-port"])
let tls_hostname =
let doc = "Hostname to use for TLS authentication" in
Arg.(value & opt (some Dns_cli.name_c) None &
info ~docv:"HOSTNAME" ~doc ["tls-hostname"])
let tls_ca_file =
let doc = "TLS trust anchor file" in
Arg.(value & opt (some file) None &
info ~docv:"CAs" ~doc ["tls-ca-file"])
let tls_ca_dir =
let doc = "TLS trust anchor directory" in
Arg.(value & opt (some dir) None &
info ~docv:"CAs" ~doc ["tls-ca-directory"])
let tls_cert_fp =
let doc = "TLS certificate fingerprint" in
Arg.(value & opt (some string) None &
info ~docv:"FP" ~doc ["tls-cert-fingerprint"])
let tls_key_fp =
let doc = "TLS public key fingerprint" in
Arg.(value & opt (some string) None &
info ~docv:"FP" ~doc ["tls-key-fingerprint"])
let no_tls =
let doc = "Disable DNS-over-TLS" in
Arg.(value & flag & info ~docv:"no-tls" ~doc ["no-tls"])
let nameserver =
let ( let* ) = Result.bind in
let ns no_tls ca_file ca_dir cert_fp key_fp hostname ip port =
if no_tls then
Option.map (fun ip -> `Tcp, [ `Plaintext (ip, port)]) ip
else
match ip with
| None -> None
| Some ip ->
let auth peer_name ip =
let cfg auth =
Result.join
(Result.map
(fun authenticator -> Tls.Config.client ~authenticator ?peer_name ?ip ()) auth)
in
let time () = Some (Ptime_clock.now ()) in
let of_fp data =
let hash, fp =
let h_of_string = function
| "md5" -> Some `MD5
| "sha" | "sha1" -> Some `SHA1
| "sha224" -> Some `SHA224
| "sha256" -> Some `SHA256
| "sha384" -> Some `SHA384
| "sha512" -> Some `SHA512
| _ -> None
in
match String.split_on_char ':' data with
| [] -> invalid_arg "empty fingerprint"
| [ fp ] -> `SHA256, fp
| hash :: rt -> match h_of_string (String.lowercase_ascii hash) with
| Some h -> h, String.concat "" rt
| None -> invalid_arg ("unknown hash: " ^ hash)
in
let hex = Ohex.encode fp in
hash, hex
in
match ca_file, ca_dir, cert_fp, key_fp with
| None, None, None, None -> cfg (Ca_certs.authenticator ())
| Some f, None, None, None ->
let* data = Bos.OS.File.read (Fpath.v f) in
let* certs = X509.Certificate.decode_pem_multiple data in
cfg (Ok (X509.Authenticator.chain_of_trust ~time certs))
| None, Some d, None, None ->
let* files = Bos.OS.Dir.contents (Fpath.v d) in
let* certs =
List.fold_left (fun r f ->
let* acc = r in
let* data = Bos.OS.File.read f in
let* cert = X509.Certificate.decode_pem data in
Ok (cert :: acc))
(Ok []) files
in
cfg (Ok (X509.Authenticator.chain_of_trust ~time certs))
| None, None, Some fp, None ->
let hash, fingerprint = of_fp fp in
cfg (Ok (X509.Authenticator.cert_fingerprint ~time ~hash ~fingerprint))
| None, None, None, Some fp ->
let hash, fingerprint = of_fp fp in
cfg (Ok (X509.Authenticator.key_fingerprint ~time ~hash ~fingerprint))
| _ -> invalid_arg "only one of cert-file, cert-dir, key-fingerprint, cert-fingerprint is supported"
in
let ip' = match hostname with None -> Some ip | Some _ -> None in
let tls = match auth hostname ip' with
| Ok a -> a
| Error `Msg msg -> invalid_arg msg
in
Some (`Tcp, [ `Tls (tls, ip, if port = 53 then 853 else port);
`Plaintext (ip, port) ])
in
Term.(const ns $ no_tls $ tls_ca_file $ tls_ca_dir $ tls_cert_fp $ tls_key_fp $ tls_hostname $ arg_ns $ arg_port)
let arg_domains : [ `raw ] Domain_name.t list Term.t =
let doc = "Domain names to operate on" in
Arg.(non_empty & pos_all Dns_cli.domain_name_c []
& info [] ~docv:"DOMAIN(s)" ~doc)
let arg_selector : string Term.t =
let doc = "DKIM selector string" in
Arg.(required & opt (some string) None
& info ["selector"] ~docv:"SELECTOR" ~doc)
let cmd_a : unit Cmd.t =
let doc = "Query a NS for A records" in
let man = [
`P {| Output mimics that of $(b,dig A )$(i,DOMAIN)|}
] in
let term =
Term.(term_result (const do_a $ nameserver $ arg_domains $ setup_log))
and info =
Cmd.info "a" ~version:(Manpage.escape "v10.2.2") ~man ~doc ~sdocs
in
Cmd.v info term
let cmd_aaaa : unit Cmd.t =
let doc = "Query a NS for AAAA records" in
let man = [
`P {| Output mimics that of $(b,dig AAAA )$(i,DOMAIN)|}
] in
let term =
Term.(term_result (const do_aaaa $ nameserver $ arg_domains $ setup_log))
and info =
Cmd.info "aaaa" ~version:(Manpage.escape "v10.2.2") ~man ~doc ~sdocs
in
Cmd.v info term
let cmd_mx : unit Cmd.t =
let doc = "Query a NS for mailserver (MX) records" in
let man = [
`P {| Output mimics that of $(b,dig MX )$(i,DOMAIN)|}
] in
let term =
Term.(term_result (const do_mx $ nameserver $ arg_domains $ setup_log))
and info =
Cmd.info "mx" ~version:(Manpage.escape "v10.2.2") ~man ~doc ~sdocs
in
Cmd.v info term
let cmd_tlsa : unit Cmd.t =
let doc = "Query a NS for TLSA records (see DANE / RFC 7671)" in
let man = [
`S Manpage.s_arguments ;
`S Manpage.s_description ;
`P {|Note that you must specify which $(b,service name)
you want to retrieve the key(s) of.
To retrieve the $(b,HTTPS) cert of $(i,www.example.com),
you would query the NS:
$(mname) $(tname) $(b,_443._tcp.)$(i,www.example.com)
|} ;
`P {|Brief list of other handy service name prefixes:|};
`P {| $(b,_5222._tcp) (XMPP); |} ;
`P {| $(b,_853._tcp) (DNS-over-TLS); |} ;
`P {| $(b,_25._tcp) (SMTP with STARTTLS); |} ;
`P {| $(b,_465._tcp)(SMTP); |} ;
`P {| $(b,_993._tcp) (IMAP) |} ;
`S Manpage.s_options ;
] in
let term =
Term.(term_result (const do_tlsa $ nameserver $ arg_domains $ setup_log))
and info =
Cmd.info "tlsa" ~version:(Manpage.escape "v10.2.2") ~man ~doc ~sdocs
in
Cmd.v info term
let cmd_txt : unit Cmd.t =
let doc = "Query a NS for TXT records" in
let man = [
`S Manpage.s_arguments ;
`S Manpage.s_description ;
`P {| Output format is currently: $(i,{TTL}: {text escaped in OCaml format})
It would be nice to mirror `dig` output here.|} ;
`S Manpage.s_options ;
] in
let term =
Term.(term_result (const do_txt $ nameserver $ arg_domains $ setup_log))
and info =
Cmd.info "txt" ~version:(Manpage.escape "v10.2.2") ~man ~doc ~sdocs
in
Cmd.v info term
let cmd_any : unit Cmd.t =
let doc = "Query a NS for ANY records" in
let man = [
`S Manpage.s_arguments ;
`S Manpage.s_description ;
`P {| The output will be fairly similar to $(b,dig ANY )$(i,example.com)|} ;
`S Manpage.s_options ;
] in
let term =
Term.(term_result (const do_any $ nameserver $ arg_domains $ setup_log))
and info =
Cmd.info "any" ~version:(Manpage.escape "v10.2.2") ~man ~doc ~sdocs
in
Cmd.v info term
let cmd_dkim : unit Cmd.t =
let doc = "Query a NS for DKIM (RFC 6376) records for a given selector" in
let man = [
`S Manpage.s_arguments ;
`S Manpage.s_description ;
`S {| Looks up DKIM (DomainKeys Identified Mail) Signatures in
accordance with RFC 6376.
Basically it's a recursive TXT lookup on
$(i,SELECTOR)._domainkeys.$(i,DOMAIN).
Each key is printed on its own concatenated line.
|} ;
`S Manpage.s_options ;
] in
let term =
Term.(term_result (const do_dkim $ nameserver $ arg_selector
$ arg_domains $ setup_log))
and info =
Cmd.info "dkim" ~version:(Manpage.escape "v10.2.2") ~man ~doc ~sdocs
in
Cmd.v info term
let arg_typ : int Term.t =
let doc = "Type to query" in
Arg.(required & opt (some int) None
& info ["type"] ~docv:"TYPE" ~doc)
let cmd_type : unit Cmd.t =
let doc = "Query a NS for a type, providing its integer number" in
let term =
Term.(term_result (const do_type $ nameserver $ arg_typ
$ arg_domains $ setup_log))
and info =
Cmd.info "type" ~version:(Manpage.escape "v10.2.2") ~doc ~sdocs
in
Cmd.v info term
let cmd_loc : unit Cmd.t =
let doc = "Query a NS for LOC records" in
let term =
Term.(term_result (const do_loc $ nameserver $ arg_domains $ setup_log))
and info =
Cmd.info "loc" ~version:(Manpage.escape "v10.2.2") ~doc ~sdocs
in
Cmd.v info term
let cmd_help : 'a Term.t =
let help _ = `Help (`Pager, None) in
Term.(ret (const help $ setup_log))
let cmds =
[ cmd_a ; cmd_tlsa; cmd_txt ; cmd_any; cmd_dkim ; cmd_aaaa ; cmd_mx ; cmd_type ; cmd_loc ]
let () =
let doc = "OCaml uDns alternative to `dig`" in
let man = [
`P {|For more information about the available subcommands,
run them while passing the help flag: $(tname) $(i,SUBCOMMAND) $(b,--help)
|}
] in
let info =
Cmd.info "odns" ~version:(Manpage.escape "v10.2.2") ~man ~doc ~sdocs
in
let group = Cmd.group ~default:cmd_help info cmds in
exit (Cmd.eval group)