Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/http-driver-mac-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"ftw": patch
---

Give HTTP drivers a hardware-stable device identity. An HTTP driver's MAC was
never resolved, so a LAN device that had not yet reported a serial fell back to
no identity at all and its persistent state (battery model, RLS twin,
calibration history) was orphaned whenever its address changed. The registry now
ARP-resolves the driver's own configured address, exactly as it already does for
MQTT and Modbus.

Only the driver's own `host`/`url` is probed, never the merged HTTP allowlist,
so vendor cloud endpoints are never treated as identity candidates. ARP lookups
are also now skipped for addresses that provably cannot appear in an ARP table
(loopback, carrier-grade-NAT/tunnel, multicast and broadcast), saving 150 ms of
pointless dialing. Public addresses stay eligible, so devices on a segment with
an ISP-routed block keep the MAC-derived identity they have today.
57 changes: 47 additions & 10 deletions go/internal/arp/arp.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,22 @@ import (
// the host before reading. The probe is cheap (50 ms timeout) and silent
// on failure — what matters is the SYN packet itself triggering ARP.
//
// Only a bare IPv4 literal is accepted; hostnames are not resolved, so a
// driver configured with a DNS name gets no MAC.
//
// Returns (mac, true) on success or ("", false) if the host can't be
// reached on L2 or isn't on this segment at all.
func Lookup(ipStr string) (string, bool) {
ip := net.ParseIP(ipStr)
if ip == nil { return "", false }
ip4 := ip.To4()
if ip4 == nil { return "", false }
// Skip addresses that provably cannot produce an ARP entry, rather
// than spending 150 ms of dialing to learn nothing. Deliberately NOT
// limited to RFC1918: a public address can legitimately sit on the
// local segment (an ISP routing a small block onto the LAN), and
// excluding it would silently re-key those devices off their MAC.
if !canHaveARPEntry(ip4) { return "", false }
// Nudge the ARP cache. We don't care if connect succeeds — the kernel
// resolves ARP regardless when it tries to send the SYN.
for _, port := range []string{"80", "502", "1883"} {
Expand All @@ -45,6 +54,26 @@ func Lookup(ipStr string) (string, bool) {
return "", false
}

// canHaveARPEntry reports whether an IPv4 address could ever appear in the
// kernel's ARP table. Only the provably-impossible cases are excluded, so
// that a device on an unusual-but-real local segment keeps its MAC-derived
// identity. ip4 must be the 4-byte form from net.IP.To4.
func canHaveARPEntry(ip4 net.IP) bool {
switch {
case ip4[0] == 0: // "this network", including 0.0.0.0
return false
case ip4[0] == 127: // loopback never resolves to a hardware address
return false
case ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127:
// Carrier-grade NAT space, where Tailscale and similar tunnels
// live. Reached over an L3 tun device, which has no L2 to ARP on.
return false
case ip4[0] >= 224: // multicast, reserved space, broadcast
return false
}
return true
}

// lookupLinux parses /proc/net/arp.
//
// IP address HW type Flags HW address Mask Device
Expand All @@ -69,22 +98,30 @@ func lookupLinux(ipStr string) (string, bool) {
func lookupDarwin(ipStr string) (string, bool) {
out, err := exec.Command("/usr/sbin/arp", "-n", ipStr).CombinedOutput()
if err != nil { return "", false }
// Output: "? (192.168.1.42) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]"
s := string(out)
return parseDarwinARP(string(out))
}

// parseDarwinARP extracts the MAC from `arp -n <ip>` output:
//
// ? (192.168.1.42) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]
//
// A miss prints "(incomplete)" or "no entry" in the same slot; both fail
// the six-octet check. macOS drops leading zeros ("1:2:3:4:5:6"), so
// octets are padded back to the canonical form callers compare against.
func parseDarwinARP(s string) (string, bool) {
idx := strings.Index(s, " at ")
if idx < 0 { return "", false }
rest := s[idx+4:]
end := strings.Index(rest, " ")
if end < 0 { return "", false }
mac := strings.ToLower(rest[:end])
if mac == "(incomplete)" || mac == "no" { return "", false }
// Normalize single-digit octets (macOS prints "1:2:3:4:5:6")
parts := strings.Split(mac, ":")
fields := strings.Fields(s[idx+len(" at "):])
if len(fields) == 0 { return "", false }
parts := strings.Split(strings.ToLower(fields[0]), ":")
if len(parts) != 6 { return "", false }
for i, p := range parts {
if len(p) == 0 || len(p) > 2 { return "", false }
if len(p) == 1 { parts[i] = "0" + p }
}
return strings.Join(parts, ":"), true
mac := strings.Join(parts, ":")
if mac == "00:00:00:00:00:00" { return "", false }
return mac, true
}

// readFile is a tiny indirection so tests can stub /proc/net/arp.
Expand Down
180 changes: 180 additions & 0 deletions go/internal/arp/arp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package arp

import (
"net"
"os"
"testing"
)

// stubARPTable replaces the /proc/net/arp reader for one test.
func stubARPTable(t *testing.T, contents string) {
t.Helper()
prev := readFile
readFile = func(string) ([]byte, error) { return []byte(contents), nil }
t.Cleanup(func() { readFile = prev })
}

// Every case here must bail before the ARP-cache nudge, which is what
// keeps this test hermetic: no case may touch the network. A hostname is
// never resolved, IPv6 has no ARP at all, and the rest are addresses that
// provably cannot appear in an ARP table.
func TestLookupRejectsAddressesThatCannotHaveAnARPEntry(t *testing.T) {
cases := []struct{ name, host string }{
{"empty", ""},
{"hostname", "inverter.local"},
{"host:port", "192.168.1.5:502"},
{"ipv6 loopback", "::1"},
{"ipv6 link-local", "fe80::1"},
{"ipv6 ula", "fd00::5"},
{"ipv4 loopback", "127.0.0.1"},
{"unspecified", "0.0.0.0"},
{"cgnat tunnel", "100.96.146.117"},
{"multicast", "224.0.0.251"},
{"broadcast", "255.255.255.255"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if mac, ok := Lookup(tc.host); ok {
t.Fatalf("Lookup(%q) = (%q, true), want no match", tc.host, mac)
}
})
}
}

// The gate excludes only what provably cannot resolve. A public address
// stays eligible on purpose: an ISP can route a small block straight onto
// the LAN, and excluding it would silently re-key those devices off their
// MAC and orphan their persistent state.
func TestCanHaveARPEntry(t *testing.T) {
cases := []struct {
ip string
want bool
}{
{"10.0.0.1", true},
{"172.16.0.1", true},
{"192.168.1.42", true},
{"169.254.10.1", true}, // link-local: no DHCP, still on our segment
{"8.8.8.8", true}, // public, but could be on our segment
{"172.32.0.1", true}, // outside RFC1918, still a routable unicast host
{"0.0.0.0", false},
{"127.0.0.1", false},
{"100.64.0.1", false},
{"100.127.255.254", false},
{"100.128.0.1", true}, // just past CGNAT, ordinary unicast again
{"224.0.0.251", false},
{"239.255.255.250", false},
{"255.255.255.255", false},
}
for _, tc := range cases {
t.Run(tc.ip, func(t *testing.T) {
ip4 := net.ParseIP(tc.ip).To4()
if ip4 == nil {
t.Fatalf("ParseIP(%q).To4() = nil", tc.ip)
}
if got := canHaveARPEntry(ip4); got != tc.want {
t.Errorf("canHaveARPEntry(%s) = %v, want %v", tc.ip, got, tc.want)
}
})
}
}

const procNetARP = `IP address HW type Flags HW address Mask Device
192.168.1.42 0x1 0x2 AA:BB:CC:DD:EE:FF * eth0
192.168.1.43 0x1 0x0 00:00:00:00:00:00 * eth0
192.168.1.44 0x1 0x2 aa:bb:cc:dd:ee:01 * wlan0
`

func TestLookupLinuxParsesProcNetARP(t *testing.T) {
stubARPTable(t, procNetARP)
cases := []struct {
name string
ip string
wantMAC string
wantOK bool
}{
// The kernel prints uppercase; device_id comparisons downstream
// assume lowercase, so normalization here is load-bearing.
{"match is lowercased", "192.168.1.42", "aa:bb:cc:dd:ee:ff", true},
{"match on second interface", "192.168.1.44", "aa:bb:cc:dd:ee:01", true},
{"incomplete entry is not a match", "192.168.1.43", "", false},
{"absent host", "192.168.1.99", "", false},
{"header row is not matched", "IP", "", false},
{"prefix of a listed ip", "192.168.1.4", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mac, ok := lookupLinux(tc.ip)
if mac != tc.wantMAC || ok != tc.wantOK {
t.Errorf("lookupLinux(%q) = (%q, %v), want (%q, %v)",
tc.ip, mac, ok, tc.wantMAC, tc.wantOK)
}
})
}
}

func TestLookupLinuxHandlesUnreadableTable(t *testing.T) {
prev := readFile
readFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
t.Cleanup(func() { readFile = prev })

if mac, ok := lookupLinux("192.168.1.42"); ok {
t.Fatalf("lookupLinux with unreadable table = (%q, true), want no match", mac)
}
}

func TestParseDarwinARP(t *testing.T) {
cases := []struct {
name string
out string
wantMAC string
wantOK bool
}{
{
"typical entry",
"? (192.168.1.42) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet]\n",
"aa:bb:cc:dd:ee:ff", true,
},
{
// macOS drops leading zeros; the padded form is what the
// devices table and every comparison downstream expect.
"single-digit octets are padded",
"? (192.168.1.42) at 1:2:3:4:5:6 on en0 ifscope [ethernet]\n",
"01:02:03:04:05:06", true,
},
{
"uppercase is normalized",
"? (192.168.1.42) at AA:BB:CC:DD:EE:FF on en0\n",
"aa:bb:cc:dd:ee:ff", true,
},
{
"mac at end of output without trailing field",
"? (192.168.1.42) at aa:bb:cc:dd:ee:ff",
"aa:bb:cc:dd:ee:ff", true,
},
{
"incomplete entry",
"? (192.168.1.42) at (incomplete) on en0 ifscope [ethernet]\n",
"", false,
},
{
"no entry at all",
"192.168.1.99 (192.168.1.99) -- no entry\n",
"", false,
},
{"all-zero mac", "? (192.168.1.42) at 00:00:00:00:00:00 on en0\n", "", false},
{"too few octets", "? (192.168.1.42) at aa:bb:cc on en0\n", "", false},
{"too many octets", "? (192.168.1.42) at aa:bb:cc:dd:ee:ff:11 on en0\n", "", false},
{"oversized octet", "? (192.168.1.42) at aaa:bb:cc:dd:ee:ff on en0\n", "", false},
{"empty octet", "? (192.168.1.42) at aa::cc:dd:ee:ff on en0\n", "", false},
{"empty output", "", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mac, ok := parseDarwinARP(tc.out)
if mac != tc.wantMAC || ok != tc.wantOK {
t.Errorf("parseDarwinARP(%q) = (%q, %v), want (%q, %v)",
tc.out, mac, ok, tc.wantMAC, tc.wantOK)
}
})
}
}
39 changes: 38 additions & 1 deletion go/internal/drivers/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ type Registry struct {
ModbusFactory func(name string, c *config.ModbusConfig) (ModbusCap, error)
// SerialFactory opens one read-only local serial device.
SerialFactory func(name string, c *config.SerialConfig) (SerialCap, error)
// ARPLookup resolves a hostname/IP to a MAC for L2-stable identity.
// ARPLookup resolves an on-LAN IPv4 literal to a MAC for L2-stable
// identity; hostnames and off-segment addresses yield ("", false).
// Optional — when nil, devices fall back to endpoint-hash IDs.
ARPLookup func(host string) (mac string, ok bool)
// SecretPersister, when set, durably stores a driver secret (keyed by
Expand Down Expand Up @@ -234,6 +235,19 @@ func (r *Registry) Add(ctx context.Context, cfg config.Driver) error {
if cfg.Capabilities.HTTP.AllowWrite {
env.WithHTTPAllowWrite()
}
// A LAN HTTP device often has no serial until it answers, and the
// HTTP branch sets no endpoint — so without this it has no stable
// identity at all and its persistent state is orphaned on a DHCP
// lease change. Probe only the driver's OWN address, never the
// merged allowlist: that list also names vendor cloud endpoints,
// which must not be identity candidates.
if r.ARPLookup != nil {
if host := httpDeviceHost(cfg.Config); host != "" {
if mac, ok := r.ARPLookup(host); ok {
env.SetMAC(mac)
}
}
}
}
if cfg.Capabilities.WebSocket != nil {
env.WithWS(NewGorillaWS(cfg.Name))
Expand Down Expand Up @@ -868,6 +882,29 @@ func mergeAllowedHosts(explicit []string, drvCfg map[string]any) []string {
return out
}

// httpDeviceHost returns the address of the device an HTTP driver talks
// to, taken from its own config block: the `host` key, else the host part
// of `url`. Any port is stripped — ARP keys on the L3 address alone.
// Returns "" for a driver with neither (a cloud driver whose base URL
// lives in the Lua source, not in config).
func httpDeviceHost(drvCfg map[string]any) string {
if drvCfg == nil {
return ""
}
if v, ok := drvCfg["host"].(string); ok {
if h := strings.TrimSpace(v); h != "" {
host, _, _ := splitHostPortLower(h)
return host
}
}
if v, ok := drvCfg["url"].(string); ok {
if u, err := url.Parse(strings.TrimSpace(v)); err == nil && u.Hostname() != "" {
return strings.ToLower(u.Hostname())
}
}
return ""
}

func findDriver(list []config.Driver, name string) (config.Driver, bool) {
for _, d := range list {
if d.Name == name {
Expand Down
Loading