NeighSnapshotter Developer Documentation

## 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

- RTNetlink man page
- ip-neighbour(8) man page
- RFC 826: ARP
- RFC 4861: IPv6 Neighbor Discovery
- Linux Kernel: neighbour.h
- Linux Kernel: if_arp.h
- CFFI Documentation