A typical router keeps active devices' ARP entries fresh continuously, so a 10-minute poll interval stays well within common ARP cache TTLs while keeping device-event churn modest (each poll records a DeviceSeen event per device). A 5s per-request timeout adds margin for a busy agent or large ARP table at no cost on the happy path. Updates the default in settings.rs and the sample TOML, README and NixOS module. TODO timing-review item narrowed to the ARP scanner. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
14 KiB
OOTT
Easy to setup and use network device discovery and alert system
Licensed under AGPL v3. See LICENSE.
What is it?
OOTT provides a service that runs behind the scenes and monitors your local network. It's most relevant features are:
- Scan network regularly using ARP probes
- Notify when a new device is found
- Notify when a device changed it's IP address or network interface vendor (based on its MAC address)
- Notify when a device came back online after a configurable period of being offline
OOTT can be installed as a NixOS module (using flakes) or as a docker image, see below for instructions on how to deploy and configure it.
Installation & configuration
Install OOTT using Docker
OOTT is available on Docker Hub as a pre-built image here. To use it I recommend using docker compose, you can find a sample compose file here.
If you use our Docker Hub image and docker compose the steps you have to follow are:
- Create a folder structure in your host
- Create the configuration file
- Create the
docker-compose.ymlfile - Start the service
1. Create a folder structure in your host
You need a place to store the docker-compose.yml file, the OOTT configuration file and the database that will store the application state:
mkdir -p /docker/oott/config
mkdir -p /docker/oott/db
You can choose whatever structure or locations you choose. Note that:
- The user that runs the docker process must have read access to the config folder and read/write access to the db folder
- OOTT uses SQLite and it doesn't really like remote access so the
db/folder should be local to your docker host
2. Create the configuration file
Create the oott.toml file (for example at /docker/oott/config/oott.toml in your docker host) and populate it with your preferences. You can use the provided example as baseline.
Important
The
database.pathoption must always be set to"/db/oott.db"when using our docker image.
3. Create the docker-compose.yml file
Create a docker compose file to run the container (for example at /docker/oott/docker-compose.yml), you can use the provided example as is or adjust it to match your environment.
4. Start the service
Run docker compose up -d from where you placed your docker-compose.yml file and verify everything is running smoothly.
You can check the applications log using docker logs CONTAINER_ID -f, to see the active containers you can use docker ps.
Install OOTT using NixOS flakes
OOTT comes with a pre-built NixOS flake that you can integrate in your configuration. If you are not using flakes, well you should. If you still won't I guess you can use the flake code as a baseline and write your own derivation.
To integrate the OOTT flake into your config in most cases you should do the following:
- Add OOTT to your inputs
- Add the OOTT module and overlay
- Enable and setup OOTT in your system configuration
1. Add OOTT to your inputs
In your flake.nix inputs section add OOTT:
inputs = {
...
oott = {
url = "github:rzuasti/oott";
inputs.nixpkgs.follows = "nixpkgs";
};
...
};
2. Add the OOTT module and overlay
In your flake.nix modules section add the OOTT module and overlay:
...
modules = [
...
oott.nixosModules.oott
({pkgs, ...}: {
nixpkgs.overlays = [
oott.overlays.default
];
})
...
];
...
3. Enable and setup OOTT in your system configuration
Finally, in your configuration.nix (or in an import file) enable and configure OOTT (note that you should embed the following sections in your file appropriately):
{
pkgs,
...
}
: {
environment.systemPackages = with pkgs; [
oott
];
services.oott = {
enable = true;
database.path = "/var/lib/oott.db";
# networking.interface = "eth0"; # Optional: auto-detected if not set
log.level = "info";
arp_scanner.wait_between_scans = "15m";
arp_scanner.sender_timeout = "20m";
arp_scanner.scan_duration = "30m";
notifications.method = "pushover";
notifications.notify_when_not_seen_for = "1w";
notifications.pushover.token = "YOUR API TOKEN GOES HERE";
notifications.pushover.user_key = "YOUR USER TOKEN GOES HERE";
retention.window = "365d";
};
}
Configuration options
The system configuration is centralized in a single config file, you can use TOML, JSON or YAML to write it.
If you are using the provided NixOS flake you should set all the options via nix in the service definition (see above).
If you are using Docker I recommend writing the config using TOML, here is a sample with all the supported options.
Options list
| Option | Sample value | Description |
|---|---|---|
database.path |
/var/lib/oott.db |
Location of the system database |
networking.interface |
eno1 |
Network interface to use for scans. Optional — if not set, the first non-loopback connected interface is used automatically. |
log.level |
info |
Log level to use (trace, debug, info, warn, error) |
arp_scanner.enabled |
true |
Whether to run the ARP scanner. Defaults to enabled; set to false to turn it off. |
arp_scanner.wait_between_scans |
15m |
Time to wait between each network scan (you can express it in seconds, minutes, hours, etc. as a suffix - for example: 30s, 10m, 1h) |
arp_scanner.sender_timeout |
1m |
If the ARP sender process takes longer than this it will be stopped (for a class C network - 254 IPs - it should take less than a minute) |
arp_scanner.scan_duration |
10m |
How long to wait for response packets on each scan (5m to 10m is a good timeframe for a class B or C network) |
mdns_scanner.enabled |
true |
Whether to run the mDNS/Bonjour scanner. Defaults to enabled; set to false to turn it off. |
mdns_scanner.probe_timeout |
2s |
When an mDNS-discovered IP is not in the OS ARP cache, how long to wait for a targeted ARP probe reply to resolve its MAC address |
ssdp_scanner.enabled |
true |
Whether to run the SSDP/UPnP scanner. Defaults to enabled; set to false to turn it off. |
ssdp_scanner.probe_timeout |
2s |
When an SSDP/UPnP-discovered IP is not in the OS ARP cache, how long to wait for a targeted ARP probe reply to resolve its MAC address |
dhcp_scanner.enabled |
true |
Whether to run the DHCP scanner. Defaults to enabled; set to false to turn it off. |
snmp_scanner.enabled |
true when the section is present, otherwise off |
Whether to run the SNMP scanner. The whole [snmp_scanner] section is optional and the scanner stays off unless you add it; within the section it defaults to enabled. |
snmp_scanner.target |
SNMP agent to poll, as host:port (e.g. your gateway: 192.168.1.1:161). The scanner reads the agent's ARP table over SNMPv2c — no local probing. |
|
snmp_scanner.community |
SNMPv2c read-only community string. Use a read-only community and never commit a real secret. | |
snmp_scanner.wait_between_scans |
10m |
Time to wait between polls. Keep it well under the agent's ARP cache timeout so active devices aren't missed. |
snmp_scanner.timeout |
5s |
Per-poll SNMP request timeout. |
notifications.method |
pushover |
For now just pushover, you can set this to "none" to avoid sending notifications (it will just log) |
notifications.notify_when_not_seen_for |
1w |
Send a notification if a device comes back online after not being seen for this timeframe (you can use hours, weeks, etc.) |
notifications.pushover.token |
Your pushover token goes here, just copy&paste from their website after creating the app | |
notifications.pushover.user_key |
User key goes here, this is the account wide code for pushover | |
retention.window |
365d |
How long to retain device events and notifications. Records older than this are purged daily. Accepts duration strings (e.g. 90d, 1y, 6m). Defaults to one year. |
Network ports
OOTT binds to the following ports on the host where it runs:
| Port | Protocol | Configurable | Used by |
|---|---|---|---|
3000 |
TCP | Yes (web_server.port) |
REST API and web UI |
5353 |
UDP (multicast) | No — fixed | mDNS/Bonjour scanner |
1900 |
UDP (multicast) | No — fixed | SSDP/UPnP scanner |
67 |
UDP (broadcast) | No — fixed | DHCP scanner |
Important
The mDNS (UDP
5353), SSDP (UDP1900) and DHCP (UDP67) ports are fixed by their respective protocols and cannot be changed. They must be available on the server where OOTT is installed.OOTT binds these sockets with address/port reuse, so it can run alongside other responders already listening on them (for example
avahion5353,minidlnaon1900, or a DHCP server/relay on67). However, the ports must not be blocked by a host firewall, and the corresponding multicast/broadcast traffic must be allowed to reach the host — otherwise the scanners will not discover any devices.Port
67is a privileged port, so OOTT must run with sufficient privileges to bind it (it already requires raw-socket access for the ARP scanner).When running under Docker these scanners require host networking (or an equivalent setup that exposes the host's multicast and broadcast traffic to the container); see the sample compose file.
Connecting the app to the backend
The OOTT app (web, desktop, iOS and Android) talks to the backend exclusively over its REST API (port 3000 by default). You point the app at your backend from the app's settings. There are two supported ways to expose the backend to the app:
Direct connection over HTTP (local network only)
Point the app at the backend's address on your local network, for example http://192.168.1.50:3000. This is the simplest setup and needs no extra infrastructure, but only works while the device is on the same local network as the backend.
Important
On iOS this only works when you use the backend's private-range IP address (
192.168.x.x,10.x.x.x,172.16–31.x.x) or a*.local(mDNS/Bonjour) name. A custom internal domain (e.g.oott.mylan.com) served over plain HTTP will be blocked by iOS App Transport Security, even when it resolves to a private IP — use the IP address directly, or HTTPS (see below), instead.
Behind a reverse proxy with HTTPS (recommended)
Put the backend behind a reverse proxy (nginx, Caddy, Traefik, …) that terminates TLS with a valid certificate from a trusted CA (for example Let's Encrypt), and point the app at the HTTPS URL (e.g. https://oott.example.com). This works on all platforms — including iOS — with no further configuration, and is required for access from outside the local network.
Connect using the domain name the certificate is issued for (not an IP address). Self-signed certificates are not supported unless they are manually trusted on the device.
Note
The first time the iOS app reaches your backend on the local network, iOS shows a one-time "find devices on your local network" prompt. This is expected — tap Allow to continue.
Storage considerations
OOTT stores a timestamped event in the database for every device detected on every scan. Storage therefore scales with three factors: number of active devices, scan frequency, and the retention window.
Estimates by network size
The figures below assume a 1-minute scan duration (arp_scanner.scan_duration = 1m) with a 1-minute wait between scans (arp_scanner.wait_between_scans = 1m), giving 720 scans per day, and a 365-day retention window. Assume all devices are continuously online (worst case).
| Network size | Active devices | Storage / year |
|---|---|---|
| Home network | 20–50 | ~1–2 GB |
| Homelab | 50–100 | ~2–4 GB |
| Small office | 100–200 | ~4–7 GB |
| Medium office | 200–500 | ~7–18 GB |
The rough formula behind these numbers is:
storage ≈ devices × scans_per_day × 145 bytes × retention_days
where 145 bytes covers the database row and its index entry, and scans_per_day = 86400 / (scan_duration_seconds + wait_between_scans_seconds).
Tuning scan timings and retention to control storage
Storage is directly proportional to scan frequency and retention window. The two main levers are:
Scan interval — arp_scanner.wait_between_scans is the most effective knob. Increasing it reduces scans per day linearly:
wait_between_scans |
Scans/day (with 1m scan) | Storage vs. 1m+1m |
|---|---|---|
1m |
720 | 1× (baseline) |
4m |
288 | 0.4× |
15m |
90 | 0.12× |
30m |
48 | 0.07× |
A 15-minute wait (the default) cuts storage to about one eighth of the worst-case figures above — the medium office drops from up to 18 GB to roughly 2 GB per year.
Retention window — retention.window sets how far back history is kept. Halving the window halves the storage. Useful reference points:
retention.window |
Use case |
|---|---|
30d |
Minimal footprint, recent activity only |
90d |
A quarter's worth of history |
180d |
Six months — good middle ground |
365d |
One year (default) |
Recommended starting points by network type:
| Network | wait_between_scans |
retention.window |
Approx. storage |
|---|---|---|---|
| Home | 15m |
365d |
~100–250 MB |
| Homelab | 5m |
180d |
~350–700 MB |
| Small office | 5m |
90d |
~175–350 MB |
| Medium office | 15m |
90d |
~175–450 MB |