Like arpwatch, but also handles IPv6 and runs on Linux, FreeBSD, OpenBSD, and NetBSD.
| Protocols | ARP (IPv4), NDP (IPv6) |
| Events | New stations, MAC changes, flip-flops, reappearances, bogons, moves |
| Notifications | Email via sendmail with hostname, vendor, and timestamps |
| Active probing | ARP requests / NDP solicitations to detect moved vs. multi-homed hosts |
| OUI database | Optional hardware vendor identification from MAC prefix |
| Storage | Plain CSV with atomic saves (temp file + rename) |
| VLAN trunks | Auto-detects VLAN subinterfaces and skips trunk parents |
| Sandboxing | pledge(2) + unveil(2) on OpenBSD, privilege drop everywhere |
| Portability | Linux (glibc, musl), FreeBSD, OpenBSD, NetBSD |
Single-threaded, single binary, no dependencies beyond libpcap.
make
sudo ./neighbot -q -f /tmp/neighbot.csvThen generate some traffic (arping, ping) and watch the log output.
Requires a C compiler and libpcap.
| OS | Install libpcap |
|---|---|
| Debian / Ubuntu | apt install libpcap-dev |
| Alpine | apk add libpcap-dev |
| Fedora / RHEL | dnf install libpcap-devel |
| FreeBSD / OpenBSD / NetBSD | included in base |
makesudo make install # binary + man page + OUI database
sudo make install-systemd # + systemd unit (Linux)
sudo make install-rcd # + rc.d script (OpenBSD)
make oui-update # re-download IEEE OUI databaseInstalls to /usr/local/sbin by default. Override with PREFIX:
sudo make PREFIX=/usr installUninstall:
sudo make uninstall.deb and .rpm packages are built automatically for each GitHub release
and attached as release assets. Supported distributions:
| Format | Distributions |
|---|---|
.deb |
Ubuntu (stable, LTS), Debian (stable, oldstable) |
.rpm |
Fedora, Rocky Linux (2 versions), openSUSE Leap, SUSE BCI |
.apk |
Alpine Linux |
.tgz |
OpenBSD (2 latest supported releases) |
Install with your package manager:
sudo dpkg -i neighbot_*.deb # Debian/Ubuntu
sudo rpm -i neighbot-*.rpm # Fedora/RHEL/SUSE
sudo apk add --allow-untrusted neighbot-*.apk # Alpine
doas pkg_add -D unsigned ./neighbot-*.tgz # OpenBSDneighbot [-B seconds] [-d] [-e days] [-f dbfile] [-i iface] [-m mailto] [-o ouifile] [-p] [-q] [-r] [-s sendmail] [-u user] [-V]
| Flag | Description |
|---|---|
-B seconds |
Bogon notification cooldown in seconds (default: 1800). Set to 0 for no rate limiting |
-d |
Daemonize (log to syslog instead of stderr) |
-e days |
Drop entries unseen for more than days days (checked hourly). 0 disables (default) |
-f path |
Database file (default: /var/neighbot/neighbot.csv) |
-i iface |
Monitor only this interface (default: all Ethernet interfaces) |
-m addr |
Email recipient (default: root) |
-o path |
OUI vendor database file (default: /var/neighbot/oui.txt) |
-p |
Disable active probing (passive only) |
-q |
Quiet mode. No email notifications, events are still logged |
-r |
Report mode. Print database summary to stdout (or email with -m), then exit |
-s path |
Path to sendmail-compatible MTA (default: /usr/sbin/sendmail) |
-u user |
Drop privileges to this user after opening pcap handles (default: nobody) |
-V |
Print the version number and exit |
# Foreground, quiet, custom DB
sudo neighbot -q -f /tmp/neighbot.csv
# Daemon with email alerts
sudo neighbot -d -u neighbot -m admin@example.com
# Single interface, no probing
sudo neighbot -d -i eth0 -p
# Print database report to stdout
neighbot -r -f /var/neighbot/neighbot.csv
# Email database report
neighbot -r -m admin@example.com| Event | Description | |
|---|---|---|
| new | Previously unknown IP address seen for the first time | yes (suppressed for IPv6 temporary address rotations) |
| changed | IP seen with a different MAC than previously recorded | yes |
| flip-flop | IP alternates between two known MACs (VRRP/HSRP, dual-homing, or spoofing). Storm detection suppresses rapid repeats | yes |
| reappeared | Known MAC/IP pair seen again after 6+ months of silence | yes |
| bogon | IP outside any local subnet on the receiving interface (possible spoofing) | yes |
| ra-learned | IPv6 on-link prefix announced by a Router Advertisement | yes (first time only) |
| moved | MAC seen at a new IP while old IP no longer responds to probes. The obsolete entry is dropped | yes |
When a known MAC appears at a new IP, neighbot sends up to 3 probes (5s timeout each) to each old IP of the same address family associated with that MAC (dual-stack hosts are not probed across IPv4/IPv6):
- IPv4: ARP request with sender IP
0.0.0.0(RFC 5227) - IPv6: NDP Neighbor Solicitation with source
::
This avoids polluting the target's neighbor cache.
| Outcome | Meaning | Action |
|---|---|---|
| Probe answered | Device has multiple IPs | Log only |
| Probe timed out | Device moved to new IP | Log + email |
Link-local addresses (fe80::/10, 169.254/16) are excluded from probing since every IPv6 interface has one alongside its global address. IPs assigned to the local host's own interfaces are also excluded, preventing false "moved" alerts when the host has multiple IPs across VLANs or other interfaces.
IPv6 temporary addresses (RFC 4941): When a device using privacy extensions rotates its temporary address, neighbot detects that the same MAC already has a non-EUI-64 address in the same /64 prefix and suppresses the "new station" email. The prior non-EUI-64 entries in that /64 are silently dropped from the database. This also covers link-local address rotation (common on devices with randomized MACs).
Disable with -p for purely passive monitoring.
neighbot removes entries it knows are no longer valid:
| Source | Trigger | Notification |
|---|---|---|
| Probe timeout | Old IP no longer responds at the recorded MAC after a probe fires | "moved" email |
| Temp address rotation | New non-EUI-64 IPv6 address arrives in a /64 prefix the MAC already uses | silent (logged only) |
| Stale non-EUI-64 IPv6 | Non-EUI-64, non-link-local IPv6 entry unseen for more than 7 days, checked once per hour | silent (logged only) |
Idle expiration (-e days) |
last_seen older than days days, checked once per hour |
silent (logged only) |
This reaping is always on so that RFC 4941 privacy addresses left behind
by an offline device or a prefix change do not linger in the also known as line of later notifications. The test is purely the address shape:
any non-EUI-64, non-link-local IPv6 address qualifies. That also covers
RFC 7217 stable-privacy, DHCPv6, and manually assigned global addresses,
which cannot be told apart from privacy addresses by inspection. A host
that keeps such an address but stays silent for more than 7 days has its
entry dropped and is reported as a new station when it reappears. EUI-64
and link-local addresses are never reaped this way. The broader -e idle
expiration remains opt-in: without -e, non-temporary entries are kept
indefinitely unless a probe confirms the IP is gone.
neighbot listens for ICMPv6 Router Advertisements and records every on-link prefix announced by a router on each interface (Prefix Information Option with the L flag set, RFC 4861). Addresses that match a learned prefix are treated as local and no longer produce bogon alerts, even when the host itself has no IPv6 address configured on that interface.
The first time a new prefix is learned on an interface, neighbot sends an ra-learned notification so the operator is aware of the new subnet. Subsequent refreshes of the same prefix are silent. Prefix entries expire after the RA valid-lifetime (capped at 7 days), and a prefix re-advertised with a zero valid-lifetime is withdrawn immediately.
Incoming Router Advertisements are validated per RFC 4861: only RAs with an IPv6 hop limit of 255 and a link-local source address are accepted, so off-link or spoofed advertisements are dropped. A rogue RA sent by an on-link attacker can still suppress bogon alerts for the prefix it announces. The first-learn notification surfaces that event, and deployments that already run RA-Guard or similar L2 filtering retain protection. Prefixes shorter than /48 are ignored (and logged), so a rogue RA cannot whitelist a wide slice of the address space with a single short prefix.
Linux (systemd)
sudo make install-systemd
sudo systemctl daemon-reload
sudo systemctl enable --now neighbotEdit /etc/systemd/system/neighbot.service to change options
(e.g. add -m admin@example.com to ExecStart, remove -q to enable email).
OpenBSD (rc.d)
sudo make install-rcd
sudo rcctl enable neighbot
sudo rcctl start neighbotOverride flags in /etc/rc.conf.local:
neighbot_flags=-d -m admin@example.com
The OUI vendor database is optional and installed by make install to
/var/neighbot/oui.txt. It is loaded once at startup. If the file is not
found, a warning is logged and neighbot continues without vendor names.
Two file formats are supported:
| Format | Example | Source |
|---|---|---|
| neighbot | aa:bb:cc Vendor Name |
make oui.txt |
| arp-scan | AABBCC\tVendor Name |
net/arp-scan,-mac package |
On OpenBSD, install the arp-scan,-mac package and point neighbot at
/usr/local/share/arp-scan/ieee-oui.txt.
To keep the bundled format current, add a daily cron job:
0 3 * * * curl -sL https://standards-oui.ieee.org/oui/oui.txt \
| awk '/\(hex\)/ { gsub(/-/,":",$1); v=""; for(i=3;i<=NF;i++) v=v(i>3?" ":"")$i; print tolower($1)" "v }' \
> /var/neighbot/oui.txtneighbot will pick up the new data on its next restart.
Plain CSV stored at /var/neighbot/neighbot.csv by default:
ip,mac,interface,first_seen,last_seen,prev_mac
192.168.1.1,aa:bb:cc:dd:ee:ff,eth0,2026-02-23T14:30:00,2026-02-23T15:12:00,00:00:00:00:00:00
fe80::1,11:22:33:44:55:66,eth0,2026-02-23T14:30:05,2026-02-23T15:12:05,00:00:00:00:00:00
Timestamps are ISO 8601, local time. The prev_mac field stores the
previous MAC address for flip-flop detection. Old database files without
this field are loaded without errors.
Saves are atomic (write to temp file + rename). The entry limit is 100,000 to prevent memory exhaustion from spoofed traffic.
| Signal | Action |
|---|---|
SIGHUP |
Save database to disk and clear storm suppression |
SIGTERM / SIGINT |
Save database and exit |
SIGUSR1 |
Dump active probe state to the log |
SIGPIPE |
Ignored |
neighbot drops to an unprivileged user (default: nobody) after opening
pcap handles. All supplementary groups are dropped. The database directory
and file are chowned to the target user before switching.
On OpenBSD, neighbot additionally restricts itself using pledge(2) and
unveil(2):
| Mode | pledge | unveil |
|---|---|---|
Quiet (-q) |
stdio rpath wpath cpath |
DB directory only |
| With email | stdio rpath wpath cpath proc exec dns |
disabled |
All pcap/BPF handles are opened before pledge, so no bpf promise is needed.
Each notification is delivered by a short-lived child process (reverse DNS
plus sendmail). To keep a flood of events (for example spoofed in-subnet
ARP) from spawning children at packet rate, no more than 32 notification
children run at once; further notifications are suppressed and logged until
the backlog clears.
Standalone test harnesses exercise the parser, database loader, OUI loader, probe packet builders, subnet matching, and notification formatting with known inputs. They link without sanitizers so they can run under valgrind.
make test # build all test binaries
tests/test_parse # run parser tests
tests/test_dbload # run database loader tests
tests/test_ouiload # run OUI loader tests
tests/test_probe # run probe builder and state machine tests
tests/test_capture # run capture_is_local subnet tests
tests/test_notify # run format_delta and format_timestamp tests
make test-clean # remove test binariesWith valgrind:
valgrind --leak-check=full --error-exitcode=1 tests/test_parse
valgrind --leak-check=full --error-exitcode=1 tests/test_dbload
valgrind --leak-check=full --error-exitcode=1 tests/test_ouiload
valgrind --leak-check=full --error-exitcode=1 tests/test_probe
valgrind --leak-check=full --error-exitcode=1 tests/test_capture
valgrind --leak-check=full --error-exitcode=1 tests/test_notifyA CI workflow (.github/workflows/valgrind.yml) runs all tests under valgrind
on every push and pull request.
Requires clang with libFuzzer support (included in most clang packages).
make fuzz # build all three fuzz targets
./fuzz_parse -max_total_time=60 # fuzz the packet parser for 60s
./fuzz_dbload -max_total_time=60 # fuzz the CSV database loader
./fuzz_ouiload -max_total_time=60 # fuzz the OUI file loader
make fuzz-clean # remove fuzz binariesEach target is built with ASan and UBSan enabled. Crashes and slow inputs are written to the current directory.
- Enumerates non-loopback Ethernet interfaces via
pcap_findalldevs(), skipping VLAN trunk parents when subinterfaces exist - Opens one pcap handle per interface with BPF filter:
arp or (icmp6 and (ip6[40] == 136 or ip6[40] == 135 or ip6[40] == 134)) - Main loop:
poll()on all handles (1s timeout) - ARP: extracts sender IP + MAC from requests/replies (skips probes)
- NDP: parses Neighbor Advertisements (type 136) and Solicitations (type 135) for link-layer address options (skips DAD); Router Advertisements (type 134) feed on-link prefixes to the bogon filter
- Updates an in-memory hash table; on new/changed entries, logs and
optionally emails via
fork()/exec()of sendmail
BSD 2-Clause. See LICENSE.