Files
oott/backend/src/web_server.rs
T

312 lines
11 KiB
Rust
Raw Normal View History

2026-02-26 08:31:48 -05:00
use std::error::Error;
use std::path::{Path, PathBuf};
2026-06-01 08:09:34 -04:00
use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventType};
use crate::model::devices::{Device, DeviceListResponse, DeviceSummary};
use crate::model::notifications::{Notification, NotificationListResponse, NotificationType};
2026-02-26 16:30:51 -05:00
use crate::settings::get_settings;
use crate::web_server::devices::{RegisterDevicePayload, UpdateDevicePayload};
use crate::web_server::scanner_status::{
ActiveScannerStatusResponse, PassiveScannerStatusResponse,
};
2026-05-29 09:37:33 -04:00
use axum::Json;
2026-02-26 12:19:58 -05:00
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{Redirect, Response};
use axum::routing::{delete, get, post, put};
use axum::{Router, http};
2026-02-26 16:30:51 -05:00
use log::{debug, error, info};
use tower::{Layer, ServiceBuilder};
use tower_http::cors::{Any, CorsLayer};
use tower_http::services::ServeDir;
use tower_http::set_header::{SetResponseHeader, SetResponseHeaderLayer};
2026-05-29 09:37:33 -04:00
use utoipa::Modify;
2026-05-27 13:36:05 -04:00
use utoipa::OpenApi;
use utoipa::openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme};
use utoipa_swagger_ui::SwaggerUi;
pub mod arp_scanner;
pub mod device_events;
2026-02-26 08:31:48 -05:00
pub mod devices;
2026-06-01 18:34:02 -04:00
pub mod dhcp_scanner;
2026-05-29 09:37:33 -04:00
pub mod mdns_scanner;
2026-02-26 08:31:48 -05:00
pub mod notifications;
pub mod scanner_status;
pub mod snmp_scanner;
2026-06-01 17:55:33 -04:00
pub mod ssdp_scanner;
2026-02-26 08:31:48 -05:00
pub mod utils;
2026-02-25 15:58:32 -05:00
2026-05-27 13:36:05 -04:00
#[derive(OpenApi)]
#[openapi(
info(
title = "OOTT API",
// version is intentionally omitted: utoipa fills it from the crate
// version (CARGO_PKG_VERSION, i.e. backend/Cargo.toml) automatically.
2026-05-27 13:36:05 -04:00
description = "Network monitoring and alert system API"
),
paths(
test_api,
devices::list,
devices::summary,
2026-05-27 13:36:05 -04:00
devices::read,
devices::register,
devices::update,
2026-05-27 13:36:05 -04:00
devices::unregister,
notifications::list,
notifications::read,
notifications::read_without_flagging,
notifications::mark_as_new,
notifications::mark_all_as_old,
device_events::list,
arp_scanner::status,
2026-05-29 09:37:33 -04:00
mdns_scanner::status,
2026-06-01 17:55:33 -04:00
ssdp_scanner::status,
2026-06-01 18:34:02 -04:00
dhcp_scanner::status,
snmp_scanner::status,
2026-05-27 13:36:05 -04:00
),
components(schemas(
Device,
DeviceListResponse,
DeviceSummary,
2026-05-27 13:36:05 -04:00
Notification,
NotificationListResponse,
2026-05-27 13:36:05 -04:00
NotificationType,
RegisterDevicePayload,
UpdateDevicePayload,
DeviceEvent,
DeviceEventType,
2026-06-01 08:09:34 -04:00
DeviceEventScanner,
ActiveScannerStatusResponse,
PassiveScannerStatusResponse,
2026-05-27 13:36:05 -04:00
)),
modifiers(&SecurityAddon),
tags(
(name = "devices", description = "Device management"),
(name = "notifications", description = "Notification management"),
(name = "device_events", description = "Device event history"),
(name = "arp_scanner", description = "ARP scanner process status"),
2026-05-29 09:37:33 -04:00
(name = "mdns_scanner", description = "mDNS/Bonjour scanner process status"),
2026-06-01 17:55:33 -04:00
(name = "ssdp_scanner", description = "SSDP/UPnP scanner process status"),
2026-06-01 18:34:02 -04:00
(name = "dhcp_scanner", description = "DHCP scanner process status"),
(name = "snmp_scanner", description = "SNMP scanner process status"),
2026-05-27 13:36:05 -04:00
)
)]
struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
let components = openapi.components.get_or_insert_with(Default::default);
components.add_security_scheme(
"bearer_auth",
2026-05-29 09:37:33 -04:00
SecurityScheme::Http(HttpBuilder::new().scheme(HttpAuthScheme::Bearer).build()),
2026-05-27 13:36:05 -04:00
);
}
}
/// Resolve the directory that holds the bundled front-end (Flutter web) assets.
///
/// The assets are installed next to the binary at `<exe_dir>/../share/oott/web`
/// (see the Nix package definition). When the executable location cannot be
/// determined we fall back to `./web` relative to the current directory.
fn resolve_web_root(exe_dir: Option<&Path>) -> PathBuf {
match exe_dir {
Some(dir) => dir.join("../share/oott/web"),
None => PathBuf::from("./web"),
}
}
/// Serve the bundled front-end assets with `Cache-Control: no-cache`.
///
/// Without an explicit directive the browser falls back to heuristic caching and
/// (together with the Flutter service worker) keeps serving stale assets after an
/// upgrade, so new images/icons never appear until the cache happens to expire.
/// `no-cache` does not disable caching: it forces a revalidation on every request,
/// which is a cheap `304 Not Modified` while files are unchanged and a fresh `200`
/// the moment they change. This guarantees users always see the latest assets.
fn web_asset_service(web_root: PathBuf) -> SetResponseHeader<ServeDir, http::HeaderValue> {
SetResponseHeaderLayer::overriding(
http::header::CACHE_CONTROL,
http::HeaderValue::from_static("no-cache"),
)
.layer(ServeDir::new(web_root))
}
pub async fn serve() -> Result<(), Box<dyn Error>> {
info!("Starting web server");
let exe = std::env::current_exe().ok();
let web_root = resolve_web_root(exe.as_deref().and_then(Path::parent));
info!("Serving front-end assets from {}", web_root.display());
let static_files = web_asset_service(web_root);
// Allow all origins and headers for API
let cors_layer = CorsLayer::new()
.allow_origin(Any)
.allow_methods([
http::Method::GET,
http::Method::POST,
http::Method::PUT,
http::Method::DELETE,
])
.allow_headers([http::header::AUTHORIZATION, http::header::CONTENT_TYPE]);
let router = Router::new()
.route("/api/test", get(test_api))
.route("/api/devices", get(devices::list))
.route("/api/devices", put(devices::register))
.route("/api/devices/summary", get(devices::summary))
.route("/api/devices/{mac_address}", delete(devices::unregister))
.route("/api/devices/{mac_address}", get(devices::read))
.route("/api/devices/{mac_address}", put(devices::update))
2026-05-29 09:37:33 -04:00
.route(
"/api/devices/{mac_address}/events",
get(device_events::list),
)
.route("/api/arp_scanner/status", get(arp_scanner::status))
2026-05-29 09:37:33 -04:00
.route("/api/mdns_scanner/status", get(mdns_scanner::status))
2026-06-01 17:55:33 -04:00
.route("/api/ssdp_scanner/status", get(ssdp_scanner::status))
2026-06-01 18:34:02 -04:00
.route("/api/dhcp_scanner/status", get(dhcp_scanner::status))
.route("/api/snmp_scanner/status", get(snmp_scanner::status))
.route("/api/notifications", get(notifications::list))
.route(
"/api/notifications/mark_all_as_old",
post(notifications::mark_all_as_old),
)
.route("/api/notifications/{id}", get(notifications::read))
.route(
"/api/notifications/{id}/read_without_flagging",
get(notifications::read_without_flagging),
)
.route(
"/api/notifications/{id}/mark_as_new",
post(notifications::mark_as_new),
)
2026-02-26 12:19:58 -05:00
.route_layer(axum::middleware::from_fn(auth))
.layer(ServiceBuilder::new().layer(cors_layer))
2026-06-06 16:11:38 -04:00
// Send visitors straight to the UI; the bare "/" has no content of its own.
.route("/", get(|| async { Redirect::temporary("/web") }))
// The API explorer lives at /api/docs; redirect the bare /api for convenience.
.route("/api", get(|| async { Redirect::temporary("/api/docs") }))
2026-05-27 13:36:05 -04:00
.nest_service("/web", static_files)
.merge(SwaggerUi::new("/api/docs").url("/api/docs/openapi.json", ApiDoc::openapi()));
2026-02-26 16:30:51 -05:00
let web_server_host_and_port = format!(
"{}:{}",
get_settings().web_server.ip_address,
get_settings().web_server.port
);
info!("Web server starting at http://{}", web_server_host_and_port);
// Start the server
2026-02-26 16:30:51 -05:00
let listener = tokio::net::TcpListener::bind(web_server_host_and_port).await?;
debug!("Web server bound to IP and port");
axum::serve(listener, router).await?;
Ok(())
}
2026-02-26 12:19:58 -05:00
2026-05-27 13:36:05 -04:00
#[utoipa::path(
get,
path = "/api/test",
responses(
(status = 200, description = "API is reachable", body = String),
),
security(("bearer_auth" = []))
)]
async fn test_api() -> Result<Json<String>, StatusCode> {
Ok(Json("OOTT_API_OK".to_string()))
}
2026-02-26 12:19:58 -05:00
async fn auth(request: Request, next: Next) -> Result<Response, StatusCode> {
let auth_header = request
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|header| header.to_str().ok());
let auth_header = match auth_header {
Some(value) => value,
None => return Err(StatusCode::UNAUTHORIZED),
};
let mut valid_header = "Bearer ".to_string();
2026-02-26 16:30:51 -05:00
let api_key = get_settings().web_server.api_key.as_str();
if api_key == "CHANGE_ME" {
error!("The configured api_key is still 'CHANGE_ME', please change it.");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
valid_header.push_str(api_key);
2026-02-26 12:19:58 -05:00
if auth_header == valid_header {
Ok(next.run(request).await)
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn web_root_is_relative_to_the_executable() {
let root = resolve_web_root(Some(Path::new("/nix/store/abc-oott/bin")));
assert_eq!(
root,
PathBuf::from("/nix/store/abc-oott/bin/../share/oott/web")
);
}
#[test]
fn web_root_falls_back_to_local_dir_without_an_executable() {
assert_eq!(resolve_web_root(None), PathBuf::from("./web"));
}
#[tokio::test]
async fn web_assets_are_served_with_no_cache() {
use axum::body::Body;
use tower::ServiceExt;
// A throwaway web root with a single asset to fetch back.
let web_root = std::env::temp_dir().join(format!(
"oott_web_asset_test_{}_{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&web_root).unwrap();
std::fs::write(web_root.join("asset.txt"), b"new-asset").unwrap();
let response = web_asset_service(web_root.clone())
.oneshot(
http::Request::builder()
.uri("/asset.txt")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
std::fs::remove_dir_all(&web_root).ok();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CACHE_CONTROL)
.expect("front-end assets must carry a Cache-Control header"),
"no-cache"
);
}
#[test]
fn openapi_version_tracks_the_crate_version() {
// The OpenAPI spec must report the crate version (backend/Cargo.toml)
// rather than a separately maintained literal.
let openapi = <ApiDoc as OpenApi>::openapi();
assert_eq!(openapi.info.version, env!("CARGO_PKG_VERSION"));
}
}