From 189b79233b8e2c41c7220c4b1354d3fe5b17778f Mon Sep 17 00:00:00 2001 From: rzuasti Date: Mon, 1 Jun 2026 18:34:02 -0400 Subject: [PATCH] Add passive DHCP-snooping scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listen for DHCP DISCOVER/REQUEST broadcasts on UDP 67 to catch devices as early as possible — a device must request an address before doing almost anything else, often before it has an IP. Follows the mDNS/SSDP scanner pattern (finder/scanner/status modules) and feeds the shared devices/events/notifications pipeline. The client MAC is taken directly from the packet's chaddr, so no ARP probe is needed; a DISCOVER with no assigned IP reuses any previously recorded address rather than clobbering it. Exposes GET /api/dhcp_scanner/status, wired into the OpenAPI generation, and adds a Dhcp variant to DeviceEventScanner. Co-Authored-By: Claude Opus 4.8 --- README.md | 9 +- backend/src/main.rs | 2 + backend/src/model/device_events.rs | 3 + backend/src/scanners.rs | 1 + backend/src/scanners/dhcp.rs | 3 + backend/src/scanners/dhcp/finder.rs | 298 +++++++++++++++++++++++++ backend/src/scanners/dhcp/scanner.rs | 97 ++++++++ backend/src/scanners/dhcp/status.rs | 105 +++++++++ backend/src/web_server.rs | 6 + backend/src/web_server/dhcp_scanner.rs | 57 +++++ 10 files changed, 578 insertions(+), 3 deletions(-) create mode 100644 backend/src/scanners/dhcp.rs create mode 100644 backend/src/scanners/dhcp/finder.rs create mode 100644 backend/src/scanners/dhcp/scanner.rs create mode 100644 backend/src/scanners/dhcp/status.rs create mode 100644 backend/src/web_server/dhcp_scanner.rs diff --git a/README.md b/README.md index b37628c..a75c6be 100644 --- a/README.md +++ b/README.md @@ -152,13 +152,16 @@ OOTT binds to the following ports on the host where it runs: |`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`) and SSDP (UDP `1900`) ports are fixed by their respective protocols and **cannot be changed**. They must be available on the server where OOTT is installed. +> The mDNS (UDP `5353`), SSDP (UDP `1900`) and DHCP (UDP `67`) 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 `avahi` on `5353` or `minidlna` on `1900`). However, the ports must not be blocked by a host firewall, and the corresponding multicast traffic must be allowed to reach the host — otherwise the mDNS and SSDP scanners will not discover any devices. +> OOTT binds these sockets with address/port reuse, so it can run alongside other responders already listening on them (for example `avahi` on `5353`, `minidlna` on `1900`, or a DHCP server/relay on `67`). 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. > -> When running under Docker these scanners require host networking (or an equivalent setup that exposes the host's multicast traffic to the container); see the [sample compose file](https://github.com/rzuasti/oott/blob/main/examples/docker-compose.yml). +> Port `67` is 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](https://github.com/rzuasti/oott/blob/main/examples/docker-compose.yml). # 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. diff --git a/backend/src/main.rs b/backend/src/main.rs index ae8b9d4..fa6bc21 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -60,12 +60,14 @@ async fn main() -> Result<(), Box> { scanners::arp::status::init(); scanners::mdns::status::init(); scanners::ssdp::status::init(); + scanners::dhcp::status::init(); // Start the device scanners, web server, and retention cleaner in parallel tokio::join!( scanners::arp::scanner::scan(), scanners::mdns::scanner::listen(), scanners::ssdp::scanner::listen(), + scanners::dhcp::scanner::listen(), web_server::serve(), retention::run() ) diff --git a/backend/src/model/device_events.rs b/backend/src/model/device_events.rs index 72ec8da..0b0893e 100644 --- a/backend/src/model/device_events.rs +++ b/backend/src/model/device_events.rs @@ -119,6 +119,7 @@ pub enum DeviceEventScanner { Arp, Mdns, Ssdp, + Dhcp, } impl fmt::Display for DeviceEventScanner { @@ -127,6 +128,7 @@ impl fmt::Display for DeviceEventScanner { Self::Arp => write!(f, "ARP"), Self::Mdns => write!(f, "mDNS"), Self::Ssdp => write!(f, "SSDP"), + Self::Dhcp => write!(f, "DHCP"), } } } @@ -150,6 +152,7 @@ impl FromStr for DeviceEventScanner { "ARP" => Ok(DeviceEventScanner::Arp), "mDNS" => Ok(DeviceEventScanner::Mdns), "SSDP" => Ok(DeviceEventScanner::Ssdp), + "DHCP" => Ok(DeviceEventScanner::Dhcp), _ => Err(DeviceEventScannerParseError), } } diff --git a/backend/src/scanners.rs b/backend/src/scanners.rs index f062493..b7e0fc2 100644 --- a/backend/src/scanners.rs +++ b/backend/src/scanners.rs @@ -1,4 +1,5 @@ pub mod arp; +pub mod dhcp; pub mod error; pub mod mdns; pub mod ssdp; diff --git a/backend/src/scanners/dhcp.rs b/backend/src/scanners/dhcp.rs new file mode 100644 index 0000000..aefcb6d --- /dev/null +++ b/backend/src/scanners/dhcp.rs @@ -0,0 +1,3 @@ +pub mod finder; +pub mod scanner; +pub mod status; diff --git a/backend/src/scanners/dhcp/finder.rs b/backend/src/scanners/dhcp/finder.rs new file mode 100644 index 0000000..a4e89c9 --- /dev/null +++ b/backend/src/scanners/dhcp/finder.rs @@ -0,0 +1,298 @@ +use std::net::{Ipv4Addr, SocketAddrV4}; + +use log::{debug, info}; +use socket2::{Domain, Protocol, Socket, Type}; +use tokio::net::UdpSocket; + +const DHCP_SERVER_PORT: u16 = 67; + +// DHCP/BOOTP fixed-header field offsets (RFC 2131). +const OP_OFFSET: usize = 0; +const HTYPE_OFFSET: usize = 1; +const HLEN_OFFSET: usize = 2; +const CIADDR_OFFSET: usize = 4 + 4 + 2 + 2; // hops, xid, secs, flags precede ciaddr +const CHADDR_OFFSET: usize = 28; +const MAGIC_COOKIE_OFFSET: usize = 236; +const OPTIONS_OFFSET: usize = 240; + +// The DHCP magic cookie that precedes the options section. +const MAGIC_COOKIE: [u8; 4] = [99, 130, 83, 99]; + +// Values we care about. +const OP_BOOTREQUEST: u8 = 1; +const HTYPE_ETHERNET: u8 = 1; +const HLEN_ETHERNET: u8 = 6; + +const OPTION_PAD: u8 = 0; +const OPTION_END: u8 = 255; +const OPTION_HOSTNAME: u8 = 12; +const OPTION_REQUESTED_IP: u8 = 50; +const OPTION_MESSAGE_TYPE: u8 = 53; + +const DHCP_DISCOVER: u8 = 1; +const DHCP_REQUEST: u8 = 3; + +/// Open a UDP socket that passively snoops DHCP client traffic. Clients broadcast +/// DISCOVER/REQUEST messages to the server port (67), so we bind `0.0.0.0:67` with +/// address/port reuse to coexist with any DHCP server/relay already on the host. DHCP is +/// broadcast rather than multicast, so there is no group to join. +pub fn open_socket() -> Result> { + let socket = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; + socket.set_reuse_address(true)?; + #[cfg(unix)] + socket.set_reuse_port(true)?; + socket.bind(&SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DHCP_SERVER_PORT).into())?; + socket.set_nonblocking(true)?; + + let udp = UdpSocket::from_std(socket.into())?; + info!("DHCP listener bound to port {DHCP_SERVER_PORT}"); + Ok(udp) +} + +/// The contents of a DHCP client request relevant to device discovery. +pub struct DhcpDiscovery { + /// Client hardware (MAC) address taken directly from `chaddr` (e.g. `aa:bb:cc:dd:ee:ff`). + pub mac: String, + /// Host name advertised by option 12, if present. + pub hostname: Option, + /// Best-effort client IP: `ciaddr` when bound/renewing, otherwise the requested IP + /// (option 50). `None` for a fresh DISCOVER that carries neither. + pub ip_hint: Option, +} + +/// Parse a raw DHCP packet into a `DhcpDiscovery`. Returns `None` for anything that is not +/// an Ethernet client DISCOVER/REQUEST (server replies, non-Ethernet hardware, other +/// message types, or unparseable/truncated buffers). +pub fn parse_packet(buf: &[u8]) -> Option { + // Need the full fixed header plus the 4-byte magic cookie before any option. + if buf.len() < OPTIONS_OFFSET { + debug!("Ignoring short DHCP packet ({} bytes)", buf.len()); + return None; + } + + // Only client requests over Ethernet carry a usable MAC in chaddr. + if buf[OP_OFFSET] != OP_BOOTREQUEST + || buf[HTYPE_OFFSET] != HTYPE_ETHERNET + || buf[HLEN_OFFSET] != HLEN_ETHERNET + { + return None; + } + + if buf[MAGIC_COOKIE_OFFSET..OPTIONS_OFFSET] != MAGIC_COOKIE { + debug!("Ignoring DHCP packet with missing/invalid magic cookie"); + return None; + } + + let mac = format_mac(&buf[CHADDR_OFFSET..CHADDR_OFFSET + 6]); + + let ciaddr = Ipv4Addr::new( + buf[CIADDR_OFFSET], + buf[CIADDR_OFFSET + 1], + buf[CIADDR_OFFSET + 2], + buf[CIADDR_OFFSET + 3], + ); + + let mut message_type: Option = None; + let mut hostname: Option = None; + let mut requested_ip: Option = None; + + // Walk the TLV options. Each option is code(1) + len(1) + len bytes, except PAD (no + // length) and END (terminates). Any malformed/truncated length stops the walk safely. + let mut i = OPTIONS_OFFSET; + while i < buf.len() { + let code = buf[i]; + if code == OPTION_END { + break; + } + if code == OPTION_PAD { + i += 1; + continue; + } + // Need a length byte and the advertised payload to be fully present. + if i + 1 >= buf.len() { + break; + } + let len = buf[i + 1] as usize; + let value_start = i + 2; + let value_end = value_start + len; + if value_end > buf.len() { + break; + } + let value = &buf[value_start..value_end]; + + match code { + OPTION_MESSAGE_TYPE => { + if let Some(&t) = value.first() { + message_type = Some(t); + } + } + OPTION_HOSTNAME => { + if let Ok(name) = std::str::from_utf8(value) { + let name = name.trim_matches(char::from(0)).trim(); + if !name.is_empty() { + hostname = Some(name.to_string()); + } + } + } + OPTION_REQUESTED_IP if value.len() == 4 => { + requested_ip = Some(Ipv4Addr::new(value[0], value[1], value[2], value[3])); + } + _ => {} + } + + i = value_end; + } + + // Only DISCOVER/REQUEST signal "a client is asking for an address". + match message_type { + Some(DHCP_DISCOVER) | Some(DHCP_REQUEST) => {} + _ => return None, + } + + let ip_hint = if ciaddr != Ipv4Addr::UNSPECIFIED { + Some(ciaddr) + } else { + requested_ip + }; + + Some(DhcpDiscovery { + mac, + hostname, + ip_hint, + }) +} + +/// Format 6 raw MAC bytes as a lowercase colon-separated string. +fn format_mac(bytes: &[u8]) -> String { + bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::>() + .join(":") +} + +#[cfg(test)] +mod tests { + use super::*; + + // Build a minimal but valid DHCP packet: fixed header + magic cookie + options. + fn build_packet(op: u8, htype: u8, hlen: u8, ciaddr: Ipv4Addr, options: &[u8]) -> Vec { + let mut buf = vec![0u8; OPTIONS_OFFSET]; + buf[OP_OFFSET] = op; + buf[HTYPE_OFFSET] = htype; + buf[HLEN_OFFSET] = hlen; + let ci = ciaddr.octets(); + buf[CIADDR_OFFSET..CIADDR_OFFSET + 4].copy_from_slice(&ci); + // chaddr: aa:bb:cc:dd:ee:ff + buf[CHADDR_OFFSET..CHADDR_OFFSET + 6].copy_from_slice(&[0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]); + buf[MAGIC_COOKIE_OFFSET..OPTIONS_OFFSET].copy_from_slice(&MAGIC_COOKIE); + buf.extend_from_slice(options); + buf.push(OPTION_END); + buf + } + + fn message_type_option(t: u8) -> Vec { + vec![OPTION_MESSAGE_TYPE, 1, t] + } + + #[test] + fn test_parse_discover_with_hostname_and_requested_ip() { + let mut options = message_type_option(DHCP_DISCOVER); + options.extend_from_slice(&[OPTION_HOSTNAME, 6, b'l', b'a', b'p', b't', b'o', b'p']); + options.extend_from_slice(&[OPTION_REQUESTED_IP, 4, 192, 168, 1, 50]); + + let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + let parsed = parse_packet(&buf).expect("discover packet"); + assert_eq!(parsed.mac, "aa:bb:cc:dd:ee:ff"); + assert_eq!(parsed.hostname.as_deref(), Some("laptop")); + assert_eq!(parsed.ip_hint, Some(Ipv4Addr::new(192, 168, 1, 50))); + } + + #[test] + fn test_parse_request_prefers_ciaddr_over_requested_ip() { + let mut options = message_type_option(DHCP_REQUEST); + options.extend_from_slice(&[OPTION_REQUESTED_IP, 4, 192, 168, 1, 50]); + + let buf = build_packet( + OP_BOOTREQUEST, + HTYPE_ETHERNET, + HLEN_ETHERNET, + Ipv4Addr::new(192, 168, 1, 99), + &options, + ); + let parsed = parse_packet(&buf).expect("request packet"); + assert_eq!(parsed.mac, "aa:bb:cc:dd:ee:ff"); + // ciaddr (bound/renewing) takes precedence over the requested-IP option. + assert_eq!(parsed.ip_hint, Some(Ipv4Addr::new(192, 168, 1, 99))); + } + + #[test] + fn test_parse_discover_without_ip_yields_none_hint() { + let options = message_type_option(DHCP_DISCOVER); + let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + let parsed = parse_packet(&buf).expect("discover packet"); + assert_eq!(parsed.mac, "aa:bb:cc:dd:ee:ff"); + assert_eq!(parsed.hostname, None); + assert_eq!(parsed.ip_hint, None); + } + + #[test] + fn test_parse_server_reply_returns_none() { + // op = 2 (BOOTREPLY) — a server message, ignored. + let options = message_type_option(DHCP_DISCOVER); + let buf = build_packet(2, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + assert!(parse_packet(&buf).is_none()); + } + + #[test] + fn test_parse_other_message_type_returns_none() { + // message type 5 = ACK (server->client), not a client request. + let options = message_type_option(5); + let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + assert!(parse_packet(&buf).is_none()); + } + + #[test] + fn test_parse_missing_message_type_returns_none() { + // No option 53 present at all. + let options = vec![OPTION_HOSTNAME, 4, b'h', b'o', b's', b't']; + let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + assert!(parse_packet(&buf).is_none()); + } + + #[test] + fn test_parse_bad_magic_cookie_returns_none() { + let options = message_type_option(DHCP_DISCOVER); + let mut buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + buf[MAGIC_COOKIE_OFFSET] = 0; + assert!(parse_packet(&buf).is_none()); + } + + #[test] + fn test_parse_non_ethernet_returns_none() { + let options = message_type_option(DHCP_DISCOVER); + // htype != 1 + let buf = build_packet(OP_BOOTREQUEST, 6, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + assert!(parse_packet(&buf).is_none()); + // hlen != 6 + let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, 8, Ipv4Addr::UNSPECIFIED, &options); + assert!(parse_packet(&buf).is_none()); + } + + #[test] + fn test_parse_truncated_and_garbage_returns_none() { + assert!(parse_packet(&[0u8; 100]).is_none()); + assert!(parse_packet(&[0xff, 0x00, 0x13]).is_none()); + } + + #[test] + fn test_parse_truncated_option_length_is_safe() { + // Option claims 4 bytes of payload but the buffer ends early. Must not panic and + // must not yield a discovery (message type never read). + let options = vec![OPTION_REQUESTED_IP, 4, 192, 168]; + let buf = build_packet(OP_BOOTREQUEST, HTYPE_ETHERNET, HLEN_ETHERNET, Ipv4Addr::UNSPECIFIED, &options); + // Drop the trailing END byte appended by build_packet to keep the option truncated. + let truncated = &buf[..buf.len() - 1]; + assert!(parse_packet(truncated).is_none()); + } +} diff --git a/backend/src/scanners/dhcp/scanner.rs b/backend/src/scanners/dhcp/scanner.rs new file mode 100644 index 0000000..1e57462 --- /dev/null +++ b/backend/src/scanners/dhcp/scanner.rs @@ -0,0 +1,97 @@ +use chrono::Local; +use log::{debug, error, info, warn}; + +use super::finder; +use super::status; +use crate::data::mac_vendor_finder; +use crate::data::vendor_device_type_finder; +use crate::db; +use crate::events; +use crate::model::device_events::DeviceEventScanner; +use crate::model::devices::Device; + +/// Passively snoop DHCP DISCOVER/REQUEST broadcasts and feed discovered devices into the +/// same pipeline used by the ARP, mDNS and SSDP scanners (devices table + events + +/// notifications). Because a device must request an address before doing almost anything +/// else, this catches new devices very early — often before they have an IP. +pub async fn listen() -> Result<(), Box> { + let socket = finder::open_socket()?; + status::set_listening(); + info!("DHCP scanner listening for client requests"); + + let mut buf = [0u8; 1500]; + loop { + let len = match socket.recv_from(&mut buf).await { + Ok((len, _src)) => len, + Err(err) => { + warn!("DHCP socket receive error: {err}"); + continue; + } + }; + + let discovery = match finder::parse_packet(&buf[..len]) { + Some(d) => d, + None => continue, // server reply, non-Ethernet, other message type, garbage + }; + + process_discovery(discovery).await; + } +} + +async fn process_discovery(discovery: finder::DhcpDiscovery) { + let mac = discovery.mac; + + // Unlike mDNS/SSDP, the MAC is carried in the packet (chaddr), so no ARP probe is + // needed. Privacy/locally-administered MACs have no real OUI, so the lookup returns + // an empty vendor; there is no DHCP equivalent of a service list to fall back on, so + // it is left empty (the DB layer preserves any previously known vendor). + let vendor = mac_vendor_finder::find(mac.get(0..8).unwrap_or("").to_string()); + + let ip = discovery.ip_hint.map(|ip| ip.to_string()); + let mut device = Device::new( + mac.clone(), + ip.clone().unwrap_or_default(), + vendor, + Local::now().to_utc(), + ); + device.device_type = vendor_device_type_finder::find(&device.vendor); + device.name = discovery.hostname; + + match db::devices::read(mac.clone()) { + Some(recorded) => { + debug!("DHCP sighting of known device {mac}; updating"); + // Keep a previously stored name (likely a richer hostname from mDNS) rather + // than overwriting it. + if recorded.name.is_some() { + device.name = recorded.name.clone(); + } + // A DISCOVER carries no assigned IP. Don't clobber a known good address with an + // empty string; reuse the recorded one when this packet has no IP hint. + if ip.is_none() { + device.ipv4_address = recorded.ipv4_address.clone(); + } + if let Err(err) = db::devices::seen( + device.mac_address.clone(), + device.ipv4_address.clone(), + device.vendor.clone(), + device.device_type.clone(), + device.name.clone(), + ) { + error!("Failed to update DHCP device {mac}: {err}"); + return; + } + // Ignoring errors: do not stop the listener if notification delivery fails + events::trigger_existing_device(recorded, device, DeviceEventScanner::Dhcp).ok(); + } + None => { + debug!("New device {mac} discovered via DHCP; inserting"); + if let Err(err) = db::devices::insert(device.clone()) { + error!("Failed to insert DHCP device {mac}: {err}"); + return; + } + events::trigger_new_device(device, DeviceEventScanner::Dhcp).ok(); + } + } + + status::record_discovery(); +} diff --git a/backend/src/scanners/dhcp/status.rs b/backend/src/scanners/dhcp/status.rs new file mode 100644 index 0000000..4d694b0 --- /dev/null +++ b/backend/src/scanners/dhcp/status.rs @@ -0,0 +1,105 @@ +use chrono::{DateTime, Utc}; +use once_cell::sync::OnceCell; +use std::sync::Mutex; + +pub struct DhcpScannerStatus { + pub is_listening: bool, + pub listening_since: Option>, + pub devices_discovered: u64, + pub last_discovery_at: Option>, +} + +#[derive(Clone)] +pub struct DhcpScannerStatusSnapshot { + pub is_listening: bool, + pub listening_since: Option>, + pub devices_discovered: u64, + pub last_discovery_at: Option>, +} + +static STATUS: OnceCell> = OnceCell::new(); + +pub fn init() { + STATUS + .set(Mutex::new(DhcpScannerStatus { + is_listening: false, + listening_since: None, + devices_discovered: 0, + last_discovery_at: None, + })) + .ok(); +} + +pub fn set_listening() { + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.is_listening = true; + s.listening_since = Some(Utc::now()); + } +} + +pub fn record_discovery() { + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.devices_discovered += 1; + s.last_discovery_at = Some(Utc::now()); + } +} + +pub fn get() -> Option { + STATUS.get().map(|m| { + let s = m.lock().unwrap(); + DhcpScannerStatusSnapshot { + is_listening: s.is_listening, + listening_since: s.listening_since, + devices_discovered: s.devices_discovered, + last_discovery_at: s.last_discovery_at, + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn reset_for_test() { + if let Some(m) = STATUS.get() { + let mut s = m.lock().unwrap(); + s.is_listening = false; + s.listening_since = None; + s.devices_discovered = 0; + s.last_discovery_at = None; + } else { + init(); + } + } + + #[test] + fn test_set_listening() { + reset_for_test(); + set_listening(); + let snapshot = get().unwrap(); + assert!(snapshot.is_listening); + assert!(snapshot.listening_since.is_some()); + } + + #[test] + fn test_record_discovery() { + reset_for_test(); + record_discovery(); + record_discovery(); + let snapshot = get().unwrap(); + assert_eq!(snapshot.devices_discovered, 2); + assert!(snapshot.last_discovery_at.is_some()); + } + + #[test] + fn test_initial_state() { + reset_for_test(); + let snapshot = get().unwrap(); + assert!(!snapshot.is_listening); + assert!(snapshot.listening_since.is_none()); + assert_eq!(snapshot.devices_discovered, 0); + assert!(snapshot.last_discovery_at.is_none()); + } +} diff --git a/backend/src/web_server.rs b/backend/src/web_server.rs index ae3797d..e433c75 100644 --- a/backend/src/web_server.rs +++ b/backend/src/web_server.rs @@ -6,6 +6,7 @@ use crate::model::notifications::{Notification, NotificationType}; use crate::settings::get_settings; use crate::web_server::arp_scanner::ArpScannerStatusResponse; use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload}; +use crate::web_server::dhcp_scanner::DhcpScannerStatusResponse; use crate::web_server::mdns_scanner::MdnsScannerStatusResponse; use crate::web_server::ssdp_scanner::SsdpScannerStatusResponse; use axum::Json; @@ -27,6 +28,7 @@ use utoipa_swagger_ui::SwaggerUi; pub mod arp_scanner; pub mod device_events; pub mod devices; +pub mod dhcp_scanner; pub mod mdns_scanner; pub mod notifications; pub mod ssdp_scanner; @@ -56,6 +58,7 @@ pub mod utils; arp_scanner::status, mdns_scanner::status, ssdp_scanner::status, + dhcp_scanner::status, ), components(schemas( Device, @@ -70,6 +73,7 @@ pub mod utils; ArpScannerStatusResponse, MdnsScannerStatusResponse, SsdpScannerStatusResponse, + DhcpScannerStatusResponse, )), modifiers(&SecurityAddon), tags( @@ -79,6 +83,7 @@ pub mod utils; (name = "arp_scanner", description = "ARP scanner process status"), (name = "mdns_scanner", description = "mDNS/Bonjour scanner process status"), (name = "ssdp_scanner", description = "SSDP/UPnP scanner process status"), + (name = "dhcp_scanner", description = "DHCP scanner process status"), ) )] struct ApiDoc; @@ -125,6 +130,7 @@ pub async fn serve() -> Result<(), Box> { .route("/api/arp_scanner/status", get(arp_scanner::status)) .route("/api/mdns_scanner/status", get(mdns_scanner::status)) .route("/api/ssdp_scanner/status", get(ssdp_scanner::status)) + .route("/api/dhcp_scanner/status", get(dhcp_scanner::status)) .route("/api/notifications", get(notifications::list)) .route( "/api/notifications/mark_all_as_old", diff --git a/backend/src/web_server/dhcp_scanner.rs b/backend/src/web_server/dhcp_scanner.rs new file mode 100644 index 0000000..9b28263 --- /dev/null +++ b/backend/src/web_server/dhcp_scanner.rs @@ -0,0 +1,57 @@ +use axum::{Json, http::StatusCode}; +use chrono::Utc; +use log::error; +use serde::Serialize; +use utoipa::ToSchema; + +#[derive(Serialize, ToSchema)] +pub struct DhcpScannerStatusResponse { + pub is_listening: bool, + /// Seconds the listener has been running (only set when is_listening is true) + pub listening_for_seconds: Option, + /// Total device requests processed since the listener started + pub devices_seen: u64, + /// Seconds since the last device was seen (None if none seen yet) + pub last_device_seen_seconds_ago: Option, +} + +#[utoipa::path( + get, + path = "/api/dhcp_scanner/status", + tag = "dhcp_scanner", + responses( + (status = 200, description = "DHCP scanner status", body = DhcpScannerStatusResponse), + (status = 500, description = "Internal server error"), + ), + security(("bearer_auth" = [])) +)] +pub async fn status() -> Result, StatusCode> { + let snapshot = match crate::scanners::dhcp::status::get() { + Some(s) => s, + None => { + error!("DHCP scanner status not initialized"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + let now = Utc::now(); + + let listening_for_seconds = if snapshot.is_listening { + snapshot + .listening_since + .map(|t| (now - t).num_milliseconds() as f64 / 1000.0) + } else { + None + }; + + let last_device_seen_seconds_ago = snapshot + .last_discovery_at + .map(|t| ((now - t).num_milliseconds() as f64 / 1000.0).max(0.0)); + + Ok(Json(DhcpScannerStatusResponse { + is_listening: snapshot.is_listening, + listening_for_seconds, + devices_seen: snapshot.devices_discovered, + last_device_seen_seconds_ago, + })) +}