summaryrefslogtreecommitdiff
path: root/adapters/arp
diff options
context:
space:
mode:
authorElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
committerElizabeth Alexander Hunt <me@liz.coffee>2026-06-20 09:40:07 -0700
commitfb653292612eddca5b088ed88466c546d1597107 (patch)
tree900ed32e066812688858ed3bb15128c41bb78054 /adapters/arp
downloadpenguins.lan-fb653292612eddca5b088ed88466c546d1597107.tar.gz
penguins.lan-fb653292612eddca5b088ed88466c546d1597107.zip
Initial commit
Diffstat (limited to 'adapters/arp')
-rw-r--r--adapters/arp/arp.go63
-rw-r--r--adapters/arp/arp_test.go51
2 files changed, 114 insertions, 0 deletions
diff --git a/adapters/arp/arp.go b/adapters/arp/arp.go
new file mode 100644
index 0000000..19d7632
--- /dev/null
+++ b/adapters/arp/arp.go
@@ -0,0 +1,63 @@
+package arp
+
+import (
+ "os/exec"
+ "regexp"
+ "strings"
+
+ "penguins.lan/utils"
+)
+
+type ArpTable struct {
+ AddrToMac map[utils.INet4]utils.Mac
+}
+
+// Lookup returns the canonical MAC for an IPv4 string, false if it isn't in the
+// table or isn't parseable IPv4 (e.g. an IPv6 peer).
+func (t *ArpTable) Lookup(ip string) (string, bool) {
+ addr, err := utils.ParseINet4(ip)
+ if err != nil {
+ return "", false
+ }
+ mac, ok := t.AddrToMac[*addr]
+ if !ok {
+ return "", false
+ }
+ return mac.Format(), true
+}
+
+type TableProvider interface {
+ Fetch() (*ArpTable, error)
+}
+
+var arpLine = regexp.MustCompile(`\((\d{1,3}(?:\.\d{1,3}){3})\) at ([0-9a-fA-F]{1,2}(?::[0-9a-fA-F]{1,2}){5})`)
+
+type SystemProvider struct{}
+
+func (SystemProvider) Fetch() (*ArpTable, error) {
+ out, err := exec.Command("arp", "-an").Output()
+ if err != nil {
+ return nil, err
+ }
+ return parseTable(string(out)), nil
+}
+
+func parseTable(out string) *ArpTable {
+ table := &ArpTable{AddrToMac: make(map[utils.INet4]utils.Mac)}
+ for _, line := range strings.Split(out, "\n") {
+ m := arpLine.FindStringSubmatch(line)
+ if m == nil {
+ continue
+ }
+ addr, err := utils.ParseINet4(m[1])
+ if err != nil {
+ continue
+ }
+ mac, err := utils.ParseMac(m[2])
+ if err != nil {
+ continue
+ }
+ table.AddrToMac[*addr] = *mac
+ }
+ return table
+}
diff --git a/adapters/arp/arp_test.go b/adapters/arp/arp_test.go
new file mode 100644
index 0000000..9a215e0
--- /dev/null
+++ b/adapters/arp/arp_test.go
@@ -0,0 +1,51 @@
+package arp
+
+import "testing"
+
+const sample = `Neighbor Linklayer Address
+? (192.168.1.5) at 8:0:27:ab:cd:ef on en0 ifscope [ethernet]
+? (192.168.1.9) at AA:BB:CC:11:22:33 [ether] on br-lan
+? (10.0.0.2) at de:ad:be:ef:00:01 on eth0
+? (192.168.1.250) at (incomplete) on en0 [ethernet]
+
+`
+
+func TestParseTable(t *testing.T) {
+ table := parseTable(sample)
+
+ got := make(map[string]string, len(table.AddrToMac))
+ for ip, mac := range table.AddrToMac {
+ got[ip.Format()] = mac.Format()
+ }
+
+ want := map[string]string{
+ "192.168.1.5": "08:00:27:ab:cd:ef",
+ "192.168.1.9": "aa:bb:cc:11:22:33",
+ "10.0.0.2": "de:ad:be:ef:00:01",
+ }
+
+ if len(got) != len(want) {
+ t.Fatalf("parsed %d entries %v, want %d (header/incomplete lines should be skipped)", len(got), got, len(want))
+ }
+ for ip, mac := range want {
+ if got[ip] != mac {
+ t.Errorf("entry %q = %q, want %q", ip, got[ip], mac)
+ }
+ }
+}
+
+func TestTableLookup(t *testing.T) {
+ table := parseTable(sample)
+
+ if mac, ok := table.Lookup("192.168.1.5"); !ok || mac != "08:00:27:ab:cd:ef" {
+ t.Errorf("Lookup(192.168.1.5) = %q,%v", mac, ok)
+ }
+ if _, ok := table.Lookup("1.2.3.4"); ok {
+ t.Error("Lookup of a miss should be false")
+ }
+ if _, ok := table.Lookup("::1"); ok {
+ t.Error("Lookup of non-IPv4 should be false")
+ }
+}
+
+var _ TableProvider = SystemProvider{}