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) |
Core Design Principles
- 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
- Complete Protocol Coverage
- IPv4 ARP cache entries
- IPv6 Neighbor Discovery (NDP) cache
- Bridge Forwarding Database (FDB)
- All protocol families with unified interface
- Full State Information
- All NUD (Neighbor Unreachability Detection) states
- Flags (PROXY, ROUTER, OFFLOADED, etc.)
- Cache timestamps (confirmed, used, updated)
- Hardware type decoding
- Type Safety & Validation
- C structures ensure correct data types
- Python provides clean, JSON-serializable output
- Unknown attributes tracked for future-proofing
- 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
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:
- Frame arrives on port X with source MAC aa:bb:cc:dd:ee:ff
- Bridge adds entry: "aa:bb:cc:dd:ee:ff is on port X"
- Future frames to aa:bb:cc:dd:ee:ff are sent only to port X
- 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
Data Flow
- Python initiates query:
ntq.get_neighbors(family='ipv4') - CFFI calls C function:
nl_send_getneigh(sock, &seq, AF_INET) - Kernel receives RTM_GETNEIGH: Triggers neighbor table dump
- Kernel sends RTM_NEWNEIGH messages: One per neighbor entry with NDA_* attributes
- C code buffers responses:
nl_recv_response()collects all messages - C code parses messages:
nl_parse_neighbors()extracts data into structures - Python receives CFFI structures: Converts to dictionaries
- Python enriches data: Resolves interface names, decodes addresses
- 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:
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 onlyNone: All neighbor types (default)
Returns: List of neighbor entry dictionaries
Raises:
ValueError: Invalid family parameterRuntimeError: 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 |
Troubleshooting
Common Issues
"Failed to create netlink socket"
Cause: Insufficient permissions
Solution:
- Run with
sudo - Or grant
CAP_NET_ADMINcapability: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
- Test with real neighbor entries (generate with
ping) - Test all protocol families (IPv4, IPv6, bridge)
- Test edge cases (FAILED states, no entries, etc.)
- Test on multiple kernel versions if possible
Adding New Attributes
To add support for new NDA_* attributes:
- Add to
known_nda_attrsarray in C code - Add case in parsing switch statement
- Add field to
neigh_entry_tstructure - Update CFFI definitions
- Add Python decoding logic
- Update documentation