Files
oott/backend/src/web_server/devices.rs
T
rzuastiandClaude Opus 4.7 ca83009944 Add passive mDNS/Bonjour discovery scanner
Introduce a second discovery module that passively listens for mDNS
multicast announcements (224.0.0.251:5353) and feeds discovered devices
into the existing devices/events/notifications pipeline, running in its
own task alongside the ARP scanner.

Since mDNS carries an IP and hostname but no MAC (the device key), the
module resolves IP->MAC via the OS ARP cache with a targeted ARP-probe
fallback. The advertised hostname is stored in a new optional `name`
column on devices (blank for ARP-only devices); the single `update`
writes `name` only when set so ARP rescans never clobber it. Device
names are included in event/notification messages.

Adds a GET /api/mdns_scanner/status endpoint (is_listening, devices_seen,
last_device_seen_seconds_ago) wired into OpenAPI, an optional
[mdns_scanner] config section, and the corresponding Nix module option.
Also aligns the Nix module's stale `timings` section with the current
`arp_scanner` schema.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 09:37:33 -04:00

210 lines
6.5 KiB
Rust

use std::collections::HashMap;
use axum::extract::Path;
use axum::response::IntoResponse;
use axum::{Json, extract::Query, http::StatusCode};
use chrono::{DateTime, Utc};
use log::{debug, error};
use serde::Deserialize;
use utoipa::ToSchema;
use crate::{
db,
model::devices::{Device, DeviceSummary},
};
use crate::web_server::utils;
#[utoipa::path(
get,
path = "/api/devices",
tag = "devices",
params(
("is_registered" = Option<bool>, Query, description = "Filter by registration status"),
("last_seen_from" = Option<String>, Query, description = "Filter devices seen after this datetime (RFC3339)"),
("last_seen_to" = Option<String>, Query, description = "Filter devices seen before this datetime (RFC3339)"),
("owner" = Option<String>, Query, description = "Filter by owner"),
("device_type" = Option<String>, Query, description = "Filter by device type"),
("vendor" = Option<String>, Query, description = "Filter by vendor"),
),
responses(
(status = 200, description = "List of devices", body = Vec<Device>),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list(
Query(params): Query<HashMap<String, String>>,
) -> Result<Json<Vec<Device>>, StatusCode> {
let is_registered: Option<bool> = utils::parse_parameter_bool(&params, "is_registered");
let last_seen_from: Option<DateTime<Utc>> =
utils::parse_parameter_date(&params, "last_seen_from");
let last_seen_to: Option<DateTime<Utc>> = utils::parse_parameter_date(&params, "last_seen_to");
let owner: Option<String> = utils::parse_parameter_string(&params, "owner");
let device_type: Option<String> = utils::parse_parameter_string(&params, "device_type");
let vendor: Option<String> = utils::parse_parameter_string(&params, "vendor");
match db::devices::list_devices(
is_registered,
last_seen_from,
last_seen_to,
owner,
device_type,
vendor,
) {
Ok(value) => Ok(Json(value)),
Err(err) => {
error!("Error listing devices: {}", err);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
#[utoipa::path(
get,
path = "/api/devices/{mac_address}",
tag = "devices",
params(
("mac_address" = String, Path, description = "MAC address of the device"),
),
responses(
(status = 200, description = "Device found", body = Device),
(status = 404, description = "Device not found"),
),
security(("bearer_auth" = []))
)]
pub async fn read(Path(mac_address): Path<String>) -> Result<Json<Device>, StatusCode> {
match db::devices::read(mac_address) {
Some(value) => Ok(Json(value)),
None => Err(StatusCode::NOT_FOUND),
}
}
#[utoipa::path(
put,
path = "/api/devices",
tag = "devices",
request_body = RegisterDevicePayload,
responses(
(status = 201, description = "Device registered"),
(status = 404, description = "Device not found"),
(status = 409, description = "Device already registered"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn register(Json(payload): Json<RegisterDevicePayload>) -> impl IntoResponse {
debug!(
"Device registration received: mac_address={}, owner={}, device_type={}",
payload.mac_address, payload.owner, payload.device_type
);
let mut device = match db::devices::read(payload.mac_address) {
Some(value) => value,
None => {
return (
axum::http::StatusCode::NOT_FOUND,
"Device not found or could not be read",
);
}
};
if device.is_registered {
return (
axum::http::StatusCode::CONFLICT,
"Device already registered",
);
}
device.is_registered = true;
device.owner = payload.owner;
device.device_type = payload.device_type;
match db::devices::update(device) {
Ok(_) => (axum::http::StatusCode::CREATED, "Device registered"),
Err(err) => {
error!("Error registering device in the database: {}", err);
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Error registering device in the server, check your logs",
)
}
}
}
#[utoipa::path(
delete,
path = "/api/devices/{mac_address}",
tag = "devices",
params(
("mac_address" = String, Path, description = "MAC address of the device"),
),
responses(
(status = 200, description = "Device unregistered"),
(status = 404, description = "Device not found"),
(status = 409, description = "Device is not registered"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn unregister(Path(mac_address): Path<String>) -> impl IntoResponse {
let mut device = match db::devices::read(mac_address) {
Some(value) => value,
None => {
return (
axum::http::StatusCode::NOT_FOUND,
"Device not found or could not be read",
);
}
};
if !device.is_registered {
return (
axum::http::StatusCode::CONFLICT,
"Device not registered, you cannot un-register it again",
);
}
device.is_registered = false;
device.owner = "".to_string();
match db::devices::update(device) {
Ok(_) => (axum::http::StatusCode::OK, "Device un-registered"),
Err(err) => {
error!("Error updating device in the database: {}", err);
(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Error updating device in the server, check your logs",
)
}
}
}
#[utoipa::path(
get,
path = "/api/devices/summary",
tag = "devices",
responses(
(status = 200, description = "Device summary counts", body = DeviceSummary),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn summary() -> Result<Json<DeviceSummary>, StatusCode> {
match db::devices::get_summary() {
Ok(value) => Ok(Json(value)),
Err(err) => {
error!("Error getting device summary: {}", err);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
// Payload structs
#[derive(Deserialize, ToSchema)]
pub struct RegisterDevicePayload {
mac_address: String,
owner: String,
device_type: String,
}