From 83894bf84d2eb7190438978afd3d7a8e31ba2e67 Mon Sep 17 00:00:00 2001 From: Cobra Date: Tue, 14 Apr 2026 21:51:02 -0400 Subject: [PATCH] Fix three presence monitoring bugs: Tailscale exclusion, mDNS arrival, ARP personal device arrival MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tailscale peers (travel router b6:cc) were kept perpetually REACHABLE in the kernel ARP cache by OPi's WireGuard keepalives, refreshing last_seen and blocking departure alerts. seed_tailscale_peers() excludes their MACs. mDNS was only refreshing the watchdog timer for already-known devices. Apple devices send mDNS on WiFi join as the primary signal — now calls on_arrival() so first-seen mDNS triggers ARRIVED alert and occupancy update. ARP opcode 1 for personal devices never called on_arrival(), so devices that joined without DHCP (c6:37 My iPhone at 01:25) got no alert. Now fires on_arrival() for personal devices not yet in known_devices. --- net_alerter/net_alerter.py | 71 +++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/net_alerter/net_alerter.py b/net_alerter/net_alerter.py index a090ca4..f073c6e 100644 --- a/net_alerter/net_alerter.py +++ b/net_alerter/net_alerter.py @@ -42,6 +42,11 @@ last_departed_time = {} # {mac: timestamp} when device was actually alerted as last_seen = {} # {mac: float} — updated by passive ARP/mDNS capture last_seen_lock = threading.Lock() +# Tailscale peer MACs — excluded from presence monitoring +# OPi's WireGuard session generates bidirectional L2 traffic that would +# otherwise keep peer MACs perpetually REACHABLE in the kernel ARP cache. +tailscale_peer_macs: set = set() + # Interface flap detection — suppresses departure storms _flap_window = [] # [(timestamp, mac)] _flap_lock = threading.Lock() @@ -831,6 +836,48 @@ def seed_from_arp_cache(): logging.error(f"Failed to seed from ARP cache: {e}") +def seed_tailscale_peers() -> None: + """Populate tailscale_peer_macs with MACs of active Tailscale peer endpoints. + + OPi has a WireGuard session to each Tailscale peer. The peer's LAN IP is the + direct endpoint, so bidirectional L2 traffic keeps it REACHABLE in the kernel + ARP cache — generating ARP opcode 1 frames that would refresh last_seen and + falsely prevent departure alerts. Exclude them entirely. + """ + global tailscale_peer_macs + try: + result = subprocess.run( + ['tailscale', 'status', '--json'], + capture_output=True, text=True, timeout=5 + ) + if result.returncode != 0: + logging.warning("tailscale status failed — no Tailscale peer exclusions") + return + status = json.loads(result.stdout) + peer_ips = set() + for peer in status.get('Peer', {}).values(): + for ep in peer.get('Endpoints', []): + ip = ep.split(':')[0] + if ip: + peer_ips.add(ip) + if not peer_ips: + return + # Resolve peer IPs → MACs via kernel neighbor cache + neigh = Path('/proc/net/arp').read_text() + found = set() + for line in neigh.splitlines(): + fields = line.split() + if len(fields) < 4: + continue + ip, _, flags, mac = fields[0], fields[1], fields[2], fields[3] + if ip in peer_ips and mac != '00:00:00:00:00:00' and flags != '0x0': + found.add(mac.lower()) + tailscale_peer_macs = found + logging.info(f"Tailscale peer MACs excluded from presence monitoring: {found}") + except Exception as e: + logging.warning(f"seed_tailscale_peers failed: {e}") + + def send_alert(msg: str) -> None: """Send alert to Matrix room. @@ -1557,7 +1604,19 @@ def parse_frame(data: bytes) -> None: # ARP replies (opcode 2) may be AP ARP proxy forgeries: the AP answers # on behalf of disconnected clients with the client MAC as Ethernet source. if len(data) >= 22 and struct.unpack('!H', data[20:22])[0] == 1: - _update_last_seen(src_mac) + # Tailscale peers: OPi has a live WireGuard session to each peer's LAN + # endpoint. Bidirectional keepalives keep the peer MAC REACHABLE in the + # kernel ARP cache indefinitely — exclude to prevent false presence. + if src_mac not in tailscale_peer_macs: + _update_last_seen(src_mac) + # Personal devices first seen via ARP get no DHCP/Netlink event. + # Fire on_arrival so they get ARRIVED alert and occupancy counts. + if is_personal_device(src_mac) and len(data) >= 32: + with known_lock: + already_known = src_mac in known_devices + if not already_known: + spa = socket.inet_ntoa(data[28:32]) + on_arrival(src_mac, spa) # Ingest ARP signal for multi-source device identity _ingest_signal(src_mac, 'arp') return @@ -1582,6 +1641,15 @@ def parse_frame(data: bytes) -> None: # Branch B: mDNS frame (IPv4 UDP dst port 5353) if dst_port == 5353: _update_last_seen(src_mac) + # mDNS is the primary arrival indicator for Apple devices — they send it + # on WiFi join before or instead of DHCP. Fire on_arrival so the device + # gets an ARRIVED alert and is counted in occupancy. + if len(data) >= 30: + src_ip = socket.inet_ntoa(data[26:30]) + with known_lock: + already_known = src_mac in known_devices + if not already_known: + on_arrival(src_mac, src_ip) # Ingest mDNS signal for multi-source device identity _ingest_signal(src_mac, 'mdns') # Parse mDNS DNS message for device names @@ -1808,6 +1876,7 @@ def main() -> None: logging.info(f"Net alerter starting on interface {iface}") seed_infrastructure_ips(iface) + seed_tailscale_peers() seed_from_arp_cache() # Start monitor threads