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 ## 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] Add Swagger and API docs (OpenAPI)
- [x] Record an activity log in the database - [x] Record an activity log in the database
- [x] One event for each device appearance - [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 // Find online devices via ARP
let devices = 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!("Done with ARP probes");
info!("Found {} online devices", devices.len()); info!("Found {} online devices", devices.len());
+117 -11
View File
@@ -14,18 +14,36 @@ use pnet::{
}; };
use tokio::time::{Duration, timeout}; use tokio::time::{Duration, timeout};
pub async fn find(interface: String) -> Result<Vec<Device>, Box<dyn std::error::Error>> { fn select_interface<'a>(
debug!("Looking up devices via ARP using interface {}", interface); 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 // Get the network device to use
let network_interface: NetworkInterface = match datalink::interfaces() let all_interfaces = datalink::interfaces();
.iter() let network_interface: NetworkInterface = match select_interface(&all_interfaces, &interface) {
.filter(|el| el.is_up()) Some(value) => {
.find(|el| el.name == interface) info!("Using network interface: {}", value.name);
{ value.clone()
Some(value) => value.clone(), }
None => { 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()); return Err(InvalidDeviceError.into());
} }
}; };
@@ -42,7 +60,7 @@ pub async fn find(interface: String) -> Result<Vec<Device>, Box<dyn std::error::
{ {
Some(value) => value, Some(value) => value,
None => { 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()); 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 { let mac = match network_interface.mac {
Some(mac) => mac, Some(mac) => mac,
None => { 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()); 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())) 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)] #[derive(Debug, Deserialize, Clone)]
pub struct Networking { pub struct Networking {
pub interface: String, pub interface: Option<String>,
} }
#[derive(Debug, Deserialize, Clone)] #[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 path = "./oott.db" # Database path. Make sure it's writeable for the user running this process
[networking] [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] [log]
level = "info" # off, error, warn, info, debug, trace level = "info" # off, error, warn, info, debug, trace
+3 -3
View File
@@ -22,9 +22,9 @@ in {
default = "/var/lib/oott.db"; default = "/var/lib/oott.db";
}; };
networking.interface = mkOption { networking.interface = mkOption {
type = types.str; type = types.nullOr types.str;
description = "Network interface to use for scans"; description = "Network interface to use for scans. If not set, the first non-loopback connected interface is used automatically.";
default = "eno1"; default = null;
}; };
log.level = mkOption { log.level = mkOption {
type = types.str; type = types.str;