feat: 实现 Redfish API 标准接口;支持通过前端开关控制 Redfish 服务

This commit is contained in:
Fucheng Sha
2026-05-12 10:45:42 +08:00
parent 17cd74f64c
commit 4e8c342905
23 changed files with 2170 additions and 5 deletions

View File

@@ -17,7 +17,7 @@ tokio-util = { version = "0.7", features = ["rt"] }
# Web framework # Web framework
axum = { version = "0.8", features = ["ws", "multipart", "tokio"] } axum = { version = "0.8", features = ["ws", "multipart", "tokio"] }
axum-extra = { version = "0.12", features = ["cookie"] } 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 # Database - Use bundled SQLite for static linking
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }

View File

@@ -110,6 +110,8 @@ pub struct AppConfig {
pub rustdesk: RustDeskConfig, pub rustdesk: RustDeskConfig,
/// RTSP streaming settings /// RTSP streaming settings
pub rtsp: RtspConfig, pub rtsp: RtspConfig,
/// Redfish API settings
pub redfish: RedfishConfig,
} }
/// Authentication configuration /// 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 }
}
}

View File

@@ -11,6 +11,7 @@ pub mod extensions;
pub mod hid; pub mod hid;
pub mod msd; pub mod msd;
pub mod otg; pub mod otg;
pub mod redfish;
pub mod rtsp; pub mod rtsp;
pub mod rustdesk; pub mod rustdesk;
pub mod state; pub mod state;

84
src/redfish/auth.rs Normal file
View File

@@ -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<Arc<AppState>>,
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))
}

3
src/redfish/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod auth;
pub mod routes;
pub mod schema;

View File

@@ -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<AppState>) -> Router<Arc<AppState>> {
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<AccountService> {
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<Arc<AppState>>) -> 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<Arc<AppState>>,
Path(account_id): Path<String>,
) -> 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<serde_json::Value> {
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<String>) -> 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()
}

View File

@@ -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<AppState>) -> Router<Arc<AppState>> {
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<Collection<ODataLink>> {
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<Arc<AppState>>,
Path(chassis_id): Path<String>,
) -> 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<String>,
) -> 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()
}

102
src/redfish/routes/event.rs Normal file
View File

@@ -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<AppState>) -> Router<Arc<AppState>> {
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<EventService> {
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<Arc<AppState>>) -> 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
}

View File

@@ -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<AppState>) -> Router<Arc<AppState>> {
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<Collection<ODataLink>> {
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<Arc<AppState>>,
Path(manager_id): Path<String>,
) -> 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<Arc<AppState>>,
Path(manager_id): Path<String>,
) -> 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()
}

211
src/redfish/routes/mod.rs Normal file
View File

@@ -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<Response> {
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<ODataLink>,
) -> Collection<ODataLink> {
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<AppState>) -> &'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<ServiceRoot> {
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<serde_json::Value> {
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#"<?xml version="1.0" encoding="UTF-8"?>
<edmx:Edmx xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx" Version="4.0">
<edmx:Reference Uri="http://redfish.dmtf.org/schemas/v1/ServiceRoot_v1.xml">
<edmx:Include Namespace="ServiceRoot"/>
</edmx:Reference>
<edmx:Reference Uri="http://redfish.dmtf.org/schemas/v1/ComputerSystem_v1.xml">
<edmx:Include Namespace="ComputerSystem"/>
</edmx:Reference>
<edmx:Reference Uri="http://redfish.dmtf.org/schemas/v1/Manager_v1.xml">
<edmx:Include Namespace="Manager"/>
</edmx:Reference>
<edmx:Reference Uri="http://redfish.dmtf.org/schemas/v1/Chassis_v1.xml">
<edmx:Include Namespace="Chassis"/>
</edmx:Reference>
<edmx:Reference Uri="http://redfish.dmtf.org/schemas/v1/Power_v1.xml">
<edmx:Include Namespace="Power"/>
</edmx:Reference>
<edmx:Reference Uri="http://redfish.dmtf.org/schemas/v1/VirtualMedia_v1.xml">
<edmx:Include Namespace="VirtualMedia"/>
</edmx:Reference>
<edmx:Reference Uri="http://redfish.dmtf.org/schemas/v1/SessionService_v1.xml">
<edmx:Include Namespace="SessionService"/>
</edmx:Reference>
<edmx:Reference Uri="http://redfish.dmtf.org/schemas/v1/AccountService_v1.xml">
<edmx:Include Namespace="AccountService"/>
</edmx:Reference>
<edmx:Reference Uri="http://redfish.dmtf.org/schemas/v1/EventService_v1.xml">
<edmx:Include Namespace="EventService"/>
</edmx:Reference>
<edmx:Reference Uri="http://redfish.dmtf.org/schemas/v1/ManagerNetworkProtocol_v1.xml">
<edmx:Include Namespace="ManagerNetworkProtocol"/>
</edmx:Reference>
<edmx:Reference Uri="http://redfish.dmtf.org/schemas/v1/Role_v1.xml">
<edmx:Include Namespace="Role"/>
</edmx:Reference>
<edmx:Reference Uri="http://docs.oasis-open.org/odata/ns/edm">
<edmx:Include Namespace="Edm" />
</edmx:Reference>
<edmx:DataServices>
<Schema xmlns="http://docs.oasis-open.org/odata/ns/edm" Namespace="OneKVM">
<EntityContainer Name="Service" Extends="ServiceRoot.v1_17_0.ServiceContainer"/>
</Schema>
</edmx:DataServices>
</edmx:Edmx>"#,
)
.into_response()
}
pub fn create_redfish_router(state: Arc<AppState>) -> 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)
}

View File

@@ -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<AppState>) -> Router<Arc<AppState>> {
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<SessionService> {
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<Arc<AppState>>) -> 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<Arc<AppState>>,
Json(req): Json<SessionCreateRequest>,
) -> 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<Arc<AppState>>,
Path(session_id): Path<String>,
) -> 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()
}
}
}

View File

@@ -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<AppState>) -> Router<Arc<AppState>> {
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<Collection<ODataLink>> {
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<Arc<AppState>>,
Path(system_id): Path<String>,
) -> 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<Arc<AppState>>,
Path(system_id): Path<String>,
Json(req): Json<ComputerSystemPatchRequest>,
) -> 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<Arc<AppState>>,
Path(system_id): Path<String>,
Json(req): Json<ResetRequest>,
) -> 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<String>,
) -> 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()
}

View File

@@ -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<AppState>) -> Router<Arc<AppState>> {
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<String>) -> 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<Arc<AppState>>,
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<Arc<AppState>>,
Path((manager_id, media_id)): Path<(String, String)>,
Json(req): Json<InsertMediaRequest>,
) -> 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<Arc<AppState>>,
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()
}
}
}

627
src/redfish/schema.rs Normal file
View File

@@ -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<String>,
}
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<T: Serialize> {
#[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<T>,
}
#[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<Value>,
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<String>,
pub boot_source_override_target: Option<String>,
pub uefi_target_boot_source_override: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct ProcessorSummary {
pub count: Option<u32>,
pub logical_processor_count: Option<u32>,
pub model: String,
pub status: Status,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct MemorySummary {
pub total_system_memory_gi_b: Option<f64>,
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<ODataLink>,
pub managed_by: Vec<ODataLink>,
}
#[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<BootPatch>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct BootPatch {
#[serde(default)]
pub boot_source_override_enabled: Option<String>,
#[serde(default)]
pub boot_source_override_target: Option<String>,
#[serde(default)]
pub boot_source_override_mode: Option<String>,
#[serde(default)]
pub uefi_target_boot_source_override: Option<String>,
}
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<String>,
}
#[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<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct ManagerLinks {
pub manager_for_servers: Vec<ODataLink>,
pub manager_for_chassis: Vec<ODataLink>,
}
#[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<String>,
pub connected_via: Option<String>,
pub inserted: bool,
pub image: Option<String>,
pub image_name: Option<String>,
pub write_protected: bool,
pub transfer_method: Option<String>,
pub transfer_protocol_type: Option<String>,
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<bool>,
#[serde(default)]
pub transfer_method: Option<String>,
#[serde(default)]
pub transfer_protocol_type: Option<String>,
pub media_types: Option<Vec<String>>,
pub inserted: Option<bool>,
pub user_name: Option<String>,
pub password: Option<String>,
}
#[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<ODataLink>,
pub managed_by: Vec<ODataLink>,
}
#[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<PowerControl>,
}
#[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<f64>,
pub power_capacity_watts: Option<f64>,
pub power_requested_watts: Option<f64>,
pub power_metrics: Option<PowerMetric>,
pub status: Status,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "PascalCase")]
pub struct PowerMetric {
pub interval_in_min: u32,
pub min_consumed_watts: Option<f64>,
pub max_consumed_watts: Option<f64>,
pub average_consumed_watts: Option<f64>,
}
#[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<String>,
pub registry_prefixes: Vec<String>,
pub subordinate_resources: bool,
#[serde(rename = "SSEFilterPropertiesSupported")]
pub sse_filter_properties_supported: SseFilterPropertiesSupported,
pub server_sent_event_uri: Option<String>,
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<RedfishExtendedInfo>,
}
#[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![],
},
}
}
}

View File

@@ -6,6 +6,7 @@ mod audio;
mod auth; mod auth;
mod hid; mod hid;
mod msd; mod msd;
mod redfish;
mod rtsp; mod rtsp;
mod rustdesk; mod rustdesk;
mod stream; 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 auth::{get_auth_config, update_auth_config};
pub use hid::{get_hid_config, update_hid_config}; pub use hid::{get_hid_config, update_hid_config};
pub use msd::{get_msd_config, update_msd_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 rtsp::{get_rtsp_config, get_rtsp_status, update_rtsp_config};
pub use rustdesk::{ pub use rustdesk::{
get_device_password, get_rustdesk_config, get_rustdesk_status, regenerate_device_id, get_device_password, get_rustdesk_config, get_rustdesk_status, regenerate_device_id,

View File

@@ -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<Arc<AppState>>) -> Json<RedfishConfigResponse> {
Json(RedfishConfigResponse {
enabled: state.config.get().redfish.enabled,
})
}
pub async fn update_redfish_config(
State(state): State<Arc<AppState>>,
Json(req): Json<RedfishConfigUpdate>,
) -> Result<Json<RedfishConfigResponse>> {
state
.config
.update(|config| {
req.apply_to(&mut config.redfish);
})
.await?;
Ok(Json(RedfishConfigResponse {
enabled: state.config.get().redfish.enabled,
}))
}

View File

@@ -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<bool>,
}
impl RedfishConfigUpdate {
pub fn apply_to(&self, config: &mut crate::config::RedfishConfig) {
if let Some(enabled) = self.enabled {
config.enabled = enabled;
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -18,6 +18,15 @@ use crate::hid::websocket::ws_hid_handler;
use crate::state::AppState; use crate::state::AppState;
pub fn create_router(state: Arc<AppState>) -> Router { pub fn create_router(state: Arc<AppState>) -> 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() let cors = CorsLayer::new()
.allow_origin(Any) .allow_origin(Any)
.allow_methods(Any) .allow_methods(Any)
@@ -137,6 +146,9 @@ pub fn create_router(state: Arc<AppState>) -> Router {
// Auth configuration // Auth configuration
.route("/config/auth", get(handlers::config::get_auth_config)) .route("/config/auth", get(handlers::config::get_auth_config))
.route("/config/auth", patch(handlers::config::update_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 // System control
.route("/system/restart", post(handlers::system_restart)) .route("/system/restart", post(handlers::system_restart))
.route("/update/overview", get(handlers::update_overview)) .route("/update/overview", get(handlers::update_overview))
@@ -245,10 +257,15 @@ pub fn create_router(state: Arc<AppState>) -> Router {
let static_routes = super::static_files::static_file_router(); let static_routes = super::static_files::static_file_router();
// Main router // Main router
Router::new() let main_router = Router::new()
.nest("/api", api_routes) .nest("/api", api_routes)
.merge(static_routes) .merge(static_routes)
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer(cors) .layer(cors)
.with_state(state) .with_state(state);
match redfish_router {
Some(rf) => main_router.merge(rf),
None => main_router,
}
} }

View File

@@ -392,6 +392,24 @@ export const webConfigApi = {
}), }),
} }
export interface RedfishConfigResponse {
enabled: boolean
}
export interface RedfishConfigUpdate {
enabled?: boolean
}
export const redfishConfigApi = {
get: () => request<RedfishConfigResponse>('/config/redfish'),
update: (config: RedfishConfigUpdate) =>
request<RedfishConfigResponse>('/config/redfish', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}
export const systemApi = { export const systemApi = {
/** /**
* 重启系统 * 重启系统

View File

@@ -678,6 +678,7 @@ export {
atxConfigApi, atxConfigApi,
audioConfigApi, audioConfigApi,
extensionsApi, extensionsApi,
redfishConfigApi,
rustdeskConfigApi, rustdeskConfigApi,
rtspConfigApi, rtspConfigApi,
webConfigApi, webConfigApi,
@@ -686,6 +687,8 @@ export {
type RustDeskConfigUpdate, type RustDeskConfigUpdate,
type RustDeskPasswordResponse, type RustDeskPasswordResponse,
type RtspConfigResponse, type RtspConfigResponse,
type RedfishConfigResponse,
type RedfishConfigUpdate,
type RtspConfigUpdate, type RtspConfigUpdate,
type RtspStatusResponse, type RtspStatusResponse,
type WebConfig, type WebConfig,

View File

@@ -569,6 +569,10 @@ export default {
httpsEnabledDesc: 'Serve over an encrypted connection. A self-signed certificate is generated automatically if none is provided.', httpsEnabledDesc: 'Serve over an encrypted connection. A self-signed certificate is generated automatically if none is provided.',
portConfig: 'Port & Protocol', portConfig: 'Port & Protocol',
portConfigDesc: 'The service listens on a single port at a time, determined by the HTTPS toggle', 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)', httpPortReserved: 'HTTP port (reserved)',
httpsPortReserved: 'HTTPS port (reserved)', httpsPortReserved: 'HTTPS port (reserved)',
portActive: 'Active', portActive: 'Active',

View File

@@ -568,6 +568,10 @@ export default {
httpsEnabledDesc: '使用加密连接对外提供服务,未配置证书时自动生成自签名证书', httpsEnabledDesc: '使用加密连接对外提供服务,未配置证书时自动生成自签名证书',
portConfig: '端口与协议', portConfig: '端口与协议',
portConfigDesc: '服务一次仅监听一个端口,由 HTTPS 开关决定生效端口', portConfigDesc: '服务一次仅监听一个端口,由 HTTPS 开关决定生效端口',
redfishTitle: 'Redfish API',
redfishDesc: 'DMTF Redfish 标准管理接口',
redfishEnabled: '启用 Redfish API',
redfishEnabledDesc: '开启后可通过 /redfish/v1/ 访问标准 Redfish 管理接口',
httpPortReserved: 'HTTP 端口(备用)', httpPortReserved: 'HTTP 端口(备用)',
httpsPortReserved: 'HTTPS 端口(备用)', httpsPortReserved: 'HTTPS 端口(备用)',
portActive: '当前生效', portActive: '当前生效',

View File

@@ -12,6 +12,7 @@ import {
streamApi, streamApi,
atxConfigApi, atxConfigApi,
extensionsApi, extensionsApi,
redfishConfigApi,
systemApi, systemApi,
updateApi, updateApi,
usbApi, usbApi,
@@ -295,6 +296,8 @@ const webServerConfig = ref<WebConfig>({
has_custom_cert: false, has_custom_cert: false,
}) })
const webServerLoading = ref(false) const webServerLoading = ref(false)
const redfishEnabled = ref(false)
const redfishSaving = ref(false)
const sslCertPem = ref('') const sslCertPem = ref('')
const sslKeyPem = ref('') const sslKeyPem = ref('')
const certSaving = ref(false) 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() { async function saveWebServerConfig() {
if (bindAddressError.value) return if (bindAddressError.value) return
webServerLoading.value = true webServerLoading.value = true
@@ -2087,6 +2114,7 @@ onMounted(async () => {
loadRustdeskPassword(), loadRustdeskPassword(),
loadRtspConfig(), loadRtspConfig(),
loadWebServerConfig(), loadWebServerConfig(),
loadRedfishConfig(),
loadUpdateOverview(), loadUpdateOverview(),
refreshUpdateStatus(), refreshUpdateStatus(),
fetchUsbDevices(), fetchUsbDevices(),
@@ -3237,9 +3265,35 @@ watch(() => route.query.tab, (tab) => {
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>
</div>
<!-- MSD Section --> <!-- Redfish API Card -->
<Card>
<CardHeader>
<CardTitle>{{ t('settings.redfishTitle') }}</CardTitle>
<CardDescription>{{ t('settings.redfishDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<div class="flex items-start justify-between gap-4">
<div class="space-y-0.5">
<Label>{{ t('settings.redfishEnabled') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.redfishEnabledDesc') }}</p>
</div>
<Switch v-model="redfishEnabled" />
</div>
</CardContent>
<CardFooter class="flex items-center justify-between gap-3 border-t pt-4">
<p class="text-xs text-muted-foreground flex items-center gap-1.5">
<AlertTriangle class="h-3.5 w-3.5 text-amber-500" />
{{ t('settings.restartRequiredHint') }}
</p>
<Button @click="saveRedfishConfig" :disabled="redfishSaving || autoRestarting">
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
<Save v-else class="h-4 w-4 mr-2" />
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
</Button>
</CardFooter>
</Card>
</div>
<div v-show="activeSection === 'msd' && config.msd_enabled" class="space-y-6"> <div v-show="activeSection === 'msd' && config.msd_enabled" class="space-y-6">
<Card> <Card>
<CardHeader> <CardHeader>