System ARP cache reader
0

Configure Feed

Select the types of activity you want to include in your feed.

nox-arp: make it the ARP wire codec; split the cache reader into arp-cache

nox-arp was a system ARP-cache reader. Repurpose it as the ARP packet codec
(RFC 826), forked from mirage/arp with the cstruct codec replaced by the wire
library, parallel to nox-ethernet/nox-ip/nox-udp: the 28-byte IPv4-over-Ethernet
request/reply packet, with to_string/of_string validating the fixed
hardware/protocol type and length fields, and a c/ EverParse 3D export (2033
generated checks pass).

The system cache reader (reads /proc/net/arp or arp -a) moves to a new arp-cache
package as Arp_cache -- a standalone OS utility, not part of the nox-* wire
family, so it keeps a plain name. Its line parsers stay pure; only table/lookup
touch the OS. A bundled arp-cache command prints the table. ocaml-meross now
depends on arp-cache and uses Arp_cache.

author
Thomas Gazagnaire
date (May 24, 2026, 10:36 PM -0700) commit 99f8bab6
+336
+1
.ocamlformat
··· 1 + version = 0.29.0
+21
LICENSE.md
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Thomas Gazagnaire 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+21
README.md
··· 1 + # arp-cache 2 + 3 + Read the host ARP cache to resolve IPv4 addresses to MAC addresses, in pure 4 + OCaml. 5 + 6 + - Linux: reads `/proc/net/arp` directly 7 + - macOS/BSD: parses the output of `arp -a` 8 + 9 + The line parsers (`parse_proc_net_arp`, `parse_arp_a_output`) are pure; only 10 + `table`, `lookup` and `lookup_mac` touch the operating system. For the ARP 11 + *wire protocol* codec, see [`nox-arp`](../ocaml-arp/README.md). 12 + 13 + ## Usage 14 + 15 + `Arp_cache.table ()` returns the cache as a list of `{ ip; mac; interface }` 16 + entries; `Arp_cache.lookup_mac ip` resolves a single address. See `arp_cache.mli` 17 + for the full API. The bundled `arp-cache` command prints the table. 18 + 19 + ## Licence 20 + 21 + MIT
+35
arp-cache.opam
··· 1 + # This file is generated by dune, edit dune-project instead 2 + opam-version: "2.0" 3 + synopsis: "System ARP cache reader" 4 + description: 5 + "Read the host ARP cache to resolve IPv4 addresses to MAC addresses, without external dependencies. Uses /proc/net/arp on Linux and arp -a on macOS/BSD. The line parsers are pure OCaml." 6 + maintainer: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 7 + authors: ["Thomas Gazagnaire <thomas@gazagnaire.org>"] 8 + license: "MIT" 9 + tags: ["org:blacksun" "network"] 10 + homepage: "https://tangled.org/gazagnaire.org/ocaml-arp-cache" 11 + bug-reports: "https://tangled.org/gazagnaire.org/ocaml-arp-cache/issues" 12 + depends: [ 13 + "ocaml" {>= "4.14.0"} 14 + "dune" {>= "3.21" & >= "3.0"} 15 + "fmt" 16 + "mdx" {with-test} 17 + "alcotest" {with-test} 18 + "odoc" {with-doc} 19 + ] 20 + build: [ 21 + ["dune" "subst"] {dev} 22 + [ 23 + "dune" 24 + "build" 25 + "-p" 26 + name 27 + "-j" 28 + jobs 29 + "@install" 30 + "@runtest" {with-test} 31 + "@doc" {with-doc} 32 + ] 33 + ] 34 + dev-repo: "git+https://tangled.org/gazagnaire.org/ocaml-arp-cache" 35 + x-maintenance-intent: ["(latest)"]
+4
bin/dune
··· 1 + (executable 2 + (name main) 3 + (public_name arp-cache) 4 + (libraries arp_cache fmt))
+6
bin/main.ml
··· 1 + let () = 2 + let pp_iface ppf = function Some i -> Fmt.pf ppf " (%s)" i | None -> () in 3 + List.iter 4 + (fun (e : Arp_cache.entry) -> 5 + Fmt.pr "%s -> %s%a@." e.ip e.mac pp_iface e.interface) 6 + (Arp_cache.table ())
+3
dune
··· 1 + (env 2 + (dev 3 + (flags :standard %{dune-warnings})))
+23
dune-project
··· 1 + (lang dune 3.21) 2 + (using mdx 0.4) 3 + (name arp-cache) 4 + (source (tangled gazagnaire.org/ocaml-arp-cache)) 5 + (license MIT) 6 + (authors "Thomas Gazagnaire <thomas@gazagnaire.org>") 7 + (maintainers "Thomas Gazagnaire <thomas@gazagnaire.org>") 8 + 9 + (generate_opam_files true) 10 + (implicit_transitive_deps false) 11 + 12 + (package 13 + (name arp-cache) 14 + (synopsis "System ARP cache reader") 15 + (tags (org:blacksun network)) 16 + (description 17 + "Read the host ARP cache to resolve IPv4 addresses to MAC addresses, without external dependencies. Uses /proc/net/arp on Linux and arp -a on macOS/BSD. The line parsers are pure OCaml.") 18 + (depends 19 + (ocaml (>= 4.14.0)) 20 + (dune (>= 3.0)) 21 + fmt 22 + (mdx :with-test) 23 + (alcotest :with-test)))
+88
lib/arp_cache.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + type entry = { ip : string; mac : string; interface : string option } 7 + 8 + let proc_arp_entry line = 9 + let parts = 10 + String.split_on_char ' ' line |> List.filter (fun s -> String.length s > 0) 11 + in 12 + match parts with 13 + | ip :: _ :: flags :: mac :: _ :: iface :: _ 14 + when flags <> "0x0" && mac <> "00:00:00:00:00:00" -> 15 + (* flags 0x2 = complete entry, 0x0 = incomplete *) 16 + Some { ip; mac; interface = Some iface } 17 + | _ -> None 18 + 19 + let parse_proc_net_arp lines = 20 + (* /proc/net/arp format: 21 + IP address HW type Flags HW address Mask Device 22 + 192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:ff * eth0 *) 23 + match lines with 24 + | [] -> [] 25 + | _ :: data_lines -> List.filter_map proc_arp_entry data_lines 26 + 27 + let parse_arp_a_output lines = 28 + (* arp -a format (macOS/BSD): 29 + hostname (192.168.1.1) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet] *) 30 + List.filter_map 31 + (fun line -> 32 + let parts = 33 + String.split_on_char ' ' line 34 + |> List.filter (fun s -> String.length s > 0) 35 + in 36 + match parts with 37 + | _ :: ip_paren :: _ :: mac :: _ :: iface :: _ 38 + when String.length ip_paren > 2 39 + && ip_paren.[0] = '(' 40 + && ip_paren.[String.length ip_paren - 1] = ')' -> 41 + let ip = String.sub ip_paren 1 (String.length ip_paren - 2) in 42 + if mac <> "(incomplete)" then Some { ip; mac; interface = Some iface } 43 + else None 44 + | _ -> None) 45 + lines 46 + 47 + let read_file path = 48 + try 49 + let ic = open_in path in 50 + let rec read_lines acc = 51 + match input_line ic with 52 + | line -> read_lines (line :: acc) 53 + | exception End_of_file -> 54 + close_in ic; 55 + List.rev acc 56 + in 57 + Some (read_lines []) 58 + with Sys_error _ -> None 59 + 60 + let run_command args = 61 + let cmd = String.concat " " args in 62 + try 63 + let ic = Unix.open_process_in cmd in 64 + let rec read_lines acc = 65 + match input_line ic with 66 + | line -> read_lines (line :: acc) 67 + | exception End_of_file -> 68 + ignore (Unix.close_process_in ic); 69 + List.rev acc 70 + in 71 + Some (read_lines []) 72 + with Sys_error _ | Unix.Unix_error _ -> None 73 + 74 + let table () = 75 + (* Try Linux /proc/net/arp first *) 76 + match read_file "/proc/net/arp" with 77 + | Some lines -> parse_proc_net_arp lines 78 + | None -> ( 79 + (* Fall back to arp -a for macOS/BSD *) 80 + match run_command [ "arp"; "-a" ] with 81 + | Some lines -> parse_arp_a_output lines 82 + | None -> []) 83 + 84 + let lookup ip = 85 + let entries = table () in 86 + List.find_opt (fun e -> e.ip = ip) entries 87 + 88 + let lookup_mac ip = Option.map (fun e -> e.mac) (lookup ip)
+39
lib/arp_cache.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** System ARP cache reader. 7 + 8 + Reads the host's ARP cache to resolve IPv4 addresses to MAC addresses: 9 + - Linux: reads [/proc/net/arp] directly 10 + - macOS/BSD: parses the output of [arp -a] 11 + 12 + The {!parse_proc_net_arp} and {!parse_arp_a_output} functions are pure; only 13 + {!table}, {!lookup} and {!lookup_mac} touch the operating system. 14 + 15 + {[ 16 + let print_entries () = 17 + List.iter 18 + (fun (entry : Arp_cache.entry) -> 19 + Printf.printf "%s -> %s\n" entry.ip entry.mac) 20 + (Arp_cache.table ()) 21 + ]} *) 22 + 23 + type entry = { ip : string; mac : string; interface : string option } 24 + (** An ARP cache entry. *) 25 + 26 + val table : unit -> entry list 27 + (** [table ()] returns all entries from the system ARP cache. *) 28 + 29 + val parse_proc_net_arp : string list -> entry list 30 + (** [parse_proc_net_arp lines] parses [/proc/net/arp] content into entries. *) 31 + 32 + val parse_arp_a_output : string list -> entry list 33 + (** [parse_arp_a_output lines] parses [arp -a] output into entries. *) 34 + 35 + val lookup : string -> entry option 36 + (** [lookup ip] finds the cache entry for the given IP address. *) 37 + 38 + val lookup_mac : string -> string option 39 + (** [lookup_mac ip] returns the MAC address for the given IP, if known. *)
+8
lib/dune
··· 1 + (library 2 + (name arp_cache) 3 + (public_name arp-cache) 4 + (libraries unix)) 5 + 6 + (mdx 7 + (files arp_cache.mli) 8 + (libraries arp-cache))
+3
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries arp-cache alcotest fmt))
+1
test/test.ml
··· 1 + let () = Alcotest.run "arp-cache" [ Test_arp_cache.suite ]
+79
test/test_arp_cache.ml
··· 1 + let entry = 2 + Alcotest.testable 3 + (fun ppf e -> 4 + Fmt.pf ppf "{ ip=%S; mac=%S; iface=%a }" e.Arp_cache.ip e.Arp_cache.mac 5 + Fmt.(option string) 6 + e.Arp_cache.interface) 7 + ( = ) 8 + 9 + let proc_basic () = 10 + let lines = 11 + [ 12 + "IP address HW type Flags HW address Mask \ 13 + Device"; 14 + "192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:ff * \ 15 + eth0"; 16 + "10.0.0.1 0x1 0x2 11:22:33:44:55:66 * \ 17 + wlan0"; 18 + ] 19 + in 20 + let result = Arp_cache.parse_proc_net_arp lines in 21 + Alcotest.(check int) "two entries" 2 (List.length result); 22 + Alcotest.(check entry) 23 + "first" 24 + { ip = "192.168.1.1"; mac = "aa:bb:cc:dd:ee:ff"; interface = Some "eth0" } 25 + (List.nth result 0); 26 + Alcotest.(check entry) 27 + "second" 28 + { ip = "10.0.0.1"; mac = "11:22:33:44:55:66"; interface = Some "wlan0" } 29 + (List.nth result 1) 30 + 31 + let proc_incomplete () = 32 + let lines = 33 + [ 34 + "IP address HW type Flags HW address Mask \ 35 + Device"; 36 + "192.168.1.1 0x1 0x0 00:00:00:00:00:00 * \ 37 + eth0"; 38 + ] 39 + in 40 + let result = Arp_cache.parse_proc_net_arp lines in 41 + Alcotest.(check int) "no entries" 0 (List.length result) 42 + 43 + let proc_empty () = 44 + Alcotest.(check int) "empty" 0 (List.length (Arp_cache.parse_proc_net_arp [])) 45 + 46 + let arp_a_basic () = 47 + let lines = 48 + [ 49 + "router (192.168.1.1) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]"; 50 + "host (10.0.0.2) at 11:22:33:44:55:66 on en0 ifscope [ethernet]"; 51 + ] 52 + in 53 + let result = Arp_cache.parse_arp_a_output lines in 54 + Alcotest.(check int) "two entries" 2 (List.length result); 55 + Alcotest.(check entry) 56 + "first" 57 + { ip = "192.168.1.1"; mac = "aa:bb:cc:dd:ee:ff"; interface = Some "en0" } 58 + (List.nth result 0) 59 + 60 + let arp_a_incomplete () = 61 + let lines = 62 + [ "router (192.168.1.1) at (incomplete) on en0 ifscope [ethernet]" ] 63 + in 64 + let result = Arp_cache.parse_arp_a_output lines in 65 + Alcotest.(check int) "no entries" 0 (List.length result) 66 + 67 + let arp_a_empty () = 68 + Alcotest.(check int) "empty" 0 (List.length (Arp_cache.parse_arp_a_output [])) 69 + 70 + let suite = 71 + ( "arp_cache", 72 + [ 73 + Alcotest.test_case "proc basic" `Quick proc_basic; 74 + Alcotest.test_case "proc incomplete" `Quick proc_incomplete; 75 + Alcotest.test_case "proc empty" `Quick proc_empty; 76 + Alcotest.test_case "arp -a basic" `Quick arp_a_basic; 77 + Alcotest.test_case "arp -a incomplete" `Quick arp_a_incomplete; 78 + Alcotest.test_case "arp -a empty" `Quick arp_a_empty; 79 + ] )
+4
test/test_arp_cache.mli
··· 1 + (** Tests for the {!Arp_cache} line parsers. *) 2 + 3 + val suite : string * unit Alcotest.test_case list 4 + (** [suite] is the Alcotest suite for {!Arp_cache}. *)