From 4e8c3429057eb803c94af4200adebbe8661cf7a1 Mon Sep 17 00:00:00 2001 From: Fucheng Sha Date: Tue, 12 May 2026 10:45:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Redfish=20API=20?= =?UTF-8?q?=E6=A0=87=E5=87=86=E6=8E=A5=E5=8F=A3=EF=BC=9B=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=80=9A=E8=BF=87=E5=89=8D=E7=AB=AF=E5=BC=80=E5=85=B3=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=20Redfish=20=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 2 +- src/config/schema.rs | 17 + src/lib.rs | 1 + src/redfish/auth.rs | 84 ++++ src/redfish/mod.rs | 3 + src/redfish/routes/account.rs | 155 +++++++ src/redfish/routes/chassis.rs | 92 ++++ src/redfish/routes/event.rs | 102 +++++ src/redfish/routes/managers.rs | 120 ++++++ src/redfish/routes/mod.rs | 211 ++++++++++ src/redfish/routes/session.rs | 160 +++++++ src/redfish/routes/systems.rs | 220 ++++++++++ src/redfish/routes/virtual_media.rs | 224 ++++++++++ src/redfish/schema.rs | 627 ++++++++++++++++++++++++++++ src/web/handlers/config/mod.rs | 2 + src/web/handlers/config/redfish.rs | 29 ++ src/web/handlers/config/types.rs | 18 + src/web/routes.rs | 21 +- web/src/api/config.ts | 18 + web/src/api/index.ts | 3 + web/src/i18n/en-US.ts | 4 + web/src/i18n/zh-CN.ts | 4 + web/src/views/SettingsView.vue | 58 ++- 23 files changed, 2170 insertions(+), 5 deletions(-) create mode 100644 src/redfish/auth.rs create mode 100644 src/redfish/mod.rs create mode 100644 src/redfish/routes/account.rs create mode 100644 src/redfish/routes/chassis.rs create mode 100644 src/redfish/routes/event.rs create mode 100644 src/redfish/routes/managers.rs create mode 100644 src/redfish/routes/mod.rs create mode 100644 src/redfish/routes/session.rs create mode 100644 src/redfish/routes/systems.rs create mode 100644 src/redfish/routes/virtual_media.rs create mode 100644 src/redfish/schema.rs create mode 100644 src/web/handlers/config/redfish.rs diff --git a/Cargo.toml b/Cargo.toml index c2b3e860..ddb2921f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ tokio-util = { version = "0.7", features = ["rt"] } # Web framework axum = { version = "0.8", features = ["ws", "multipart", "tokio"] } axum-extra = { version = "0.12", features = ["cookie"] } -tower-http = { version = "0.6", features = ["cors", "trace"] } +tower-http = { version = "0.6", features = ["cors", "trace", "set-header"] } # Database - Use bundled SQLite for static linking sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } diff --git a/src/config/schema.rs b/src/config/schema.rs index 49575fdc..6dadbeb1 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -110,6 +110,8 @@ pub struct AppConfig { pub rustdesk: RustDeskConfig, /// RTSP streaming settings pub rtsp: RtspConfig, + /// Redfish API settings + pub redfish: RedfishConfig, } /// Authentication configuration @@ -808,3 +810,18 @@ impl Default for WebConfig { } } } + +/// Redfish API configuration +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct RedfishConfig { + /// Enable Redfish API endpoint + pub enabled: bool, +} + +impl Default for RedfishConfig { + fn default() -> Self { + Self { enabled: false } + } +} diff --git a/src/lib.rs b/src/lib.rs index 4b2d04f5..4ded12c1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ pub mod extensions; pub mod hid; pub mod msd; pub mod otg; +pub mod redfish; pub mod rtsp; pub mod rustdesk; pub mod state; diff --git a/src/redfish/auth.rs b/src/redfish/auth.rs new file mode 100644 index 00000000..b1f61b35 --- /dev/null +++ b/src/redfish/auth.rs @@ -0,0 +1,84 @@ +use axum::{ + extract::{Request, State}, + http::{header, Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, +}; +use base64::Engine; +use std::sync::Arc; + +use super::schema::RedfishError; +use crate::state::AppState; + +pub async fn redfish_auth_middleware( + State(state): State>, + mut request: Request, + next: Next, +) -> Response { + if !state.config.is_initialized() { + let body = RedfishError::service_unavailable("System not initialized"); + return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(body)).into_response(); + } + + let path = request.uri().path(); + if is_redfish_public_endpoint(path, request.method()) { + return next.run(request).await; + } + + if let Some(token) = request.headers().get("X-Auth-Token") { + if let Ok(token_str) = token.to_str() { + if state.is_session_revoked(token_str).await { + let body = RedfishError::invalid_credentials(); + return (StatusCode::UNAUTHORIZED, axum::Json(body)).into_response(); + } + if let Ok(Some(session)) = state.sessions.get(token_str).await { + request.extensions_mut().insert(session); + return next.run(request).await; + } + } + } + + if let Some(auth_header) = request.headers().get(header::AUTHORIZATION) { + if let Ok(auth_str) = auth_header.to_str() { + if let Some(credentials) = auth_str.strip_prefix("Basic ") { + if let Some((username, password)) = decode_basic_auth(credentials) { + match state.users.verify(&username, &password).await { + Ok(Some(user)) => { + request.extensions_mut().insert(user); + return next.run(request).await; + } + _ => { + let body = RedfishError::invalid_credentials(); + return (StatusCode::UNAUTHORIZED, axum::Json(body)).into_response(); + } + } + } + } + } + } + + let body = RedfishError::authentication_required(); + (StatusCode::UNAUTHORIZED, axum::Json(body)).into_response() +} + +fn is_redfish_public_endpoint(path: &str, method: &Method) -> bool { + matches!( + path, + "/" | "/v1" | "/v1/" | "/v1/odata" + ) || path.starts_with("/v1/$metadata") + || (path == "/v1/SessionService/Sessions" && *method == Method::POST) +} + +fn decode_basic_auth(encoded: &str) -> Option<(String, String)> { + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + let credentials = String::from_utf8(decoded).ok()?; + let mut parts = credentials.splitn(2, ':'); + let username = parts.next()?.to_string(); + let password = parts.next()?.to_string(); + if username.is_empty() { + return None; + } + Some((username, password)) +} diff --git a/src/redfish/mod.rs b/src/redfish/mod.rs new file mode 100644 index 00000000..d537a616 --- /dev/null +++ b/src/redfish/mod.rs @@ -0,0 +1,3 @@ +pub mod auth; +pub mod routes; +pub mod schema; diff --git a/src/redfish/routes/account.rs b/src/redfish/routes/account.rs new file mode 100644 index 00000000..c49253db --- /dev/null +++ b/src/redfish/routes/account.rs @@ -0,0 +1,155 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; + +use std::sync::Arc; + +use super::{empty_collection, resource_not_found}; +use super::super::schema::*; +use crate::state::AppState; + +pub(crate) fn router(state: Arc) -> Router> { + Router::new() + .route("/v1/AccountService", get(account_service)) + .route( + "/v1/AccountService/Accounts", + get(account_list), + ) + .route( + "/v1/AccountService/Accounts/{account_id}", + get(account_detail), + ) + .route( + "/v1/AccountService/Roles", + get(roles_stub), + ) + .route( + "/v1/AccountService/Roles/{role_id}", + get(role_detail_stub), + ) + .with_state(state) +} + +async fn account_service() -> Json { + Json(AccountService { + odata_type: "#AccountService.v1_13_0.AccountService".to_string(), + odata_id: "/redfish/v1/AccountService".to_string(), + odata_context: "/redfish/v1/$metadata#AccountService.AccountService".to_string(), + id: "AccountService".to_string(), + name: "Account Service".to_string(), + description: "Account Service".to_string(), + service_enabled: true, + accounts: odata_ref("/redfish/v1/AccountService/Accounts"), + roles: odata_ref("/redfish/v1/AccountService/Roles"), + }) +} + +async fn account_list(State(state): State>) -> Response { + let user = match state.users.single_user().await { + Ok(Some(u)) => u, + Ok(None) => { + return Json(empty_collection( + "#ManagerAccountCollection.ManagerAccountCollection", + "/redfish/v1/AccountService/Accounts", + "/redfish/v1/$metadata#ManagerAccountCollection.ManagerAccountCollection", + "Accounts Collection", + "Collection of Accounts", + vec![], + )) + .into_response() + } + Err(e) => { + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(RedfishError::general_error(&e.to_string())), + ) + .into_response() + } + }; + + Json(empty_collection( + "#ManagerAccountCollection.ManagerAccountCollection", + "/redfish/v1/AccountService/Accounts", + "/redfish/v1/$metadata#ManagerAccountCollection.ManagerAccountCollection", + "Accounts Collection", + "Collection of Accounts", + vec![odata_ref(&format!("/redfish/v1/AccountService/Accounts/{}", user.id))], + )) + .into_response() +} + +async fn account_detail( + State(state): State>, + Path(account_id): Path, +) -> Response { + let user = match state.users.single_user().await { + Ok(Some(u)) => u, + Ok(None) => return resource_not_found(), + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(RedfishError::general_error(&e.to_string())), + ) + .into_response() + } + }; + + if user.id != account_id { + return resource_not_found(); + } + + Json(ManagerAccount { + odata_type: "#ManagerAccount.v1_12_0.ManagerAccount".to_string(), + odata_id: format!("/redfish/v1/AccountService/Accounts/{}", user.id), + odata_context: "/redfish/v1/$metadata#ManagerAccount.ManagerAccount".to_string(), + id: user.id, + name: format!("Account {}", user.username), + description: "User Account".to_string(), + enabled: true, + user_name: user.username, + role_id: "Administrator".to_string(), + locked: false, + links: ManagerAccountLinks { + role: odata_ref("/redfish/v1/AccountService/Roles/Administrator"), + }, + }) + .into_response() +} + +async fn roles_stub() -> Json { + Json(serde_json::json!({ + "@odata.type": "#RoleCollection.RoleCollection", + "@odata.id": "/redfish/v1/AccountService/Roles", + "@odata.context": "/redfish/v1/$metadata#RoleCollection.RoleCollection", + "Name": "Roles Collection", + "Description": "Collection of Roles", + "Members@odata.count": 1, + "Members": [{ "@odata.id": "/redfish/v1/AccountService/Roles/Administrator" }] + })) +} + +async fn role_detail_stub(Path(role_id): Path) -> Response { + if role_id != "Administrator" { + return resource_not_found(); + } + + Json(serde_json::json!({ + "@odata.type": "#Role.v1_3_1.Role", + "@odata.id": "/redfish/v1/AccountService/Roles/Administrator", + "@odata.context": "/redfish/v1/$metadata#Role.Role", + "Id": "Administrator", + "Name": "Administrator Role", + "Description": "Administrator role with full access", + "IsPredefined": true, + "AssignedPrivileges": [ + "Login", "ConfigureManager", "ConfigureUsers", + "ConfigureSelf", "ConfigureComponents" + ], + "OemPrivileges": [] + })) + .into_response() +} diff --git a/src/redfish/routes/chassis.rs b/src/redfish/routes/chassis.rs new file mode 100644 index 00000000..6e02d582 --- /dev/null +++ b/src/redfish/routes/chassis.rs @@ -0,0 +1,92 @@ +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; + +use std::sync::Arc; + +use super::{empty_collection, get_power_state, validate_id, RESOURCE_ID}; +use super::super::schema::*; +use crate::state::AppState; + +pub(crate) fn router(state: Arc) -> Router> { + Router::new() + .route("/v1/Chassis", get(chassis_collection)) + .route("/v1/Chassis/{chassis_id}", get(chassis_detail)) + .route("/v1/Chassis/{chassis_id}/Power", get(chassis_power)) + .with_state(state) +} + +async fn chassis_collection() -> Json> { + Json(empty_collection( + "#ChassisCollection.ChassisCollection", + "/redfish/v1/Chassis", + "/redfish/v1/$metadata#ChassisCollection.ChassisCollection", + "Chassis Collection", + "Collection of Chassis", + vec![odata_ref("/redfish/v1/Chassis/1")], + )) +} + +async fn chassis_detail( + State(state): State>, + Path(chassis_id): Path, +) -> Response { + if let Some(resp) = validate_id(&chassis_id) { + return resp; + } + + let power_state = get_power_state(&state).await; + + Json(Chassis { + odata_type: "#Chassis.v1_25_0.Chassis".to_string(), + odata_id: format!("/redfish/v1/Chassis/{}", chassis_id), + odata_context: "/redfish/v1/$metadata#Chassis.Chassis".to_string(), + id: chassis_id.clone(), + name: "One-KVM Chassis".to_string(), + description: "The physical chassis managed by One-KVM".to_string(), + chassis_type: "RackMount".to_string(), + asset_tag: String::new(), + manufacturer: "One-KVM".to_string(), + model: "Virtual".to_string(), + serial_number: String::new(), + part_number: String::new(), + power_state: power_state.to_string(), + status: Status::enabled_ok(), + power: odata_ref(&format!("/redfish/v1/Chassis/{}/Power", chassis_id)), + links: ChassisLinks { + computer_systems: vec![odata_ref(&format!("/redfish/v1/Systems/{}", RESOURCE_ID))], + managed_by: vec![odata_ref(&format!("/redfish/v1/Managers/{}", RESOURCE_ID))], + }, + }) + .into_response() +} + +async fn chassis_power( + Path(chassis_id): Path, +) -> Response { + if let Some(resp) = validate_id(&chassis_id) { + return resp; + } + + Json(Power { + odata_type: "#Power.v1_7_3.Power".to_string(), + odata_id: format!("/redfish/v1/Chassis/{}/Power", chassis_id), + odata_context: "/redfish/v1/$metadata#Power.Power".to_string(), + id: "Power".to_string(), + name: "Power".to_string(), + power_control: vec![PowerControl { + odata_id: format!("/redfish/v1/Chassis/{}/Power#/PowerControl/0", chassis_id), + member_id: "0".to_string(), + name: "System Power Control".to_string(), + power_consumed_watts: None, + power_capacity_watts: None, + power_requested_watts: None, + power_metrics: None, + status: Status::enabled_ok(), + }], + }) + .into_response() +} diff --git a/src/redfish/routes/event.rs b/src/redfish/routes/event.rs new file mode 100644 index 00000000..1d1d64ca --- /dev/null +++ b/src/redfish/routes/event.rs @@ -0,0 +1,102 @@ +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde_json::json; +use std::{convert::Infallible, sync::Arc, time::Duration}; +use tracing::info; + +use super::super::schema::*; +use crate::state::AppState; + +pub(crate) fn router(state: Arc) -> Router> { + Router::new() + .route("/v1/EventService", get(event_service)) + .route("/v1/EventService/SSE", get(event_service_sse)) + .route( + "/v1/EventService/Actions/EventService.SubmitTestEvent", + post(event_submit_test), + ) + .with_state(state) +} + +async fn event_service() -> Json { + Json(EventService { + odata_type: "#EventService.v1_8_1.EventService".to_string(), + odata_id: "/redfish/v1/EventService".to_string(), + odata_context: "/redfish/v1/$metadata#EventService.EventService".to_string(), + id: "EventService".to_string(), + name: "Event Service".to_string(), + description: "Event Service".to_string(), + service_enabled: true, + delivery_retry_attempts: 3, + delivery_retry_interval_seconds: 30, + event_format_types: vec!["Event".to_string(), "MetricReport".to_string()], + registry_prefixes: vec!["Base".to_string()], + subordinate_resources: true, + sse_filter_properties_supported: SseFilterPropertiesSupported { + event_format_type: false, + message_id: false, + metric_report_definition: false, + origin_resource: false, + registry_prefix: false, + resource_type: false, + }, + server_sent_event_uri: Some("/redfish/v1/EventService/SSE".to_string()), + actions: EventServiceActions { + submit_test_event: ActionTarget { + target: "/redfish/v1/EventService/Actions/EventService.SubmitTestEvent" + .to_string(), + }, + }, + }) +} + +async fn event_service_sse(State(state): State>) -> Response { + use axum::response::sse::{Event, KeepAlive, Sse}; + + let mut device_info_rx = state.subscribe_device_info(); + + let stream = async_stream::stream! { + loop { + match device_info_rx.changed().await { + Ok(()) => { + let payload = json!({ + "@odata.type": "#Event.v1_7_0.Event", + "Id": uuid::Uuid::new_v4().to_string(), + "Name": "One-KVM Event", + "Context": "One-KVM", + "Events": [{ + "EventType": "ResourceUpdated", + "EventId": uuid::Uuid::new_v4().to_string(), + "Severity": "OK", + "Message": "Device state updated", + "MessageId": "ResourceUpdated.1.0.0.ResourceUpdated" + }] + }); + + let event = Event::default() + .data(serde_json::to_string(&payload).unwrap_or_default()); + yield Ok::<_, Infallible>(event); + } + Err(_) => break, + } + } + }; + + Sse::new(Box::pin(stream)) + .keep_alive( + KeepAlive::new() + .interval(Duration::from_secs(30)) + .text(":\n"), + ) + .into_response() +} + +async fn event_submit_test() -> StatusCode { + info!("Redfish: SubmitTestEvent received (no-op)"); + StatusCode::NO_CONTENT +} diff --git a/src/redfish/routes/managers.rs b/src/redfish/routes/managers.rs new file mode 100644 index 00000000..a6d0df72 --- /dev/null +++ b/src/redfish/routes/managers.rs @@ -0,0 +1,120 @@ +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; + +use std::sync::Arc; + +use super::{empty_collection, validate_id, RESOURCE_ID}; +use super::super::schema::*; +use crate::state::AppState; + +pub(crate) fn router(state: Arc) -> Router> { + Router::new() + .route("/v1/Managers", get(managers_collection)) + .route("/v1/Managers/{manager_id}", get(manager_detail)) + .route( + "/v1/Managers/{manager_id}/NetworkProtocol", + get(network_protocol), + ) + .with_state(state) +} + +async fn managers_collection() -> Json> { + Json(empty_collection( + "#ManagerCollection.ManagerCollection", + "/redfish/v1/Managers", + "/redfish/v1/$metadata#ManagerCollection.ManagerCollection", + "Manager Collection", + "Collection of Managers", + vec![odata_ref("/redfish/v1/Managers/1")], + )) +} + +async fn manager_detail( + State(state): State>, + Path(manager_id): Path, +) -> Response { + if let Some(resp) = validate_id(&manager_id) { + return resp; + } + + let now = time::OffsetDateTime::now_utc(); + let datetime = now + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_default(); + let offset = now.offset(); + let offset_str = format!( + "{:+03}{:02}", + offset.whole_hours(), + offset.minutes_past_hour().abs() + ); + + let mgr_uuid = "00000000-0000-0000-0000-000000000001".to_string(); + + Json(Manager { + odata_type: "#Manager.v1_15_0.Manager".to_string(), + odata_id: format!("/redfish/v1/Managers/{}", manager_id), + odata_context: "/redfish/v1/$metadata#Manager.Manager".to_string(), + id: manager_id.clone(), + name: "One-KVM Manager".to_string(), + description: "One-KVM Management Controller".to_string(), + manager_type: "BMC".to_string(), + status: Status::enabled_ok(), + firmware_version: env!("CARGO_PKG_VERSION").to_string(), + manufacturer: "One-KVM".to_string(), + model: "One-KVM".to_string(), + date_time: datetime, + date_time_local_offset: offset_str, + service_entry_point_uuid: mgr_uuid, + command_shell: CommandShell { + service_enabled: state + .extensions + .check_available(crate::extensions::ExtensionId::Ttyd), + max_concurrent_sessions: 1, + connect_types_supported: vec!["WebUI".to_string()], + }, + graphical_console: GraphicalConsole { + service_enabled: true, + max_concurrent_sessions: 4, + connect_types_supported: vec!["KVMIP".to_string()], + }, + virtual_media: odata_ref(&format!("/redfish/v1/Managers/{}/VirtualMedia", manager_id)), + links: ManagerLinks { + manager_for_servers: vec![odata_ref(&format!("/redfish/v1/Systems/{}", RESOURCE_ID))], + manager_for_chassis: vec![odata_ref(&format!("/redfish/v1/Chassis/{}", RESOURCE_ID))], + }, + network_protocol: odata_ref(&format!("/redfish/v1/Managers/{}/NetworkProtocol", manager_id)), + }) + .into_response() +} + +async fn network_protocol( + State(state): State>, + Path(manager_id): Path, +) -> Response { + if let Some(resp) = validate_id(&manager_id) { + return resp; + } + + let config = state.config.get(); + let http_port = config.web.http_port; + let https_enabled = config.web.https_enabled; + let https_port = config.web.https_port; + + Json(serde_json::json!({ + "@odata.type": "#ManagerNetworkProtocol.v1_10_0.ManagerNetworkProtocol", + "@odata.id": format!("/redfish/v1/Managers/{}/NetworkProtocol", manager_id), + "@odata.context": "/redfish/v1/$metadata#ManagerNetworkProtocol.ManagerNetworkProtocol", + "Id": "NetworkProtocol", + "Name": "Manager Network Protocol", + "Description": "Network protocol settings", + "Status": { "State": "Enabled", "Health": "OK" }, + "HTTP": { "ProtocolEnabled": !https_enabled, "Port": http_port }, + "HTTPS": { "ProtocolEnabled": https_enabled, "Port": https_port }, + "SSDP": { "ProtocolEnabled": false } + })) + .into_response() +} diff --git a/src/redfish/routes/mod.rs b/src/redfish/routes/mod.rs new file mode 100644 index 00000000..a41b8385 --- /dev/null +++ b/src/redfish/routes/mod.rs @@ -0,0 +1,211 @@ +mod account; +mod chassis; +mod event; +mod managers; +mod session; +mod systems; +mod virtual_media; + +use axum::{ + http::{HeaderName, HeaderValue}, + middleware, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use serde_json::json; +use std::sync::Arc; +use tower_http::set_header::SetResponseHeaderLayer; + +use super::auth::redfish_auth_middleware; +use super::schema::*; +use crate::state::AppState; + +pub(crate) const REDFISH_VERSION: &str = "1.18.1"; +pub(crate) const RESOURCE_ID: &str = "1"; + +pub(crate) fn resource_not_found() -> Response { + ( + axum::http::StatusCode::NOT_FOUND, + axum::Json(RedfishError::resource_not_found()), + ) + .into_response() +} + +pub(crate) fn validate_id(id: &str) -> Option { + if id != RESOURCE_ID { + return Some(resource_not_found()); + } + None +} + +pub(crate) fn service_unavailable(msg: &str) -> Response { + ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + axum::Json(RedfishError::service_unavailable(msg)), + ) + .into_response() +} + +pub(crate) fn empty_collection( + odata_type: &str, + odata_id: &str, + odata_context: &str, + name: &str, + description: &str, + members: Vec, +) -> Collection { + Collection { + odata_type: odata_type.to_string(), + odata_id: odata_id.to_string(), + odata_context: odata_context.to_string(), + name: name.to_string(), + description: description.to_string(), + members_count: members.len() as u64, + members, + } +} + +pub(crate) async fn get_power_state(state: &Arc) -> &'static str { + let guard = state.atx.read().await; + match guard.as_ref() { + Some(atx) => match atx.power_status().await { + crate::atx::PowerStatus::On => "On", + crate::atx::PowerStatus::Off => "Off", + crate::atx::PowerStatus::Unknown => "Unknown", + }, + None => "Unknown", + } +} + +async fn service_root_redirect() -> Response { + axum::response::Redirect::permanent("/redfish/v1/").into_response() +} + +async fn service_root() -> Json { + let uuid = "00000000-0000-0000-0000-000000000001".to_string(); + + Json(ServiceRoot { + odata_type: "#ServiceRoot.v1_17_0.ServiceRoot".to_string(), + odata_id: "/redfish/v1".to_string(), + odata_context: "/redfish/v1/$metadata#ServiceRoot.ServiceRoot".to_string(), + id: "RootService".to_string(), + name: "One-KVM Redfish Service".to_string(), + redfish_version: REDFISH_VERSION.to_string(), + uuid, + protocol_features_supported: ProtocolFeaturesSupported { + excerpt_query: false, + expand_query: ExpandQuery { + expand_all: false, + levels: false, + max_levels: 0, + no_links: false, + top: false, + }, + filter_query: false, + only_member_query: true, + select_query: false, + }, + systems: odata_ref("/redfish/v1/Systems"), + chassis: odata_ref("/redfish/v1/Chassis"), + managers: odata_ref("/redfish/v1/Managers"), + session_service: odata_ref("/redfish/v1/SessionService"), + account_service: odata_ref("/redfish/v1/AccountService"), + event_service: odata_ref("/redfish/v1/EventService"), + links: ServiceRootLinks { + sessions: odata_ref("/redfish/v1/SessionService/Sessions"), + }, + }) +} + +async fn odata_document() -> Json { + Json(json!({ + "@odata.context": "/redfish/v1/$metadata", + "value": [ + { "name": "ServiceRoot", "kind": "Singleton", "url": "/redfish/v1" }, + { "name": "Systems", "kind": "Collection", "url": "/redfish/v1/Systems" }, + { "name": "Chassis", "kind": "Collection", "url": "/redfish/v1/Chassis" }, + { "name": "Managers", "kind": "Collection", "url": "/redfish/v1/Managers" } + ] + })) +} + +async fn metadata() -> Response { + ( + [(axum::http::header::CONTENT_TYPE, "application/xml")], + r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"#, + ) + .into_response() +} + +pub fn create_redfish_router(state: Arc) -> Router { + let redfish_routes = Router::new() + .route("/", get(service_root)) + .route("/v1", get(service_root_redirect)) + .route("/v1/", get(service_root)) + .route("/v1/odata", get(odata_document)) + .route("/v1/$metadata", get(metadata)) + .merge(systems::router(state.clone())) + .merge(chassis::router(state.clone())) + .merge(managers::router(state.clone())) + .merge(virtual_media::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, + )); + + Router::new() + .route("/redfish", get(service_root_redirect)) + .nest("/redfish/", redfish_routes) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("odata-version"), + HeaderValue::from_static("4.0"), + )) + .with_state(state) +} diff --git a/src/redfish/routes/session.rs b/src/redfish/routes/session.rs new file mode 100644 index 00000000..c9b67dd0 --- /dev/null +++ b/src/redfish/routes/session.rs @@ -0,0 +1,160 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{delete, get}, + Json, Router, +}; +use tracing::info; + +use std::sync::Arc; + +use super::empty_collection; +use super::super::schema::*; +use crate::state::AppState; + +pub(crate) fn router(state: Arc) -> Router> { + Router::new() + .route("/v1/SessionService", get(session_service)) + .route( + "/v1/SessionService/Sessions", + get(session_list).post(session_create), + ) + .route( + "/v1/SessionService/Sessions/{session_id}", + delete(session_delete), + ) + .with_state(state) +} + +async fn session_service() -> Json { + Json(SessionService { + odata_type: "#SessionService.v1_1_8.SessionService".to_string(), + odata_id: "/redfish/v1/SessionService".to_string(), + odata_context: "/redfish/v1/$metadata#SessionService.SessionService".to_string(), + id: "SessionService".to_string(), + name: "Session Service".to_string(), + description: "Session Service".to_string(), + service_enabled: true, + session_timeout: "PT24H".to_string(), + sessions: odata_ref("/redfish/v1/SessionService/Sessions"), + }) +} + +async fn session_list(State(state): State>) -> Response { + let session_ids = match state.sessions.list_ids().await { + Ok(ids) => ids, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(RedfishError::general_error(&e.to_string())), + ) + .into_response() + } + }; + + let mut members = Vec::new(); + for id in &session_ids { + if state.sessions.get(id).await.ok().flatten().is_some() { + members.push(odata_ref(&format!("/redfish/v1/SessionService/Sessions/{}", id))); + } + } + + Json(empty_collection( + "#SessionCollection.SessionCollection", + "/redfish/v1/SessionService/Sessions", + "/redfish/v1/$metadata#SessionCollection.SessionCollection", + "Session Collection", + "Collection of Sessions", + members, + )) + .into_response() +} + +async fn session_create( + State(state): State>, + Json(req): Json, +) -> Response { + let user = match state.users.verify(&req.user_name, &req.password).await { + Ok(Some(user)) => user, + _ => { + return ( + StatusCode::UNAUTHORIZED, + Json(RedfishError::invalid_credentials()), + ) + .into_response() + } + }; + + if !state.config.get().auth.single_user_allow_multiple_sessions { + let revoked_ids = state.sessions.list_ids().await.unwrap_or_default(); + let _ = state.sessions.delete_all().await; + state.remember_revoked_sessions(revoked_ids).await; + } + + let session = match state.sessions.create(&user.id).await { + Ok(s) => s, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(RedfishError::general_error(&e.to_string())), + ) + .into_response() + } + }; + + info!("Redfish: Session created for user '{}'", user.username); + + let location = format!("/redfish/v1/SessionService/Sessions/{}", session.id); + + ( + StatusCode::CREATED, + [ + ("X-Auth-Token", session.id.clone()), + ("Location", location.clone()), + ], + Json(Session { + odata_type: "#Session.v1_0_0.Session".to_string(), + odata_id: location, + odata_context: "/redfish/v1/$metadata#Session.Session".to_string(), + id: session.id, + name: format!("Session for {}", user.username), + description: "Manager User Session".to_string(), + user_name: user.username, + }), + ) + .into_response() +} + +async fn session_delete( + State(state): State>, + Path(session_id): Path, +) -> Response { + match state.sessions.get(&session_id).await { + Ok(Some(_)) => { + if let Err(e) = state.sessions.delete(&session_id).await { + tracing::warn!("Redfish: Session delete failed: {}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(RedfishError::general_error(&e.to_string())), + ) + .into_response(); + } + info!("Redfish: Session {} deleted", session_id); + StatusCode::NO_CONTENT.into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(RedfishError::resource_not_found()), + ) + .into_response(), + Err(e) => { + tracing::warn!("Redfish: Session delete failed: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(RedfishError::general_error(&e.to_string())), + ) + .into_response() + } + } +} diff --git a/src/redfish/routes/systems.rs b/src/redfish/routes/systems.rs new file mode 100644 index 00000000..8dfb3dd5 --- /dev/null +++ b/src/redfish/routes/systems.rs @@ -0,0 +1,220 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use std::sync::Arc; +use tracing::info; + +use super::{get_power_state, validate_id, service_unavailable, empty_collection, RESOURCE_ID}; +use super::super::schema::*; +use crate::state::AppState; + +pub(crate) fn router(state: Arc) -> Router> { + Router::new() + .route("/v1/Systems", get(systems_collection)) + .route( + "/v1/Systems/{system_id}", + get(system_detail).patch(system_patch), + ) + .route( + "/v1/Systems/{system_id}/Actions/ComputerSystem.Reset", + post(system_reset), + ) + .route( + "/v1/Systems/{system_id}/Actions/ComputerSystem.SetDefaultBootOrder", + post(system_set_default_boot_order), + ) + .with_state(state) +} + +fn build_computer_system(system_id: &str, power_state: &str, boot: Boot) -> ComputerSystem { + ComputerSystem { + odata_type: "#ComputerSystem.v1_20_0.ComputerSystem".to_string(), + odata_id: format!("/redfish/v1/Systems/{}", system_id), + odata_context: "/redfish/v1/$metadata#ComputerSystem.ComputerSystem".to_string(), + odata_etag: "W/\"168\"".to_string(), + id: system_id.to_string(), + name: "Managed System".to_string(), + description: "The managed computer system connected via One-KVM".to_string(), + system_type: "Physical".to_string(), + asset_tag: String::new(), + manufacturer: "Unknown".to_string(), + model: "Unknown".to_string(), + serial_number: String::new(), + part_number: String::new(), + power_state: power_state.to_string(), + bios_version: "Unknown".to_string(), + status: Status::enabled_ok(), + boot, + processor_summary: ProcessorSummary { + count: None, + logical_processor_count: None, + model: "Unknown".to_string(), + status: Status::enabled_ok(), + }, + memory_summary: MemorySummary { + total_system_memory_gi_b: None, + status: Status::enabled_ok(), + }, + trusted_modules: vec![], + actions: ComputerSystemActions { + reset: ActionTarget { + target: format!( + "/redfish/v1/Systems/{}/Actions/ComputerSystem.Reset", + system_id + ), + }, + set_default_boot_order: ActionTarget { + target: format!( + "/redfish/v1/Systems/{}/Actions/ComputerSystem.SetDefaultBootOrder", + system_id + ), + }, + }, + links: ComputerSystemLinks { + chassis: vec![odata_ref(&format!("/redfish/v1/Chassis/{}", RESOURCE_ID))], + managed_by: vec![odata_ref(&format!("/redfish/v1/Managers/{}", RESOURCE_ID))], + }, + } +} + +async fn systems_collection() -> Json> { + Json(empty_collection( + "#ComputerSystemCollection.ComputerSystemCollection", + "/redfish/v1/Systems", + "/redfish/v1/$metadata#ComputerSystemCollection.ComputerSystemCollection", + "Computer System Collection", + "Collection of Computer Systems", + vec![odata_ref("/redfish/v1/Systems/1")], + )) +} + +async fn system_detail( + State(state): State>, + Path(system_id): Path, +) -> Response { + if let Some(resp) = validate_id(&system_id) { + return resp; + } + + let power_state = get_power_state(&state).await; + let system = build_computer_system( + &system_id, + power_state, + Boot { + boot_source_override_enabled: "Disabled".to_string(), + boot_source_override_mode: None, + boot_source_override_target: None, + uefi_target_boot_source_override: None, + }, + ); + + Json(system).into_response() +} + +async fn system_patch( + State(state): State>, + Path(system_id): Path, + Json(req): Json, +) -> Response { + if let Some(resp) = validate_id(&system_id) { + return resp; + } + + if let Some(boot) = &req.boot { + if let Some(target) = &boot.boot_source_override_target { + info!( + "Redfish: PATCH Systems/{} BootSourceOverrideTarget='{}' (accepted, no-op)", + system_id, target + ); + } + } + + let power_state = get_power_state(&state).await; + let boot = match req.boot { + Some(b) => Boot { + boot_source_override_enabled: b + .boot_source_override_enabled + .unwrap_or_else(|| "Disabled".to_string()), + boot_source_override_mode: b.boot_source_override_mode, + boot_source_override_target: b.boot_source_override_target, + uefi_target_boot_source_override: b.uefi_target_boot_source_override, + }, + None => Boot { + boot_source_override_enabled: "Disabled".to_string(), + boot_source_override_mode: None, + boot_source_override_target: None, + uefi_target_boot_source_override: None, + }, + }; + + let system = build_computer_system(&system_id, power_state, boot); + Json(system).into_response() +} + +async fn system_reset( + State(state): State>, + Path(system_id): Path, + Json(req): Json, +) -> Response { + if let Some(resp) = validate_id(&system_id) { + return resp; + } + + let result = { + let guard = state.atx.read().await; + let atx = match guard.as_ref() { + Some(atx) => atx, + None => return service_unavailable("ATX power control not available"), + }; + + match req.reset_type.as_str() { + "On" | "ForceOn" | "PushPowerButton" => atx.power_short().await, + "ForceOff" | "GracefulShutdown" => atx.power_long().await, + "ForceRestart" | "GracefulRestart" | "PowerCycle" => atx.reset().await, + "Nmi" => { + return ( + StatusCode::NOT_ACCEPTABLE, + Json(RedfishError::action_not_supported("Nmi")), + ) + .into_response() + } + _ => { + return ( + StatusCode::NOT_ACCEPTABLE, + Json(RedfishError::action_not_supported(&req.reset_type)), + ) + .into_response() + } + } + }; + + match result { + Ok(()) => { + info!("Redfish: System reset '{}' executed", req.reset_type); + StatusCode::NO_CONTENT.into_response() + } + Err(e) => { + tracing::warn!("Redfish: System reset failed: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(RedfishError::general_error(&e.to_string())), + ) + .into_response() + } + } +} + +async fn system_set_default_boot_order( + Path(system_id): Path, +) -> Response { + if let Some(resp) = validate_id(&system_id) { + return resp; + } + + info!("Redfish: SetDefaultBootOrder for system {} (accepted, no-op)", system_id); + StatusCode::NO_CONTENT.into_response() +} diff --git a/src/redfish/routes/virtual_media.rs b/src/redfish/routes/virtual_media.rs new file mode 100644 index 00000000..8be015c5 --- /dev/null +++ b/src/redfish/routes/virtual_media.rs @@ -0,0 +1,224 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use tracing::{info, warn}; + +use std::sync::Arc; + +use super::{empty_collection, validate_id, service_unavailable, resource_not_found, RESOURCE_ID}; +use super::super::schema::*; +use crate::state::AppState; + +pub(crate) fn router(state: Arc) -> Router> { + Router::new() + .route( + "/v1/Managers/{manager_id}/VirtualMedia", + get(virtual_media_collection), + ) + .route( + "/v1/Managers/{manager_id}/VirtualMedia/{media_id}", + get(virtual_media_detail), + ) + .route( + "/v1/Managers/{manager_id}/VirtualMedia/{media_id}/Actions/VirtualMedia.InsertMedia", + post(virtual_media_insert), + ) + .route( + "/v1/Managers/{manager_id}/VirtualMedia/{media_id}/Actions/VirtualMedia.EjectMedia", + post(virtual_media_eject), + ) + .with_state(state) +} + +async fn virtual_media_collection(Path(manager_id): Path) -> Response { + if let Some(resp) = validate_id(&manager_id) { + return resp; + } + + 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 + ))], + )) + .into_response() +} + +async fn virtual_media_detail( + State(state): State>, + Path((manager_id, media_id)): Path<(String, String)>, +) -> Response { + 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 guard = state.msd.read().await; + match guard.as_ref() { + Some(msd) => { + let msd_state = msd.state().await; + let img_name = msd_state + .current_image + .as_ref() + .map(|i| i.name.clone()) + .or_else(|| { + msd_state + .drive_info + .as_ref() + .map(|_| "Virtual Drive".to_string()) + }); + ( + msd_state.connected, + img_name, + if msd_state.connected { + Some("Applet".to_string()) + } else { + None + }, + ) + } + None => (false, None, None), + } + }; + + Json(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, + transfer_protocol_type: None, + status: if inserted { + Status::enabled_ok() + } else { + Status::disabled_ok() + }, + actions: VirtualMediaActions { + insert_media: ActionTarget { + target: format!( + "/redfish/v1/Managers/{}/VirtualMedia/{}/Actions/VirtualMedia.InsertMedia", + manager_id, media_id + ), + }, + eject_media: ActionTarget { + target: format!( + "/redfish/v1/Managers/{}/VirtualMedia/{}/Actions/VirtualMedia.EjectMedia", + manager_id, media_id + ), + }, + }, + }) + .into_response() +} + +async fn virtual_media_insert( + State(state): State>, + Path((manager_id, media_id)): Path<(String, String)>, + Json(req): Json, +) -> Response { + if let Some(resp) = validate_id(&manager_id) { + return resp; + } + if media_id != RESOURCE_ID { + return resource_not_found(); + } + + let result = { + let guard = state.msd.read().await; + let msd = match guard.as_ref() { + Some(msd) => msd, + None => return service_unavailable("MSD not available"), + }; + + if msd.state().await.connected { + return ( + StatusCode::CONFLICT, + Json(RedfishError::general_error("Virtual media already inserted")), + ) + .into_response(); + } + + info!("Redfish: VirtualMedia.InsertMedia image='{}'", req.image); + msd.connect_drive().await + }; + + match result { + Ok(()) => { + info!("Redfish: VirtualMedia.InsertMedia executed"); + 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() + } + } +} + +async fn virtual_media_eject( + State(state): State>, + Path((manager_id, media_id)): Path<(String, String)>, +) -> Response { + if let Some(resp) = validate_id(&manager_id) { + return resp; + } + if media_id != RESOURCE_ID { + return resource_not_found(); + } + + let result = { + let guard = state.msd.read().await; + let msd = match guard.as_ref() { + Some(msd) => msd, + None => return service_unavailable("MSD not available"), + }; + + if !msd.state().await.connected { + return ( + StatusCode::CONFLICT, + Json(RedfishError::general_error("No virtual media inserted")), + ) + .into_response(); + } + + msd.disconnect().await + }; + + match result { + Ok(()) => { + info!("Redfish: VirtualMedia.EjectMedia executed"); + 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() + } + } +} diff --git a/src/redfish/schema.rs b/src/redfish/schema.rs new file mode 100644 index 00000000..2ddf6a5b --- /dev/null +++ b/src/redfish/schema.rs @@ -0,0 +1,627 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub fn odata_ref(id: &str) -> ODataLink { + ODataLink { + odata_id: id.to_string(), + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ODataLink { + #[serde(rename = "@odata.id")] + pub odata_id: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct Status { + pub state: String, + pub health: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub health_rollup: Option, +} + +impl Status { + pub fn enabled_ok() -> Self { + Self { + state: "Enabled".to_string(), + health: "OK".to_string(), + health_rollup: None, + } + } + + pub fn enabled_health(health: &str) -> Self { + Self { + state: "Enabled".to_string(), + health: health.to_string(), + health_rollup: None, + } + } + + pub fn disabled_ok() -> Self { + Self { + state: "Disabled".to_string(), + health: "OK".to_string(), + health_rollup: None, + } + } + + pub fn offline_ok() -> Self { + Self { + state: "Offline".to_string(), + health: "OK".to_string(), + health_rollup: None, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ServiceRoot { + #[serde(rename = "@odata.type")] + pub odata_type: String, + #[serde(rename = "@odata.id")] + pub odata_id: String, + #[serde(rename = "@odata.context")] + pub odata_context: String, + pub id: String, + pub name: String, + pub redfish_version: String, + #[serde(rename = "UUID")] + pub uuid: String, + pub protocol_features_supported: ProtocolFeaturesSupported, + pub systems: ODataLink, + pub chassis: ODataLink, + pub managers: ODataLink, + pub session_service: ODataLink, + pub account_service: ODataLink, + pub event_service: ODataLink, + pub links: ServiceRootLinks, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ProtocolFeaturesSupported { + pub excerpt_query: bool, + pub expand_query: ExpandQuery, + pub filter_query: bool, + pub only_member_query: bool, + pub select_query: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ExpandQuery { + pub expand_all: bool, + pub levels: bool, + pub max_levels: u32, + pub no_links: bool, + pub top: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ServiceRootLinks { + pub sessions: ODataLink, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct Collection { + #[serde(rename = "@odata.type")] + pub odata_type: String, + #[serde(rename = "@odata.id")] + pub odata_id: String, + #[serde(rename = "@odata.context")] + pub odata_context: String, + pub name: String, + pub description: String, + #[serde(rename = "Members@odata.count")] + pub members_count: u64, + pub members: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ComputerSystem { + #[serde(rename = "@odata.type")] + pub odata_type: String, + #[serde(rename = "@odata.id")] + pub odata_id: String, + #[serde(rename = "@odata.context")] + pub odata_context: String, + #[serde(rename = "@odata.etag")] + pub odata_etag: String, + pub id: String, + pub name: String, + pub description: String, + pub system_type: String, + pub asset_tag: String, + pub manufacturer: String, + pub model: String, + pub serial_number: String, + pub part_number: String, + pub power_state: String, + pub bios_version: String, + pub status: Status, + pub boot: Boot, + pub processor_summary: ProcessorSummary, + pub memory_summary: MemorySummary, + pub trusted_modules: Vec, + pub actions: ComputerSystemActions, + pub links: ComputerSystemLinks, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct Boot { + pub boot_source_override_enabled: String, + pub boot_source_override_mode: Option, + pub boot_source_override_target: Option, + pub uefi_target_boot_source_override: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ProcessorSummary { + pub count: Option, + pub logical_processor_count: Option, + pub model: String, + pub status: Status, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct MemorySummary { + pub total_system_memory_gi_b: Option, + pub status: Status, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ComputerSystemActions { + #[serde(rename = "#ComputerSystem.Reset")] + pub reset: ActionTarget, + #[serde(rename = "#ComputerSystem.SetDefaultBootOrder")] + pub set_default_boot_order: ActionTarget, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ActionTarget { + pub target: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ComputerSystemLinks { + pub chassis: Vec, + pub managed_by: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ResetRequest { + #[serde(default = "default_reset_type")] + pub reset_type: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct ComputerSystemPatchRequest { + #[serde(default)] + pub boot: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct BootPatch { + #[serde(default)] + pub boot_source_override_enabled: Option, + #[serde(default)] + pub boot_source_override_target: Option, + #[serde(default)] + pub boot_source_override_mode: Option, + #[serde(default)] + pub uefi_target_boot_source_override: Option, +} + +fn default_reset_type() -> String { + "ForceRestart".to_string() +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct Manager { + #[serde(rename = "@odata.type")] + pub odata_type: String, + #[serde(rename = "@odata.id")] + pub odata_id: String, + #[serde(rename = "@odata.context")] + pub odata_context: String, + pub id: String, + pub name: String, + pub description: String, + pub manager_type: String, + pub status: Status, + pub firmware_version: String, + pub manufacturer: String, + pub model: String, + pub date_time: String, + pub date_time_local_offset: String, + pub service_entry_point_uuid: String, + pub command_shell: CommandShell, + pub graphical_console: GraphicalConsole, + pub virtual_media: ODataLink, + pub links: ManagerLinks, + pub network_protocol: ODataLink, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct CommandShell { + pub service_enabled: bool, + pub max_concurrent_sessions: u32, + pub connect_types_supported: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct GraphicalConsole { + pub service_enabled: bool, + pub max_concurrent_sessions: u32, + pub connect_types_supported: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ManagerLinks { + pub manager_for_servers: Vec, + pub manager_for_chassis: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct VirtualMedia { + #[serde(rename = "@odata.type")] + pub odata_type: String, + #[serde(rename = "@odata.id")] + pub odata_id: String, + #[serde(rename = "@odata.context")] + pub odata_context: String, + pub id: String, + pub name: String, + pub description: String, + pub media_types: Vec, + pub connected_via: Option, + pub inserted: bool, + pub image: Option, + pub image_name: Option, + pub write_protected: bool, + pub transfer_method: Option, + pub transfer_protocol_type: Option, + pub status: Status, + pub actions: VirtualMediaActions, +} + +#[derive(Debug, Clone, Serialize)] +pub struct VirtualMediaActions { + #[serde(rename = "#VirtualMedia.InsertMedia")] + pub insert_media: ActionTarget, + #[serde(rename = "#VirtualMedia.EjectMedia")] + pub eject_media: ActionTarget, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct InsertMediaRequest { + pub image: String, + #[serde(default)] + pub write_protected: Option, + #[serde(default)] + pub transfer_method: Option, + #[serde(default)] + pub transfer_protocol_type: Option, + pub media_types: Option>, + pub inserted: Option, + pub user_name: Option, + pub password: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct Chassis { + #[serde(rename = "@odata.type")] + pub odata_type: String, + #[serde(rename = "@odata.id")] + pub odata_id: String, + #[serde(rename = "@odata.context")] + pub odata_context: String, + pub id: String, + pub name: String, + pub description: String, + pub chassis_type: String, + pub asset_tag: String, + pub manufacturer: String, + pub model: String, + pub serial_number: String, + pub part_number: String, + pub power_state: String, + pub status: Status, + pub power: ODataLink, + pub links: ChassisLinks, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ChassisLinks { + pub computer_systems: Vec, + pub managed_by: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct Power { + #[serde(rename = "@odata.type")] + pub odata_type: String, + #[serde(rename = "@odata.id")] + pub odata_id: String, + #[serde(rename = "@odata.context")] + pub odata_context: String, + pub id: String, + pub name: String, + pub power_control: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct PowerControl { + #[serde(rename = "@odata.id")] + pub odata_id: String, + pub member_id: String, + pub name: String, + pub power_consumed_watts: Option, + pub power_capacity_watts: Option, + pub power_requested_watts: Option, + pub power_metrics: Option, + pub status: Status, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct PowerMetric { + pub interval_in_min: u32, + pub min_consumed_watts: Option, + pub max_consumed_watts: Option, + pub average_consumed_watts: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct SessionService { + #[serde(rename = "@odata.type")] + pub odata_type: String, + #[serde(rename = "@odata.id")] + pub odata_id: String, + #[serde(rename = "@odata.context")] + pub odata_context: String, + pub id: String, + pub name: String, + pub description: String, + pub service_enabled: bool, + pub session_timeout: String, + pub sessions: ODataLink, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct Session { + #[serde(rename = "@odata.type")] + pub odata_type: String, + #[serde(rename = "@odata.id")] + pub odata_id: String, + #[serde(rename = "@odata.context")] + pub odata_context: String, + pub id: String, + pub name: String, + pub description: String, + pub user_name: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub struct SessionCreateRequest { + pub user_name: String, + pub password: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct AccountService { + #[serde(rename = "@odata.type")] + pub odata_type: String, + #[serde(rename = "@odata.id")] + pub odata_id: String, + #[serde(rename = "@odata.context")] + pub odata_context: String, + pub id: String, + pub name: String, + pub description: String, + pub service_enabled: bool, + pub accounts: ODataLink, + pub roles: ODataLink, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ManagerAccount { + #[serde(rename = "@odata.type")] + pub odata_type: String, + #[serde(rename = "@odata.id")] + pub odata_id: String, + #[serde(rename = "@odata.context")] + pub odata_context: String, + pub id: String, + pub name: String, + pub description: String, + pub enabled: bool, + pub user_name: String, + pub role_id: String, + pub locked: bool, + pub links: ManagerAccountLinks, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct ManagerAccountLinks { + pub role: ODataLink, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct EventService { + #[serde(rename = "@odata.type")] + pub odata_type: String, + #[serde(rename = "@odata.id")] + pub odata_id: String, + #[serde(rename = "@odata.context")] + pub odata_context: String, + pub id: String, + pub name: String, + pub description: String, + pub service_enabled: bool, + pub delivery_retry_attempts: u32, + pub delivery_retry_interval_seconds: u32, + pub event_format_types: Vec, + pub registry_prefixes: Vec, + pub subordinate_resources: bool, + #[serde(rename = "SSEFilterPropertiesSupported")] + pub sse_filter_properties_supported: SseFilterPropertiesSupported, + pub server_sent_event_uri: Option, + pub actions: EventServiceActions, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct SseFilterPropertiesSupported { + pub event_format_type: bool, + pub message_id: bool, + pub metric_report_definition: bool, + pub origin_resource: bool, + pub registry_prefix: bool, + pub resource_type: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct EventServiceActions { + #[serde(rename = "#EventService.SubmitTestEvent")] + pub submit_test_event: ActionTarget, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RedfishError { + pub error: RedfishErrorBody, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RedfishErrorBody { + pub code: String, + pub message: String, + #[serde(rename = "@Message.ExtendedInfo", skip_serializing_if = "Vec::is_empty")] + pub extended_info: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "PascalCase")] +pub struct RedfishExtendedInfo { + #[serde(rename = "@odata.type")] + pub odata_type: String, + pub message_id: String, + pub message: String, + pub severity: String, + pub resolution: String, +} + +impl RedfishError { + pub fn general_error(message: &str) -> Self { + Self { + error: RedfishErrorBody { + code: "Base.1.18.GeneralError".to_string(), + message: message.to_string(), + extended_info: vec![], + }, + } + } + + pub fn authentication_required() -> Self { + Self { + error: RedfishErrorBody { + code: "Base.1.18.AuthenticationRequired".to_string(), + message: "Authentication is required to access this resource".to_string(), + extended_info: vec![RedfishExtendedInfo { + odata_type: "#Message.v1_2_1.Message".to_string(), + message_id: "Base.1.18.AuthenticationRequired".to_string(), + message: "Authentication is required to access this resource".to_string(), + severity: "Critical".to_string(), + resolution: "Authenticate using HTTP Basic auth or create a session via POST /redfish/v1/SessionService/Sessions".to_string(), + }], + }, + } + } + + pub fn invalid_credentials() -> Self { + Self { + error: RedfishErrorBody { + code: "Base.1.18.AuthenticationRequired".to_string(), + message: "Invalid username or password".to_string(), + extended_info: vec![RedfishExtendedInfo { + odata_type: "#Message.v1_2_1.Message".to_string(), + message_id: "Base.1.18.InvalidCredentials".to_string(), + message: "Invalid username or password".to_string(), + severity: "Critical".to_string(), + resolution: "Correct the credentials and retry".to_string(), + }], + }, + } + } + + pub fn resource_not_found() -> Self { + Self { + error: RedfishErrorBody { + code: "Base.1.18.ResourceNotFound".to_string(), + message: "The requested resource was not found".to_string(), + extended_info: vec![], + }, + } + } + + pub fn action_not_supported(action: &str) -> Self { + Self { + error: RedfishErrorBody { + code: "Base.1.18.ActionNotSupported".to_string(), + message: format!("Action '{}' is not supported", action), + extended_info: vec![], + }, + } + } + + pub fn property_missing(property: &str) -> Self { + Self { + error: RedfishErrorBody { + code: "Base.1.18.PropertyMissing".to_string(), + message: format!("Property '{}' is required", property), + extended_info: vec![], + }, + } + } + + pub fn service_unavailable(msg: &str) -> Self { + Self { + error: RedfishErrorBody { + code: "Base.1.18.ServiceUnavailable".to_string(), + message: msg.to_string(), + extended_info: vec![], + }, + } + } +} diff --git a/src/web/handlers/config/mod.rs b/src/web/handlers/config/mod.rs index c2b3e023..a02446b3 100644 --- a/src/web/handlers/config/mod.rs +++ b/src/web/handlers/config/mod.rs @@ -6,6 +6,7 @@ mod audio; mod auth; mod hid; mod msd; +mod redfish; mod rtsp; mod rustdesk; mod stream; @@ -17,6 +18,7 @@ pub use audio::{get_audio_config, update_audio_config}; pub use auth::{get_auth_config, update_auth_config}; pub use hid::{get_hid_config, update_hid_config}; pub use msd::{get_msd_config, update_msd_config}; +pub use redfish::{get_redfish_config, update_redfish_config}; pub use rtsp::{get_rtsp_config, get_rtsp_status, update_rtsp_config}; pub use rustdesk::{ get_device_password, get_rustdesk_config, get_rustdesk_status, regenerate_device_id, diff --git a/src/web/handlers/config/redfish.rs b/src/web/handlers/config/redfish.rs new file mode 100644 index 00000000..a18f75c6 --- /dev/null +++ b/src/web/handlers/config/redfish.rs @@ -0,0 +1,29 @@ +use axum::{extract::State, Json}; +use std::sync::Arc; + +use crate::error::Result; +use crate::state::AppState; + +use super::types::{RedfishConfigResponse, RedfishConfigUpdate}; + +pub async fn get_redfish_config(State(state): State>) -> Json { + Json(RedfishConfigResponse { + enabled: state.config.get().redfish.enabled, + }) +} + +pub async fn update_redfish_config( + State(state): State>, + Json(req): Json, +) -> Result> { + state + .config + .update(|config| { + req.apply_to(&mut config.redfish); + }) + .await?; + + Ok(Json(RedfishConfigResponse { + enabled: state.config.get().redfish.enabled, + })) +} diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index 640a50ac..cbddd46b 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -1009,6 +1009,24 @@ impl WebConfigUpdate { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedfishConfigResponse { + pub enabled: bool, +} + +#[derive(Debug, Deserialize)] +pub struct RedfishConfigUpdate { + pub enabled: Option, +} + +impl RedfishConfigUpdate { + pub fn apply_to(&self, config: &mut crate::config::RedfishConfig) { + if let Some(enabled) = self.enabled { + config.enabled = enabled; + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/web/routes.rs b/src/web/routes.rs index 7d6ac2ff..79171823 100644 --- a/src/web/routes.rs +++ b/src/web/routes.rs @@ -18,6 +18,15 @@ use crate::hid::websocket::ws_hid_handler; use crate::state::AppState; pub fn create_router(state: Arc) -> Router { + let redfish_router = { + let config = state.config.get(); + if config.redfish.enabled { + Some(crate::redfish::routes::create_redfish_router(state.clone())) + } else { + None + } + }; + let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) @@ -137,6 +146,9 @@ pub fn create_router(state: Arc) -> Router { // Auth configuration .route("/config/auth", get(handlers::config::get_auth_config)) .route("/config/auth", patch(handlers::config::update_auth_config)) + // Redfish configuration + .route("/config/redfish", get(handlers::config::get_redfish_config)) + .route("/config/redfish", patch(handlers::config::update_redfish_config)) // System control .route("/system/restart", post(handlers::system_restart)) .route("/update/overview", get(handlers::update_overview)) @@ -245,10 +257,15 @@ pub fn create_router(state: Arc) -> Router { let static_routes = super::static_files::static_file_router(); // Main router - Router::new() + let main_router = Router::new() .nest("/api", api_routes) .merge(static_routes) .layer(TraceLayer::new_for_http()) .layer(cors) - .with_state(state) + .with_state(state); + + match redfish_router { + Some(rf) => main_router.merge(rf), + None => main_router, + } } diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 573e489a..dcdbcf2f 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -392,6 +392,24 @@ export const webConfigApi = { }), } +export interface RedfishConfigResponse { + enabled: boolean +} + +export interface RedfishConfigUpdate { + enabled?: boolean +} + +export const redfishConfigApi = { + get: () => request('/config/redfish'), + + update: (config: RedfishConfigUpdate) => + request('/config/redfish', { + method: 'PATCH', + body: JSON.stringify(config), + }), +} + export const systemApi = { /** * 重启系统 diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 58c726fa..7da22c19 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -678,6 +678,7 @@ export { atxConfigApi, audioConfigApi, extensionsApi, + redfishConfigApi, rustdeskConfigApi, rtspConfigApi, webConfigApi, @@ -686,6 +687,8 @@ export { type RustDeskConfigUpdate, type RustDeskPasswordResponse, type RtspConfigResponse, + type RedfishConfigResponse, + type RedfishConfigUpdate, type RtspConfigUpdate, type RtspStatusResponse, type WebConfig, diff --git a/web/src/i18n/en-US.ts b/web/src/i18n/en-US.ts index 95502fee..ffe39458 100644 --- a/web/src/i18n/en-US.ts +++ b/web/src/i18n/en-US.ts @@ -569,6 +569,10 @@ export default { httpsEnabledDesc: 'Serve over an encrypted connection. A self-signed certificate is generated automatically if none is provided.', portConfig: 'Port & Protocol', portConfigDesc: 'The service listens on a single port at a time, determined by the HTTPS toggle', + redfishTitle: 'Redfish API', + redfishDesc: 'DMTF Redfish standard management interface', + redfishEnabled: 'Enable Redfish API', + redfishEnabledDesc: 'When enabled, the standard Redfish management interface is available at /redfish/v1/', httpPortReserved: 'HTTP port (reserved)', httpsPortReserved: 'HTTPS port (reserved)', portActive: 'Active', diff --git a/web/src/i18n/zh-CN.ts b/web/src/i18n/zh-CN.ts index 7ada80a3..91941e87 100644 --- a/web/src/i18n/zh-CN.ts +++ b/web/src/i18n/zh-CN.ts @@ -568,6 +568,10 @@ export default { httpsEnabledDesc: '使用加密连接对外提供服务,未配置证书时自动生成自签名证书', portConfig: '端口与协议', portConfigDesc: '服务一次仅监听一个端口,由 HTTPS 开关决定生效端口', + redfishTitle: 'Redfish API', + redfishDesc: 'DMTF Redfish 标准管理接口', + redfishEnabled: '启用 Redfish API', + redfishEnabledDesc: '开启后可通过 /redfish/v1/ 访问标准 Redfish 管理接口', httpPortReserved: 'HTTP 端口(备用)', httpsPortReserved: 'HTTPS 端口(备用)', portActive: '当前生效', diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index f5c9ab0b..b303d9d4 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -12,6 +12,7 @@ import { streamApi, atxConfigApi, extensionsApi, + redfishConfigApi, systemApi, updateApi, usbApi, @@ -295,6 +296,8 @@ const webServerConfig = ref({ has_custom_cert: false, }) const webServerLoading = ref(false) +const redfishEnabled = ref(false) +const redfishSaving = ref(false) const sslCertPem = ref('') const sslKeyPem = ref('') const certSaving = ref(false) @@ -1601,6 +1604,30 @@ async function loadWebServerConfig() { } } +async function loadRedfishConfig() { + try { + const data = await redfishConfigApi.get() + redfishEnabled.value = data.enabled + } catch (e) { + console.error('Failed to load redfish config:', e) + } +} + +async function saveRedfishConfig() { + redfishSaving.value = true + try { + const data = await redfishConfigApi.update({ + enabled: redfishEnabled.value, + }) + redfishEnabled.value = data.enabled + await triggerAutoRestart() + } catch (e) { + console.error('Failed to save redfish config:', e) + } finally { + redfishSaving.value = false + } +} + async function saveWebServerConfig() { if (bindAddressError.value) return webServerLoading.value = true @@ -2087,6 +2114,7 @@ onMounted(async () => { loadRustdeskPassword(), loadRtspConfig(), loadWebServerConfig(), + loadRedfishConfig(), loadUpdateOverview(), refreshUpdateStatus(), fetchUsbDevices(), @@ -3237,9 +3265,35 @@ watch(() => route.query.tab, (tab) => { - - + + + + {{ t('settings.redfishTitle') }} + {{ t('settings.redfishDesc') }} + + +
+
+ +

{{ t('settings.redfishEnabledDesc') }}

+
+ +
+
+ +

+ + {{ t('settings.restartRequiredHint') }} +

+ +
+
+