See every device on your LAN — which AP, which switch port, which VLAN, how it's connected, what it is.
NDU continuously scans your home network and correlates data from your existing infrastructure (MikroTik, OPNsense, Pi-hole, UniFi, OpenWRT) into a single, live topology view. Open the browser, see the whole house — no agents, no cloud.
Most tools tell you what is connected. NDU tells you how.
| Tool | What you get |
|---|---|
nmap, arp-scan, Fing |
"Device at 192.168.1.50, MAC aa:bb:cc:..." |
| Router / controller GUI | Only the subset of devices that one box sees |
| NDU | "Galaxy Tab, WiFi to cap-ap2-basement, SSID HomeWiFi, −54 dBm, DHCP lease expires in 2h, last seen 5s ago, VLAN 20." |
NDU stitches together who (vendor, hostname, custom labels), where (AP / switch port / VLAN / interface), how (wifi vs ethernet, signal, link speed), and when (first/last seen, lease expiry, presence timeline) — from the devices you already run.
Sortable, filterable, with per-device tags (infra / handheld / iot / printer / nas / …), presence badges, and last-seen timestamps — all updated in real time via WebSocket.
Expand any row for full metadata, first/last seen, seen count, one-click ping and Wake-on-LAN, and on-demand nmap port scan with cached results.
Status: v0.6.0 running in production on a home LAN of ~50 devices. v0.7.0 (Docker image, audit cleanup) in progress — Docker works locally, GHCR multi-arch publishing pending. See CHANGELOG.md for full history.
ip neigh / ping sweep.ntfy.For feature history see CHANGELOG.md.
┌───────────────────────────────────────────────────────────────┐
│ Browser (Svelte 5 SPA, svelte-spa-router) │
│ Devices table • Topology • Compare • Admin • Login │
│ ▲ ▲ │
│ │ REST /api/* │ WebSocket /ws │
└─────────────────┼─────────────────────┼───────────────────────┘
│ │
┌─────────────────┴─────────────────────┴───────────────────────┐
│ ndu_api.py — FastAPI backend (REST + WS + static SPA) │
├───────────────────────────────────────────────────────────────┤
│ ndu_monitor.py — scan loop orchestrator (subcommands) │
│ ndu_scan.py — scan modes, ARP, provider envelope merge │
│ topology/ — capability-claim graph engine │
│ ndu_topology.py — persisted-topology loader (thin helper) │
│ ndu_db.py — SQLite (WAL), devices / change_log / nmap │
│ ndu_common.py — shared models (DeviceSnapshot, helpers) │
│ ndu_notify.py — HA webhook / HA API / ntfy dispatch │
│ ndu_report.py — markdown / CSV / HTML / compare reports │
├───────────────────────────────────────────────────────────────┤
│ providers/<name>/metadata_<name>.py + config.json │
│ mikrotik opnsense openwrt pihole ubiquiti │
└───────────────────────────────────────────────────────────────┘
All Python modules live at the repo root (not under backend/). The frontend is a separate tree in frontend/ that builds to frontend/dist/ — committed to git so the Pi can deploy without running Node.
Full architecture — provider envelope, capability model, merge rules, topology algorithm — is in docs/architecture.md.
| Path | Purpose |
|---|---|
ndu_monitor.py |
Entrypoint; subcommands monitor, serve, serve-report, report, compare. |
ndu_api.py |
FastAPI app. REST + WebSocket + mounts frontend/dist/ as SPA. |
ndu_scan.py |
Scan modes (passive / active / arp_scan), provider envelope parsing, merge. |
topology/ |
Capability-claim topology engine — per-provider adapters, resolver, persistence. See docs/topology.md. |
ndu_topology.py |
Persisted-topology loader (load_provider_topology). The v1 score-mixing engine was retired in v0.5.0 — see CHANGELOG. |
ndu_db.py |
SQLite schema, migrations, WAL, thread-safe access. |
ndu_notify.py |
Home Assistant + ntfy dispatch. |
ndu_report.py |
Markdown / CSV / HTML / compare reports. |
ndu_common.py |
Shared models and helpers. |
providers/<name>/ |
Provider plugins — metadata_<name>.py + config.example.json. |
frontend/ |
Svelte 5 SPA (source + committed dist/). |
scripts/ |
deploy-pi.sh, deploy.ps1, install.sh, create_admin.py, debug_topology_claims.py, update_oui_vendors.py. |
tests/ |
Unit tests for topology adapters, provider parsing, secret masking, password hashing. |
default/ |
Safe config templates (no personal data), OUI bundle. |
custom/ |
Your real config (gitignored). |
docs/ |
architecture.md, TODO & Notes.md, AI bootstrap files. |
data/ |
Runtime state: ndu.db, users.json, OUI cache, nmap cache (gitignored). |
python3-venv — NDU runs in a project-local virtualenv.arp-scan — for scan.mode: "arp_scan" (recommended; finds the most devices).nmap — for port scanning from the UI.sshpass — optional, MikroTik SSH password fallback when REST is closed.frontend/dist/).sudo apt update
sudo apt install -y python3 python3-venv arp-scan nmap sshpass
sudo setcap cap_net_raw,cap_net_admin+eip "$(command -v arp-scan)"
Python dependencies (FastAPI, argon2, etc.) come from requirements.txt and are installed into /opt/ndu/.venv by scripts/install.sh — no global pip install.
Linux host only — network_mode: host is required (ARP broadcast, WOL, reverse DNS all need the real LAN, not a Docker bridge). Docker Desktop on macOS / Windows cannot join the host network — for those, run NDU on a Raspberry Pi or a small Linux VM.
git clone https://github.com/TursiThePanda/NDU.git ndu
cd ndu
mkdir -p config data
docker compose up -d
Open http://<host>:8787/ in a browser — the first-run wizard will configure everything.
What the compose file does:
Dockerfile (multi-stage; Node compiles the SPA, Python runtime stays lean at ~270 MB)network_mode: host with NET_RAW + NET_ADMIN capabilities so arp-scan works without root./config → /app/custom (config.json, wizard writes here) and ./data → /app/data (SQLite, OUI cache, runtime state)/api/health every 30 sOnce GitHub Actions publishes to GHCR, swap build: . for image: ghcr.io/tursithepanda/ndu:latest to skip the local build.
Known limitation for v0.7.0: the wizard writes the main config.json to the mounted ./config/ directory (persistent), but provider-specific credentials (MikroTik password, UniFi API key, etc.) are written inside the container filesystem and are lost on restart. Workaround: after the wizard, copy the provider configs out of the container to your host before stopping it, or pre-fill them before first start. A proper fix (wizard writes provider configs to the mounted volume) is tracked for v0.7.1.
sudo mkdir -p /opt/ndu
sudo chown "$USER:$USER" /opt/ndu
git clone https://github.com/<your-account>/NDU-Network_Discovery_Utility.git /opt/ndu
cd /opt/ndu
sudo /opt/ndu/scripts/install.sh
Does everything in one pass:
python3, python3-venv, arp-scan, nmap) and prints the exact apt install line if anything is missing.ndu system user with /usr/sbin/nologin and no login shell, chowns /opt/ndu so the service cannot write elsewhere. Accept this on fresh installs; existing User=youruser deployments can keep their owner./opt/ndu/.venv and pip install -r requirements.txt inside it — no --break-system-packages, no global pollution.chmod 600 on custom/config.json, providers/*/config.json, data/users.json; chmod 700 on data/./etc/systemd/system/ndu.service template with the resolved User= and ExecStart= pointing at the venv — operator saves it to disk and runs systemctl enable --now ndu.service.Re-run anytime to refresh dependencies: sudo /opt/ndu/scripts/install.sh --deps-only (also what scripts/deploy-pi.sh calls after git pull).
See docs/secure-deployment.md for the threat model behind the dedicated user + chmods and the upgrade path from a legacy interactive-user install.
Run the first-run wizard in your browser at http://<pi-ip>:8787/setup — it walks you through scan settings, provider detection, credentials, and admin account creation, then writes custom/config.json, per-provider configs, and data/users.json for you.
If you prefer to seed a config from the template manually:
cp custom/config.example.json custom/config.json
Then edit custom/config.json — at minimum set scan.network_cidr and scan.interface.
install.sh printed a unit file template at the end of step 2. Save it, reload, enable:
sudo nano /etc/systemd/system/ndu.service # paste the template
sudo systemctl daemon-reload
sudo systemctl enable --now ndu.service
sudo systemctl status ndu.service
Open http://<pi-ip>:8787 in a browser and log in with the admin account you created in step 3.
Deployments created before v0.5.0 used a global pip3 install --break-system-packages and User=<your-interactive-user>. They keep working — the installer, deploy-pi.sh, and install.sh --deps-only all fall back gracefully when no venv / dedicated user exists. To migrate at your own pace, follow docs/secure-deployment.md.
Two paths, in order of preference:
Commit + push changes from your workstation to GitHub.
On the Pi:
sudo /opt/ndu/scripts/deploy-pi.sh
The script runs git pull as the youruser account (owner of /opt/ndu), then restarts ndu.service. Live configs (custom/, providers/*/config.json, data/, users.json) are gitignored — git pull does not touch them.
GitHub is the source of truth. /opt/ndu has origin set to the repo via a read-only SSH deploy key (github-ndu).
For testing uncommitted changes before push:
scripts/deploy.ps1 robocopies Windows → Samba share.rsync from the Samba path into /opt/ndu.Use sparingly. Production always goes through git.
| Mode | Behavior | Pros | Cons |
|---|---|---|---|
passive |
Only reads ip neigh. |
Zero network noise. | Discovers only devices that are already talking. |
active |
Ping sweep + ip neigh. |
Finds sleeping devices that answer ICMP. | Some ICMP traffic. |
arp_scan |
L2 ARP broadcast via arp-scan. |
Highest hit rate on LAN. | Needs arp-scan + cap_net_raw. |
If arp-scan is missing or cannot open raw sockets, NDU falls back to active for that cycle.
Active verification for provider-only devices (ICMP ping before enrollment) is on by default — keeps DHCP-lease ghosts and UniFi "recently seen" phantoms out of state. See CHANGELOG.md entry for scan.require_active_verification.
Routes in frontend/src/routes/:
/) — sortable table, filters, per-device expand row with metadata / topology location / nmap results./topology) — pan/zoom/search graph with privacy-blur, offline retention, tag-based highlight./compare) — change log for a time window (new / removed / renamed / IP changed)./report) — markdown / HTML export./login) — required before any other route./admin/*) — admin/Config.svelte (scan, identification, notifications, integrations, logging, classification), admin/Users.svelte (user CRUD). Admin role only.Privacy blur (👁/🙈 in the top nav) toggles a CSS blur on sensitive cells (IP / MAC / hostname / SSID / WAN / nmap details). Good for screenshots and screen-share, not a security boundary — values stay in the DOM.
Three channels, any combination:
"home_assistant": {
"webhook_url": "http://HA_IP:8123/api/webhook/ndu_new_device"
}
Automation in automations.yaml:
- id: ndu_new_device_push
alias: NDU - New device discovered
mode: queued
trigger:
- platform: webhook
webhook_id: ndu_new_device
allowed_methods: [POST]
local_only: false
action:
- service: notify.mobile_app_YOUR_PHONE
data:
title: "NDU: New device on network"
message: >
{{ trigger.json.display_name or trigger.json.hostname or 'unknown' }}
| {{ trigger.json.vendor or 'unknown vendor' }}
| {{ trigger.json.ip }} / {{ trigger.json.mac }}
{% if trigger.json.ssid %}| SSID: {{ trigger.json.ssid }}{% endif %}
"home_assistant": {
"api_url": "http://HA_IP:8123",
"token": "LONG_LIVED_ACCESS_TOKEN",
"notify_service": "notify.mobile_app_your_phone"
}
"notifications": {
"channels": {
"ntfy": {
"enabled": true,
"server_url": "https://ntfy.sh",
"topic": "my-ndu-topic",
"priority": "default",
"token": ""
}
}
}
"notifications": {
"enabled": true,
"cooldown_hours": 6,
"cooldown_scope": "mac",
"ignore": {
"macs": ["aa:bb:cc:dd:ee:ff"],
"oui_prefixes": ["48:8f:5a"],
"tags": ["iot", "tv", "ap"],
"connection_types": ["ethernet"],
"vendors": ["routerboard"]
},
"offline": {
"enabled": true,
"grace_seconds": 900
}
}
Ignored devices are still tracked and shown — only the notification is suppressed. Offline events fire on online → offline transition after the grace window.
NDU can also push aggregate sensor values to Home Assistant:
"home_assistant": {
"api_url": "http://HA_IP:8123",
"token": "...",
"sensors": {
"enabled": true,
"interval_seconds": 300,
"entity_prefix": "ndu_network"
}
}
Exposed entities: total_devices, online_devices, offline_devices, unknown_devices, new_devices_24h.
Display name fallback order:
hostname (reverse DNS or provider metadata).identification.mac_aliases.display_name / name / device_name.<vendor> <MAC suffix> from the OUI map.Unknown (xx:yy).OUI vendor map comes from three layers (later overrides earlier):
default/oui_vendors.bundle.json (shipped fallback).identification.oui_vendor_file (auto-updated from IEEE if auto_update_oui: true).identification.oui_vendors in config.Manual refresh:
python3 scripts/update_oui_vendors.py --output data/oui_vendors.json
Tags can be set via config (classification.tags_by_mac, classification.tags_by_oui), from provider metadata, or matched by 23 default auto-tag rules (laptop, phone, AP, NAS, printer, camera, TV, IoT, …).
"classification": {
"tags_by_mac": {
"02:00:00:19:73:58": ["laptop", "trusted"]
},
"tags_by_oui": {
"48:8f:5a": ["infra", "ap"]
}
}
Tags drive: notification ignore rules, topology search highlight, device detail groups, node icons.
"scan": {
"state_retention_hours": 24,
"change_log_retention_hours": 168,
"change_log_max_entries": 5000
}
Devices not seen within state_retention_hours are pruned from state. Known MAC history (known_macs) is kept separately so pruned devices do not re-trigger "new device" alerts when they reappear. To fully reset, delete data/ndu.db (and data/devices_state.json if present — legacy).
# Markdown
python3 ndu_monitor.py --config custom/config.json report --format markdown --output reports/network.md
# HTML sorted by IP
python3 ndu_monitor.py --config custom/config.json report --format html --sort ip --output reports/network.html
# Change log for the last 24 hours
python3 ndu_monitor.py --config custom/config.json compare --hours 24 --format html --output reports/compare.html
--live-scan forces a fresh scan before generating the report. --desc reverses sort order.
The scan loop + API are one process under ndu.service. Other subcommands are available for scripting:
| Command | What it does |
|---|---|
monitor |
Scan loop only, no HTTP. Legacy. |
serve |
Scan loop + FastAPI + WebSocket + SPA (recommended). |
serve-report |
Same as serve, legacy name kept for the home Pi unit. |
report |
One-shot report. |
compare |
One-shot change-log report. |
All accept --config <path>. Defaults to ./config.json.
Licensed under AGPL v3 — see LICENSE. Derivative works, including ones made available over a network (SaaS / managed NDU deployments for others), must publish their modified source under the same terms. Self-hosting NDU on your own LAN carries no obligation.
The project welcomes new providers (the contract is in docs/architecture.md — "Contract for new provider authors"); adding one does not require any change to the scanner.