NeighSnapshotter

Complete Linux Neighbor Table Query Library

ARP • NDP • Bridge FDB

Design Rationale

Why Another Neighbor Table Tool?

Linux provides several ways to query the neighbor table (ARP/NDP cache), but each has significant limitations:

Method Limitations Performance
/proc/net/arp IPv4 only, text parsing, limited fields, no timestamps Moderate
ip neigh show Subprocess overhead, text parsing, inconsistent format Slow (15-20ms)
arp -a IPv4 only, deprecated, limited information Slow
/proc/net/ndisc_cache IPv6 only, text parsing, missing bridge FDB Moderate
bridge fdb show Bridge only, subprocess overhead, incomplete data Slow
pyroute2 Heavy dependency, complex API, slower than native Moderate (8-10ms)
NeighSnapshotter solves all these problems by querying the kernel's neighbor table directly via RTNetlink, providing complete information for IPv4 ARP, IPv6 NDP, and bridge FDB in a single unified API with minimal overhead (~2-3ms).

Core Design Principles

  1. Direct Kernel Communication
    • Uses RTNetlink sockets (RTM_GETNEIGH) for direct kernel queries
    • No text parsing, no external commands, no proc/sys filesystem
    • Single syscall retrieves all neighbor entries
  2. Complete Protocol Coverage
    • IPv4 ARP cache entries
    • IPv6 Neighbor Discovery (NDP) cache
    • Bridge Forwarding Database (FDB)
    • All protocol families with unified interface
  3. Full State Information
    • All NUD (Neighbor Unreachability Detection) states
    • Flags (PROXY, ROUTER, OFFLOADED, etc.)
    • Cache timestamps (confirmed, used, updated)
    • Hardware type decoding
  4. Type Safety & Validation
    • C structures ensure correct data types
    • Python provides clean, JSON-serializable output
    • Unknown attributes tracked for future-proofing
  5. Performance First
    • Compiled C code for netlink parsing
    • Minimal Python overhead
    • Bulk queries (all neighbors in one call)

Use Cases

NeighSnapshotter is designed for:

  • Network Monitoring: Track ARP/NDP cache health and changes
  • Security Auditing: Detect ARP spoofing, rogue neighbors
  • Container Networking: Inspect bridge FDB for container MAC addresses
  • Troubleshooting: Debug IPv6 NDP issues, stale entries
  • SDN/OpenFlow: Monitor learned MAC addresses in bridge FDB
  • Performance Analysis: Identify neighbor resolution delays
  • Automation: Integrate neighbor table state into configuration management

Protocol Deep Dive

IPv4 ARP (Address Resolution Protocol)

Overview

ARP resolves IPv4 addresses to MAC addresses on local networks. When a system needs to send a packet to an IPv4 address on the same subnet, it broadcasts an ARP request to find the corresponding MAC address.

Neighbor States for ARP

INCOMPLETE

ARP request sent, waiting for reply

REACHABLE

Valid entry, recently confirmed

STALE

Entry exists but needs revalidation

DELAY

Waiting before sending probe

PROBE

Sending unicast ARP probes

FAILED

Resolution failed, entry invalid

PERMANENT

Static entry, never expires

NOARP

No ARP needed (loopback, static)

Common ARP Flags

  • PROXY: This system answers ARP requests on behalf of another
  • ROUTER: Entry is for a router
  • PERMANENT: Entry will not be garbage collected

IPv6 NDP (Neighbor Discovery Protocol)

Overview

NDP is the IPv6 equivalent of ARP, but more sophisticated. It uses ICMPv6 messages for neighbor discovery, address autoconfiguration, router discovery, and redirect messages.

Key Differences from ARP

  • Uses ICMPv6 instead of a separate protocol
  • Multicast instead of broadcast (more efficient)
  • Duplicate Address Detection (DAD) built-in
  • Router Discovery integrated
  • Stateless Address Autoconfiguration (SLAAC) support
Important: NOARP and IPv6

While IPv6 can have NOARP state, it's NOT the normal case. Most IPv6 neighbor entries use NDP (which IS a neighbor resolution protocol) and will show states like REACHABLE, STALE, DELAY, or PROBE.

NOARP appears for IPv6 only in special cases:

  • Loopback address (::1)
  • Point-to-point links where no resolution is needed
  • Static/permanent entries configured manually

NDP Message Types

Message Type Purpose
Neighbor Solicitation (NS) ICMPv6 Type 135 Request MAC address for IPv6 (like ARP request)
Neighbor Advertisement (NA) ICMPv6 Type 136 Provide MAC address (like ARP reply)
Router Solicitation (RS) ICMPv6 Type 133 Request router information
Router Advertisement (RA) ICMPv6 Type 134 Announce router presence and prefixes

NDP-Specific Flags

  • ROUTER: Neighbor is a router (from RA messages)
  • PROXY: Proxy NDP entry

Bridge FDB (Forwarding Database)

Overview

The bridge FDB is a Layer 2 forwarding table that maps MAC addresses to bridge ports. Unlike ARP/NDP which operate at Layer 3, the FDB is pure Layer 2.

FDB Learning

Bridges learn MAC addresses by observing traffic:

  1. Frame arrives on port X with source MAC aa:bb:cc:dd:ee:ff
  2. Bridge adds entry: "aa:bb:cc:dd:ee:ff is on port X"
  3. Future frames to aa:bb:cc:dd:ee:ff are sent only to port X
  4. Entries age out after a timeout (default 300s)

FDB States

PERMANENT

Static entry, never ages out

NOARP

Entry added by user/system (not learned)

REACHABLE

Dynamically learned, valid

STALE

Learned but may need refresh

FDB-Specific Flags

  • SELF: Entry is on the bridge itself
  • MASTER: Entry is on a bridge port
  • OFFLOADED: Entry offloaded to hardware (switch ASIC)
  • STICKY: Entry will not age out
  • EXT_LEARNED: Learned externally (e.g., from EVPN)

VLAN Support

When VLAN filtering is enabled on a bridge, FDB entries include VLAN IDs. This allows the same MAC address to exist in multiple VLANs.

Architecture

Component Overview

┌─────────────────────────────────────────────────────────────┐ │ Python Application │ │ (neighsnapshotter.py) │ ├─────────────────────────────────────────────────────────────┤ │ NeighborTableQuery Class │ │ ├─ get_neighbors(family=None) │ │ │ ├─ family='ipv4' → IPv4 ARP only │ │ │ ├─ family='ipv6' → IPv6 NDP only │ │ │ ├─ family='bridge'→ Bridge FDB only │ │ │ └─ family=None → All neighbor types │ │ │ │ │ └─ Returns: List[Dict] with complete neighbor metadata │ ├═════════════════════════════════════════════════════════════┤ │ CFFI Boundary │ ├═════════════════════════════════════════════════════════════┤ │ C Library (Compiled inline via CFFI) │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Socket Management │ │ │ ├─────────────────────────────────────────────────────┤ │ │ │ nl_create_socket() │ │ │ │ └─ socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE) │ │ │ │ │ │ │ │ nl_close_socket() │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Message Construction & Sending │ │ │ ├─────────────────────────────────────────────────────┤ │ │ │ nl_send_getneigh(sock, seq, family) │ │ │ │ ├─ Creates struct ndmsg │ │ │ │ ├─ Sets nlmsg_type = RTM_GETNEIGH │ │ │ │ ├─ Sets nlmsg_flags = NLM_F_REQUEST | NLM_F_DUMP │ │ │ │ └─ Sends via send() │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Response Handling │ │ │ ├─────────────────────────────────────────────────────┤ │ │ │ nl_recv_response(sock, expected_seq) │ │ │ │ ├─ Receives netlink messages via recv() │ │ │ │ ├─ Buffers and reassembles multi-part responses │ │ │ │ ├─ Validates sequence numbers │ │ │ │ └─ Handles NLMSG_DONE │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Parsing Functions │ │ │ ├─────────────────────────────────────────────────────┤ │ │ │ nl_parse_neighbors(buf, neighbors, count) │ │ │ │ ├─ Iterates RTM_NEWNEIGH messages │ │ │ │ ├─ Extracts struct ndmsg (state, flags, type) │ │ │ │ ├─ Parses NDA_* attributes: │ │ │ │ │ ├─ NDA_DST → Destination address │ │ │ │ │ ├─ NDA_LLADDR → Link-layer address (MAC) │ │ │ │ │ ├─ NDA_CACHEINFO → Timestamps │ │ │ │ │ ├─ NDA_PROBES → Probe count │ │ │ │ │ ├─ NDA_VLAN → VLAN ID │ │ │ │ │ └─ NDA_MASTER → Master interface │ │ │ │ └─ Tracks unknown attributes │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ Helper Functions │ │ │ ├─────────────────────────────────────────────────────┤ │ │ │ nl_get_neigh_state_name(state) │ │ │ │ └─ Converts NUD_* bits to human-readable string │ │ │ │ │ │ │ │ nl_get_neigh_flag_name(flags, index) │ │ │ │ └─ Converts NTF_* bits to flag names │ │ │ │ │ │ │ │ nl_get_hw_type_name(type) │ │ │ │ └─ Converts ARPHRD_* to hardware type name │ │ │ └─────────────────────────────────────────────────────┘ │ ├═════════════════════════════════════════════════════════════┤ │ Kernel Boundary │ ├═════════════════════════════════════════════════════════════┤ │ Linux Kernel │ │ │ │ RTNetlink Subsystem (NETLINK_ROUTE) │ │ ├─ Neighbor Table Subsystem │ │ │ ├─ IPv4 ARP cache (net/ipv4/arp.c) │ │ │ ├─ IPv6 NDP cache (net/ipv6/ndisc.c) │ │ │ └─ Bridge FDB (net/bridge/br_fdb.c) │ │ │ │ │ └─ RTM_GETNEIGH Handler │ │ ├─ neigh_dump_table() │ │ ├─ Iterates neighbor hash table │ │ └─ Returns RTM_NEWNEIGH messages with NDA_* attributes │ └─────────────────────────────────────────────────────────────┘

Data Flow

  1. Python initiates query: ntq.get_neighbors(family='ipv4')
  2. CFFI calls C function: nl_send_getneigh(sock, &seq, AF_INET)
  3. Kernel receives RTM_GETNEIGH: Triggers neighbor table dump
  4. Kernel sends RTM_NEWNEIGH messages: One per neighbor entry with NDA_* attributes
  5. C code buffers responses: nl_recv_response() collects all messages
  6. C code parses messages: nl_parse_neighbors() extracts data into structures
  7. Python receives CFFI structures: Converts to dictionaries
  8. Python enriches data: Resolves interface names, decodes addresses
  9. Returns JSON-serializable result: Complete neighbor table as Python list

Implementation Details

Key Data Structures

C Structures

// Neighbor cache information
typedef struct {
    unsigned char cacheinfo_confirmed;  // Last confirmed (jiffies)
    unsigned char cacheinfo_used;       // Last used (jiffies)
    unsigned char cacheinfo_updated;    // Last updated (jiffies)
    unsigned char cacheinfo_refcnt;     // Reference count
    int has_cacheinfo_*;                // Presence flags
} neigh_cacheinfo_t;

// Neighbor entry information
typedef struct {
    int ifindex;                        // Interface index
    unsigned char family;               // AF_INET, AF_INET6, AF_BRIDGE
    unsigned char state;                // NUD_* state bits
    unsigned char flags;                // NTF_* flag bits
    unsigned char type;                 // ARPHRD_* hardware type
    unsigned char dst_addr[16];         // Destination IP/MAC
    unsigned char lladdr[32];           // Link-layer address
    int has_dst_addr;                   // Address present
    int has_lladdr;                     // Link-layer address present
    int lladdr_len;                     // Link-layer address length
    unsigned int probes;                // Number of probes sent
    int has_probes;                     // Probes field present
    unsigned short vlan;                // VLAN ID (bridge only)
    int has_vlan;                       // VLAN present
    unsigned int master;                // Master interface (bridge)
    int has_master;                     // Master present
    neigh_cacheinfo_t cacheinfo;        // Cache timing info
    unsigned short unknown_nda_attrs[64]; // Unknown attributes
    int unknown_nda_attrs_count;        // Count of unknown
} neigh_entry_t;

Python Output Structure

{
  "ifindex": 2,
  "family": "ipv4",
  "state": 2,
  "state_name": "REACHABLE",
  "flags": 0,
  "flag_names": [],
  "type": 1,
  "type_name": "ETHER",
  "dst": "192.168.1.1",
  "dst_canonical": "192.168.1.1",
  "lladdr": "aa:bb:cc:dd:ee:ff",
  "cacheinfo": {
    "confirmed": 12345,
    "used": 12340,
    "updated": 12345,
    "refcnt": 1
  }
}

State Machine

Neighbor Unreachability Detection (NUD)

The Linux kernel maintains a state machine for each neighbor entry:

INCOMPLETE │ │ (ARP/NDP reply received) ▼ REACHABLE ◄────────────────┐ │ │ │ (timeout) │ (traffic confirms reachability) ▼ │ STALE ──────────────────┘ │ │ (needs revalidation) ▼ DELAY │ │ (send probe) ▼ PROBE │ ├─(success)──► REACHABLE │ └─(failure)──► FAILED

State Transitions

From State Event To State
NONE Need to send packet INCOMPLETE
INCOMPLETE Receive reply REACHABLE
INCOMPLETE Timeout (no reply) FAILED
REACHABLE Reachable timeout (~30s) STALE
STALE Traffic confirms reachability REACHABLE
STALE Need to send, unconfirmed DELAY
DELAY Delay timeout (~5s) PROBE
PROBE Receive reply REACHABLE
PROBE Max probes reached FAILED

Attribute Parsing

RTNetlink Attribute Format

Netlink attributes use a Type-Length-Value (TLV) format:

struct rtattr {
    unsigned short rta_len;    // Length including header
    unsigned short rta_type;   // Attribute type (NDA_*)
    // Followed by attribute data
};

NDA_* Attribute Types

Attribute Value Data Type Description
NDA_DST 1 IPv4/IPv6/MAC Destination address
NDA_LLADDR 2 MAC address Link-layer address
NDA_CACHEINFO 3 struct nda_cacheinfo Timing information
NDA_PROBES 4 uint32 Number of probes sent
NDA_VLAN 5 uint16 VLAN ID
NDA_MASTER 9 uint32 Master interface index

Hardware Type Decoding

The type field uses ARPHRD_* constants from if_arp.h:

Constant Value Description
ARPHRD_ETHER 1 Ethernet (most common)
ARPHRD_LOOPBACK 772 Loopback interface
ARPHRD_IEEE80211 801 WiFi (802.11)
ARPHRD_TUNNEL6 769 IPv6 tunnel
ARPHRD_VOID 0xFFFF No hardware address

Usage Guide

Command-Line Interface

Basic Usage

# Full JSON output (all neighbor types)
sudo python3 neighsnapshotter.py

# Human-readable summary
sudo python3 neighsnapshotter.py --summary

Protocol-Specific Queries

# IPv4 ARP only
sudo python3 neighsnapshotter.py --arp --summary

# IPv6 NDP only
sudo python3 neighsnapshotter.py --ndp --summary

# Bridge FDB only
sudo python3 neighsnapshotter.py --bridge --summary

Interface Filtering

# All neighbors on eth0
sudo python3 neighsnapshotter.py --interface eth0 --summary

# IPv4 ARP on specific interface
sudo python3 neighsnapshotter.py --arp --interface eth0 --summary

Python API

Basic Query

from neighsnapshotter import NeighborTableQuery

# Query all neighbors
with NeighborTableQuery() as ntq:
    neighbors = ntq.get_neighbors()

# Print all IPv4 ARP entries
for entry in neighbors:
    if entry['family'] == 'ipv4':
        print(f"{entry['dst']} -> {entry.get('lladdr', 'N/A')}")
        print(f"  State: {entry['state_name']}")
        print(f"  Interface: {entry['ifindex']}")

Protocol-Specific Queries

# Query only IPv4 ARP
with NeighborTableQuery() as ntq:
    arp_entries = ntq.get_neighbors(family='ipv4')

# Query only IPv6 NDP
with NeighborTableQuery() as ntq:
    ndp_entries = ntq.get_neighbors(family='ipv6')

# Query only bridge FDB
with NeighborTableQuery() as ntq:
    fdb_entries = ntq.get_neighbors(family='bridge')

State Analysis

# Find all failed entries
with NeighborTableQuery() as ntq:
    neighbors = ntq.get_neighbors()

failed = [n for n in neighbors if 'FAILED' in n['state_name']]
print(f"Found {len(failed)} failed neighbor entries:")
for entry in failed:
    print(f"  {entry.get('dst', 'unknown')} on interface {entry['ifindex']}")

Router Detection

# Find all routers (typically from IPv6 NDP)
with NeighborTableQuery() as ntq:
    neighbors = ntq.get_neighbors(family='ipv6')

routers = [n for n in neighbors if 'ROUTER' in n['flag_names']]
print(f"Found {len(routers)} routers:")
for router in routers:
    print(f"  {router['dst']}")
    print(f"    MAC: {router.get('lladdr', 'N/A')}")
    print(f"    State: {router['state_name']}")

Bridge FDB Analysis

import socket

# Analyze bridge FDB entries
with NeighborTableQuery() as ntq:
    fdb = ntq.get_neighbors(family='bridge')

# Group by interface
by_interface = {}
for entry in fdb:
    ifindex = entry['ifindex']
    try:
        ifname = socket.if_indextoname(ifindex)
    except:
        ifname = f"if{ifindex}"
    
    if ifname not in by_interface:
        by_interface[ifname] = []
    by_interface[ifname].append(entry)

# Print statistics
for ifname, entries in sorted(by_interface.items()):
    print(f"{ifname}: {len(entries)} MAC addresses")
    
    # Count by state
    states = {}
    for entry in entries:
        state = entry['state_name']
        states[state] = states.get(state, 0) + 1
    
    for state, count in sorted(states.items()):
        print(f"  {state}: {count}")

Cache Health Monitoring

import time

# Monitor cache timing
with NeighborTableQuery() as ntq:
    neighbors = ntq.get_neighbors(family='ipv4')

current_time = int(time.time())

for entry in neighbors:
    if 'cacheinfo' in entry:
        cache = entry['cacheinfo']
        
        # Convert jiffies to seconds (approximate)
        # Note: Actual conversion depends on kernel HZ value
        last_used_sec = cache.get('used', 0) / 100.0
        
        if last_used_sec < 60:
            status = "recently active"
        elif last_used_sec < 300:
            status = "idle"
        else:
            status = "stale"
        
        print(f"{entry['dst']}: {status} (last used ~{last_used_sec:.0f}s ago)")

API Reference

NeighborTableQuery Class

Constructor

NeighborTableQuery(capture_unknown_attrs=True)

Parameters:

  • capture_unknown_attrs (bool): Track unknown netlink attributes (default: True)

get_neighbors()

get_neighbors(family: Optional[str] = None) -> List[Dict[str, Any]]

Parameters:

  • family (str|None): Protocol family filter
    • 'ipv4': IPv4 ARP only
    • 'ipv6': IPv6 NDP only
    • 'bridge': Bridge FDB only
    • None: All neighbor types (default)

Returns: List of neighbor entry dictionaries

Raises:

  • ValueError: Invalid family parameter
  • RuntimeError: Failed to create socket or query kernel

Neighbor Entry Dictionary

Common Fields

Field Type Description
ifindex int Interface index
family str 'ipv4', 'ipv6', or 'bridge'
state int State bitmask (NUD_* flags)
state_name str Human-readable state
flags int Flag bitmask (NTF_* flags)
flag_names List[str] Human-readable flags
type int Hardware type (ARPHRD_*)
type_name str Hardware type name

Optional Fields

Field Type Present When
dst str Destination address available
lladdr str Link-layer address available
probes int Probe count available
vlan int VLAN ID present (bridge only)
master int Master interface index present
cacheinfo Dict Cache timing available

Cache Info Dictionary

Field Type Description
confirmed int Last confirmed time (jiffies)
used int Last used time (jiffies)
updated int Last updated time (jiffies)
refcnt int Reference count
Note on Jiffies: Cache timing values are in jiffies (kernel time ticks). To convert to seconds, divide by the kernel's HZ value (typically 100, 250, or 1000). The exact value depends on your kernel configuration.

Troubleshooting

Common Issues

"Failed to create netlink socket"

Cause: Insufficient permissions

Solution:

  • Run with sudo
  • Or grant CAP_NET_ADMIN capability: setcap cap_net_admin+ep python3

"Python 3.12+ requires setuptools"

Cause: CFFI needs setuptools on Python 3.12+

Solution: pip install setuptools

Empty Neighbor Table

Cause: No neighbors currently cached

Diagnosis:

  • Check if interface is up: ip link show
  • Generate traffic to populate cache: ping 192.168.1.1
  • For IPv6: ping6 fe80::1%eth0

IPv6 Shows NOARP Instead of NDP States

This is normal for:

  • Loopback addresses (::1)
  • Static entries: ip neigh add
  • Point-to-point interfaces

If all IPv6 entries show NOARP: Check if NDP is functioning with ip -6 neigh show

Debugging

Enable Verbose Output

import logging
logging.basicConfig(level=logging.DEBUG)

with NeighborTableQuery() as ntq:
    neighbors = ntq.get_neighbors()

Check Unknown Attributes

# See what attributes the library doesn't recognize
with NeighborTableQuery() as ntq:
    neighbors = ntq.get_neighbors()

for entry in neighbors:
    if 'unknown_nda_attrs_decoded' in entry:
        print(f"Unknown attrs: {entry['unknown_nda_attrs_decoded']}")

Validate Against ip Command

# Compare with system tools
import subprocess
import json

# Get data from neighsnapshotter
with NeighborTableQuery() as ntq:
    our_data = ntq.get_neighbors(family='ipv4')

# Get data from ip command
result = subprocess.run(['ip', '-j', 'neigh', 'show'], 
                       capture_output=True, text=True)
system_data = json.loads(result.stdout)

print(f"NeighSnapshotter: {len(our_data)} entries")
print(f"ip command: {len(system_data)} entries")

Performance Tuning

Disable Unknown Attribute Tracking

# For production use, disable tracking to reduce overhead
with NeighborTableQuery(capture_unknown_attrs=False) as ntq:
    neighbors = ntq.get_neighbors()

Filter Early

# Query only what you need
# More efficient than filtering in Python
with NeighborTableQuery() as ntq:
    # Only get IPv4 ARP from kernel
    arp_only = ntq.get_neighbors(family='ipv4')

Common Patterns

Periodic Monitoring

import time

while True:
    with NeighborTableQuery() as ntq:
        neighbors = ntq.get_neighbors(family='ipv4')
    
    failed = [n for n in neighbors if 'FAILED' in n['state_name']]
    
    if failed:
        print(f"WARNING: {len(failed)} failed ARP entries")
        for entry in failed:
            print(f"  {entry.get('dst', 'unknown')}")
    
    time.sleep(60)  # Check every minute

Export to JSON File

import json
from datetime import datetime

with NeighborTableQuery() as ntq:
    neighbors = ntq.get_neighbors()

output = {
    'timestamp': datetime.now().isoformat(),
    'count': len(neighbors),
    'entries': neighbors
}

with open('neighbor_table.json', 'w') as f:
    json.dump(output, f, indent=2)

Contributing

Code Style

  • C code: Linux kernel style (tabs, descriptive names)
  • Python code: PEP 8 compliant
  • Comments: Explain "why", not "what"

Testing

  1. Test with real neighbor entries (generate with ping)
  2. Test all protocol families (IPv4, IPv6, bridge)
  3. Test edge cases (FAILED states, no entries, etc.)
  4. Test on multiple kernel versions if possible

Adding New Attributes

To add support for new NDA_* attributes:

  1. Add to known_nda_attrs array in C code
  2. Add case in parsing switch statement
  3. Add field to neigh_entry_t structure
  4. Update CFFI definitions
  5. Add Python decoding logic
  6. Update documentation

References