diff --git a/src/redfish/auth.rs b/src/redfish/auth.rs index cbac4de8..fc4489fc 100644 --- a/src/redfish/auth.rs +++ b/src/redfish/auth.rs @@ -80,3 +80,30 @@ fn decode_basic_auth(encoded: &str) -> Option<(String, String)> { } Some((username, password)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_service_discovery_and_session_creation_are_public() { + assert!(is_redfish_public_endpoint("/v1/", &Method::GET)); + assert!(is_redfish_public_endpoint( + "/v1/$metadata", + &Method::GET + )); + assert!(is_redfish_public_endpoint( + "/v1/SessionService/Sessions", + &Method::POST + )); + + assert!(!is_redfish_public_endpoint( + "/v1/Managers/1/VirtualMedia", + &Method::GET + )); + assert!(!is_redfish_public_endpoint( + "/v1/Managers/1/VirtualMedia/1/Actions/VirtualMedia.EjectMedia", + &Method::POST + )); + } +} diff --git a/src/redfish/routes/event.rs b/src/redfish/routes/event.rs index f4f4ae68..ba6d90af 100644 --- a/src/redfish/routes/event.rs +++ b/src/redfish/routes/event.rs @@ -55,7 +55,7 @@ async fn event_service() -> Json { } async fn event_service_sse(State(state): State>) -> Response { - use axum::response::sse::{Event, KeepAlive, Sse}; + use axum::response::sse::{Event, Sse}; let mut device_info_rx = state.subscribe_device_info(); @@ -87,15 +87,25 @@ async fn event_service_sse(State(state): State>) -> Response { }; Sse::new(Box::pin(stream)) - .keep_alive( - KeepAlive::new() - .interval(Duration::from_secs(30)) - .text(":\n"), - ) + .keep_alive(redfish_keep_alive()) .into_response() } +fn redfish_keep_alive() -> axum::response::sse::KeepAlive { + axum::response::sse::KeepAlive::new().interval(Duration::from_secs(30)) +} + async fn event_submit_test() -> StatusCode { info!("Redfish: SubmitTestEvent received (no-op)"); StatusCode::NO_CONTENT } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keep_alive_configuration_does_not_panic() { + let _ = redfish_keep_alive(); + } +} diff --git a/src/redfish/routes/mod.rs b/src/redfish/routes/mod.rs index 22006637..e08412cf 100644 --- a/src/redfish/routes/mod.rs +++ b/src/redfish/routes/mod.rs @@ -194,15 +194,16 @@ pub fn create_redfish_router(state: Arc) -> Router { .merge(managers::router(state.clone())) .merge(session::router(state.clone())) .merge(account::router(state.clone())) - .merge(event::router(state.clone())) - .layer(middleware::from_fn_with_state( - state.clone(), - redfish_auth_middleware, - )); + .merge(event::router(state.clone())); #[cfg(all(unix, not(feature = "android")))] let redfish_routes = redfish_routes.merge(virtual_media::router(state.clone())); + let redfish_routes = redfish_routes.layer(middleware::from_fn_with_state( + state.clone(), + redfish_auth_middleware, + )); + Router::new() .route("/redfish", get(service_root_redirect)) .nest("/redfish/", redfish_routes) diff --git a/src/redfish/routes/virtual_media.rs b/src/redfish/routes/virtual_media.rs index e937c332..b7e03488 100644 --- a/src/redfish/routes/virtual_media.rs +++ b/src/redfish/routes/virtual_media.rs @@ -5,12 +5,13 @@ use axum::{ routing::{get, post}, Json, Router, }; +use std::sync::Arc; use tracing::{info, warn}; -use std::sync::Arc; - use super::super::schema::*; -use super::{empty_collection, resource_not_found, service_unavailable, validate_id, RESOURCE_ID}; +use super::{empty_collection, resource_not_found, service_unavailable, validate_id}; +use crate::error::AppError; +use crate::msd::{ImageInfo, ImageManager, MountedMedia, MountedMediaKind}; use crate::state::AppState; pub(crate) fn router(state: Arc) -> Router> { @@ -34,21 +35,37 @@ pub(crate) fn router(state: Arc) -> Router> { .with_state(state) } -async fn virtual_media_collection(Path(manager_id): Path) -> Response { +async fn virtual_media_collection( + State(state): State>, + Path(manager_id): Path, +) -> Response { if let Some(resp) = validate_id(&manager_id) { return resp; } + let capacity = { + let guard = state.msd.read().await; + let Some(msd) = guard.as_ref() else { + return service_unavailable("MSD not available"); + }; + msd.state().await.disk_mode.capacity() + }; + let members = (1..=capacity) + .map(|slot| { + odata_ref(&format!( + "/redfish/v1/Managers/{}/VirtualMedia/{}", + manager_id, slot + )) + }) + .collect(); + Json(empty_collection( "#VirtualMediaCollection.VirtualMediaCollection", &format!("/redfish/v1/Managers/{}/VirtualMedia", manager_id), "/redfish/v1/$metadata#VirtualMediaCollection.VirtualMediaCollection", "Virtual Media Collection", "Collection of Virtual Media", - vec![odata_ref(&format!( - "/redfish/v1/Managers/{}/VirtualMedia/{}", - manager_id, RESOURCE_ID - ))], + members, )) .into_response() } @@ -60,50 +77,58 @@ async fn virtual_media_detail( if let Some(resp) = validate_id(&manager_id) { return resp; } - if media_id != RESOURCE_ID { - return resource_not_found(); - } - let (inserted, image_name, connected_via) = { + let (msd_state, lun) = { let guard = state.msd.read().await; - match guard.as_ref() { - Some(msd) => { - let msd_state = msd.state().await; - let img_name = msd_state - .mounted_media - .first() - .map(|media| media.name.clone()); - ( - !msd_state.mounted_media.is_empty(), - img_name, - if !msd_state.mounted_media.is_empty() { - Some("Applet".to_string()) - } else { - None - }, - ) - } - None => (false, None, None), - } + let Some(msd) = guard.as_ref() else { + return service_unavailable("MSD not available"); + }; + let msd_state = msd.state().await; + let Some(lun) = parse_slot_id(&media_id, msd_state.disk_mode.capacity()) else { + return resource_not_found(); + }; + (msd_state, lun) + }; + let media = msd_state + .mounted_media + .iter() + .find(|media| media.lun == lun); + + Json(virtual_media_resource(&manager_id, &media_id, media)).into_response() +} + +fn virtual_media_resource( + manager_id: &str, + media_id: &str, + media: Option<&MountedMedia>, +) -> VirtualMedia { + let inserted = media.is_some(); + let is_image = media.is_some_and(|media| media.kind == MountedMediaKind::Image); + let media_types = match media { + Some(media) if media.cdrom => vec!["CD".to_string(), "DVD".to_string()], + Some(_) => vec!["USBStick".to_string()], + None => vec!["CD".to_string(), "DVD".to_string(), "USBStick".to_string()], }; - Json(VirtualMedia { + VirtualMedia { odata_type: "#VirtualMedia.v1_6_2.VirtualMedia".to_string(), odata_id: format!( "/redfish/v1/Managers/{}/VirtualMedia/{}", manager_id, media_id ), odata_context: "/redfish/v1/$metadata#VirtualMedia.VirtualMedia".to_string(), - id: media_id.clone(), - name: "Virtual Media 1".to_string(), - description: "Virtual Media Device".to_string(), - media_types: vec!["CD".to_string(), "USBStick".to_string()], - connected_via: connected_via, - inserted: inserted, - image: None, - image_name: image_name, - write_protected: true, - transfer_method: None, + id: media_id.to_string(), + name: format!("Virtual Media Slot {}", media_id), + description: "Virtual Media Slot".to_string(), + media_types, + connected_via: media.map(|_| if is_image { "URI" } else { "Applet" }.to_string()), + inserted, + image: media + .filter(|_| is_image) + .map(|media| format!("/api/msd/images/{}", media.id)), + image_name: media.map(|media| media.name.clone()), + write_protected: media.is_none_or(|media| media.read_only), + transfer_method: is_image.then(|| "Upload".to_string()), transfer_protocol_type: None, status: if inserted { Status::enabled_ok() @@ -124,8 +149,7 @@ async fn virtual_media_detail( ), }, }, - }) - .into_response() + } } async fn virtual_media_insert( @@ -136,43 +160,53 @@ async fn virtual_media_insert( if let Some(resp) = validate_id(&manager_id) { return resp; } - if media_id != RESOURCE_ID { - return resource_not_found(); + + let lun = { + let guard = state.msd.read().await; + let Some(msd) = guard.as_ref() else { + return service_unavailable("MSD not available"); + }; + let msd_state = msd.state().await; + let Some(lun) = parse_slot_id(&media_id, msd_state.disk_mode.capacity()) else { + return resource_not_found(); + }; + if msd_state.mounted_media.iter().any(|media| media.lun == lun) { + return redfish_error( + StatusCode::CONFLICT, + "Virtual media slot is already occupied", + ); + } + lun + }; + + if let Err(error) = validate_insert_request(&req) { + return app_error_response(error); } + let image = match resolve_image(&state, &req).await { + Ok(image) => image, + Err(error) => return app_error_response(error), + }; + let (cdrom, read_only) = match mount_options(&req, &image.name) { + Ok(options) => options, + Err(error) => return app_error_response(error), + }; let result = { let guard = state.msd.read().await; - let msd = match guard.as_ref() { - Some(msd) => msd, - None => return service_unavailable("MSD not available"), + let Some(msd) = guard.as_ref() else { + return service_unavailable("MSD not available"); }; - - if !msd.state().await.mounted_media.is_empty() { - return ( - StatusCode::CONFLICT, - Json(RedfishError::general_error( - "Virtual media already inserted", - )), - ) - .into_response(); - } - - info!("Redfish: VirtualMedia.InsertMedia image='{}'", req.image); - msd.mount_drive().await + msd.mount_image_at_lun(&image, cdrom, read_only, lun).await }; match result { Ok(()) => { - info!("Redfish: VirtualMedia.InsertMedia executed"); + info!(slot = %media_id, image = %image.name, "Redfish virtual media inserted"); StatusCode::NO_CONTENT.into_response() } - Err(e) => { - warn!("Redfish: VirtualMedia.InsertMedia failed: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(RedfishError::general_error(&e.to_string())), - ) - .into_response() + Err(error) => { + warn!(slot = %media_id, %error, "Redfish virtual media insert failed"); + app_error_response(error) } } } @@ -184,40 +218,207 @@ async fn virtual_media_eject( if let Some(resp) = validate_id(&manager_id) { return resp; } - if media_id != RESOURCE_ID { - return resource_not_found(); - } + + let lun = { + let guard = state.msd.read().await; + let Some(msd) = guard.as_ref() else { + return service_unavailable("MSD not available"); + }; + let capacity = msd.state().await.disk_mode.capacity(); + let Some(lun) = parse_slot_id(&media_id, capacity) else { + return resource_not_found(); + }; + lun + }; let result = { let guard = state.msd.read().await; - let msd = match guard.as_ref() { - Some(msd) => msd, - None => return service_unavailable("MSD not available"), + let Some(msd) = guard.as_ref() else { + return service_unavailable("MSD not available"); }; - - if msd.state().await.mounted_media.is_empty() { - return ( - StatusCode::CONFLICT, - Json(RedfishError::general_error("No virtual media inserted")), - ) - .into_response(); - } - - msd.disconnect().await + msd.unmount_lun(lun).await }; match result { - Ok(()) => { - info!("Redfish: VirtualMedia.EjectMedia executed"); + Ok(true) => { + info!(slot = %media_id, "Redfish virtual media ejected"); StatusCode::NO_CONTENT.into_response() } - Err(e) => { - warn!("Redfish: VirtualMedia.EjectMedia failed: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(RedfishError::general_error(&e.to_string())), - ) - .into_response() + Ok(false) => redfish_error( + StatusCode::CONFLICT, + "No virtual media inserted in this slot", + ), + Err(error) => { + warn!(slot = %media_id, %error, "Redfish virtual media eject failed"); + app_error_response(error) } } } + +fn parse_slot_id(media_id: &str, capacity: u8) -> Option { + media_id + .parse::() + .ok()? + .checked_sub(1) + .filter(|lun| *lun < capacity) +} + +fn validate_insert_request(req: &InsertMediaRequest) -> Result<(), AppError> { + if req.inserted == Some(false) { + return Err(AppError::BadRequest( + "Inserted=false is not supported".to_string(), + )); + } + if req + .transfer_method + .as_deref() + .is_some_and(|method| !method.eq_ignore_ascii_case("Upload")) + { + return Err(AppError::BadRequest( + "Only TransferMethod=Upload is supported".to_string(), + )); + } + Ok(()) +} + +fn mount_options(req: &InsertMediaRequest, image_name: &str) -> Result<(bool, bool), AppError> { + let requested_type = req + .media_types + .as_ref() + .and_then(|types| types.first()) + .map(|value| value.as_str()); + let cdrom = match requested_type { + Some(value) if value.eq_ignore_ascii_case("CD") || value.eq_ignore_ascii_case("DVD") => { + true + } + Some(value) if value.eq_ignore_ascii_case("USBStick") => false, + Some(value) => { + return Err(AppError::BadRequest(format!( + "Unsupported virtual media type: {value}" + ))) + } + None => image_name.to_ascii_lowercase().ends_with(".iso"), + }; + + Ok((cdrom, cdrom || req.write_protected.unwrap_or(true))) +} + +async fn resolve_image( + state: &Arc, + req: &InsertMediaRequest, +) -> Result { + if req.user_name.is_some() || req.password.is_some() { + return Err(AppError::BadRequest( + "Authenticated virtual media URIs are not supported".to_string(), + )); + } + + let config = state.config.get(); + let manager = ImageManager::new(config.msd.images_dir()); + if req.image.starts_with("http://") || req.image.starts_with("https://") { + if let Some(protocol) = req.transfer_protocol_type.as_deref() { + let expected = if req.image.starts_with("https://") { + "HTTPS" + } else { + "HTTP" + }; + if !protocol.eq_ignore_ascii_case(expected) { + return Err(AppError::BadRequest(format!( + "TransferProtocolType must be {expected} for this Image URI" + ))); + } + } + return manager.download_from_url(&req.image, None, |_, _| {}).await; + } + if req.transfer_protocol_type.is_some() { + return Err(AppError::BadRequest( + "TransferProtocolType is only valid for remote Image URIs".to_string(), + )); + } + + let image_id = req + .image + .strip_prefix("/api/msd/images/") + .unwrap_or(&req.image) + .split(['?', '#']) + .next() + .unwrap_or_default(); + if image_id.is_empty() || image_id.contains('/') { + return Err(AppError::BadRequest( + "Image must be an HTTP(S) URI, image ID, or /api/msd/images/{id}".to_string(), + )); + } + manager.get(image_id) +} + +fn app_error_response(error: AppError) -> Response { + let status = match &error { + AppError::BadRequest(_) => StatusCode::BAD_REQUEST, + AppError::NotFound(_) => StatusCode::NOT_FOUND, + AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + redfish_error(status, &error.to_string()) +} + +fn redfish_error(status: StatusCode, message: &str) -> Response { + (status, Json(RedfishError::general_error(message))).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request(image: &str) -> InsertMediaRequest { + InsertMediaRequest { + image: image.to_string(), + write_protected: None, + transfer_method: None, + transfer_protocol_type: None, + media_types: None, + inserted: None, + user_name: None, + password: None, + } + } + + #[test] + fn slot_ids_map_to_zero_based_luns() { + assert_eq!(parse_slot_id("1", 1), Some(0)); + assert_eq!(parse_slot_id("8", 8), Some(7)); + assert_eq!(parse_slot_id("0", 8), None); + assert_eq!(parse_slot_id("2", 1), None); + assert_eq!(parse_slot_id("invalid", 8), None); + } + + #[test] + fn mount_options_follow_media_type_and_redfish_write_protect_default() { + assert_eq!( + mount_options(&request("opaque-id"), "image.iso").unwrap(), + (true, true) + ); + assert_eq!( + mount_options(&request("opaque-id"), "image.img").unwrap(), + (false, true) + ); + + let mut writable = request("image.img"); + writable.write_protected = Some(false); + writable.media_types = Some(vec!["USBStick".to_string()]); + assert_eq!( + mount_options(&writable, "image.img").unwrap(), + (false, false) + ); + } + + #[test] + fn stream_transfer_and_non_inserted_media_are_rejected() { + let mut stream = request("image.iso"); + stream.transfer_method = Some("Stream".to_string()); + assert!(validate_insert_request(&stream).is_err()); + + let mut not_inserted = request("image.iso"); + not_inserted.inserted = Some(false); + assert!(validate_insert_request(¬_inserted).is_err()); + } +} diff --git a/src/web/error.rs b/src/web/error.rs index 83fa4063..8ecc8b3c 100644 --- a/src/web/error.rs +++ b/src/web/error.rs @@ -14,6 +14,7 @@ pub struct ErrorResponse { impl IntoResponse for AppError { fn into_response(self) -> Response { + let status = status_code(&self); let body = ErrorResponse { success: false, message: self.to_string(), @@ -25,7 +26,49 @@ impl IntoResponse for AppError { "Request failed" ); - // Always return 200 OK - success/failure is indicated by the success field - (StatusCode::OK, Json(body)).into_response() + (status, Json(body)).into_response() + } +} + +fn status_code(error: &AppError) -> StatusCode { + match error { + AppError::AuthError(_) | AppError::Unauthorized => StatusCode::UNAUTHORIZED, + AppError::BadRequest(_) => StatusCode::BAD_REQUEST, + AppError::NotFound(_) => StatusCode::NOT_FOUND, + AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_client_and_availability_errors_to_http_statuses() { + assert_eq!( + status_code(&AppError::BadRequest("invalid".to_string())), + StatusCode::BAD_REQUEST + ); + assert_eq!( + status_code(&AppError::AuthError("invalid".to_string())), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + status_code(&AppError::NotFound("missing".to_string())), + StatusCode::NOT_FOUND + ); + assert_eq!( + status_code(&AppError::ServiceUnavailable("offline".to_string())), + StatusCode::SERVICE_UNAVAILABLE + ); + } + + #[test] + fn maps_internal_errors_to_server_error() { + assert_eq!( + status_code(&AppError::Internal("failed".to_string())), + StatusCode::INTERNAL_SERVER_ERROR + ); } }