Auto-detect network interface when not configured

If no interface is set in the config, the ARP scanner now picks the
first non-loopback, up, IPv4-enabled interface automatically. The
setting is optional in the TOML config, sample config, and Nix module.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-29 07:35:25 -04:00
co-authored by Claude Sonnet 4.6
parent 49adadf059
commit 08f2dc8340
6 changed files with 124 additions and 18 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
## Backend
- [ ] Use first available network interface when not configured
- [x] Use first available network interface when not configured
- [x] Add Swagger and API docs (OpenAPI)
- [x] Record an activity log in the database
- [x] One event for each device appearance
+1 -1
View File
@@ -13,7 +13,7 @@ pub async fn scan() -> Result<(), Box<dyn std::error::Error>> {
// Find online devices via ARP
let devices =
device_finders::arp::find(get_settings().networking.interface.to_string()).await?;
device_finders::arp::find(get_settings().networking.interface.clone()).await?;
info!("Done with ARP probes");
info!("Found {} online devices", devices.len());
+117 -11
View File
@@ -14,18 +14,36 @@ use pnet::{
};
use tokio::time::{Duration, timeout};
pub async fn find(interface: String) -> Result<Vec<Device>, Box<dyn std::error::Error>> {
debug!("Looking up devices via ARP using interface {}", interface);
fn select_interface<'a>(
interfaces: &'a [NetworkInterface],
configured: &Option<String>,
) -> Option<&'a NetworkInterface> {
match configured {
Some(name) => interfaces
.iter()
.filter(|el| el.is_up())
.find(|el| &el.name == name),
None => interfaces
.iter()
.find(|el| el.is_up() && !el.is_loopback() && el.ips.iter().any(|ip| ip.is_ipv4())),
}
}
pub async fn find(interface: Option<String>) -> Result<Vec<Device>, Box<dyn std::error::Error>> {
debug!("Looking up devices via ARP, configured interface: {:?}", interface);
// Get the network device to use
let network_interface: NetworkInterface = match datalink::interfaces()
.iter()
.filter(|el| el.is_up())
.find(|el| el.name == interface)
{
Some(value) => value.clone(),
let all_interfaces = datalink::interfaces();
let network_interface: NetworkInterface = match select_interface(&all_interfaces, &interface) {
Some(value) => {
info!("Using network interface: {}", value.name);
value.clone()
}
None => {
error!("Interface ({interface}) not found or not active.");
match &interface {
Some(name) => error!("Interface ({name}) not found or not active."),
None => error!("No suitable non-loopback interface with an IPv4 address found."),
}
return Err(InvalidDeviceError.into());
}
};
@@ -42,7 +60,7 @@ pub async fn find(interface: String) -> Result<Vec<Device>, Box<dyn std::error::
{
Some(value) => value,
None => {
error!("No IP address found for selected interface ({interface}).");
error!("No IP address found for selected interface ({}).", network_interface.name);
return Err(NoIPAddressError.into());
}
};
@@ -51,7 +69,7 @@ pub async fn find(interface: String) -> Result<Vec<Device>, Box<dyn std::error::
let mac = match network_interface.mac {
Some(mac) => mac,
None => {
error!("Could not get MAC address for selected interface ({interface}).");
error!("Could not get MAC address for selected interface ({}).", network_interface.name);
return Err(NoMACAddressError.into());
}
};
@@ -120,3 +138,91 @@ pub async fn find(interface: String) -> Result<Vec<Device>, Box<dyn std::error::
Ok(result_receive.unwrap_or(Vec::new()))
}
#[cfg(test)]
mod tests {
use super::*;
use pnet::datalink::NetworkInterface;
use pnet::ipnetwork::IpNetwork;
// Raw Linux IFF flag values used by pnet's is_up() / is_loopback()
const IFF_UP: u32 = 0x1;
const IFF_LOOPBACK: u32 = 0x8;
fn make_interface(name: &str, up: bool, loopback: bool, ipv4: bool) -> NetworkInterface {
let mut flags: u32 = 0;
if up {
flags |= IFF_UP;
}
if loopback {
flags |= IFF_LOOPBACK;
}
let ips = if ipv4 {
vec![IpNetwork::V4("192.168.1.1/24".parse().unwrap())]
} else {
vec![]
};
NetworkInterface {
name: name.to_string(),
description: String::new(),
index: 0,
mac: None,
ips,
flags,
}
}
#[test]
fn test_select_configured_interface_found() {
let ifaces = vec![
make_interface("eth0", true, false, true),
make_interface("wlan0", true, false, true),
];
let result = select_interface(&ifaces, &Some("wlan0".to_string()));
assert!(result.is_some());
assert_eq!(result.unwrap().name, "wlan0");
}
#[test]
fn test_select_configured_interface_not_found() {
let ifaces = vec![make_interface("eth0", true, false, true)];
let result = select_interface(&ifaces, &Some("missing0".to_string()));
assert!(result.is_none());
}
#[test]
fn test_select_configured_interface_down_not_found() {
let ifaces = vec![make_interface("eth0", false, false, true)];
let result = select_interface(&ifaces, &Some("eth0".to_string()));
assert!(result.is_none());
}
#[test]
fn test_auto_select_skips_loopback() {
let ifaces = vec![
make_interface("lo", true, true, true),
make_interface("eth0", true, false, true),
];
let result = select_interface(&ifaces, &None);
assert!(result.is_some());
assert_eq!(result.unwrap().name, "eth0");
}
#[test]
fn test_auto_select_only_loopback_returns_none() {
let ifaces = vec![make_interface("lo", true, true, true)];
let result = select_interface(&ifaces, &None);
assert!(result.is_none());
}
#[test]
fn test_auto_select_skips_interface_without_ipv4() {
let ifaces = vec![
make_interface("eth0", true, false, false),
make_interface("wlan0", true, false, true),
];
let result = select_interface(&ifaces, &None);
assert!(result.is_some());
assert_eq!(result.unwrap().name, "wlan0");
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ pub struct Database {
#[derive(Debug, Deserialize, Clone)]
pub struct Networking {
pub interface: String,
pub interface: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
+1 -1
View File
@@ -2,7 +2,7 @@
path = "./oott.db" # Database path. Make sure it's writeable for the user running this process
[networking]
interface = "eno1" # Network interface to use for scans
# interface = "eno1" # Optional: network interface to use for scans. If not set, the first non-loopback connected interface is used.
[log]
level = "info" # off, error, warn, info, debug, trace
+3 -3
View File
@@ -22,9 +22,9 @@ in {
default = "/var/lib/oott.db";
};
networking.interface = mkOption {
type = types.str;
description = "Network interface to use for scans";
default = "eno1";
type = types.nullOr types.str;
description = "Network interface to use for scans. If not set, the first non-loopback connected interface is used automatically.";
default = null;
};
log.level = mkOption {
type = types.str;