mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 03:32:00 +08:00
Merge pull request #257 from btzen/redfish
feat: 实现 Redfish API 标准接口;支持通过前端开关控制 Redfish 服务
This commit is contained in:
@@ -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"] }
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
84
src/redfish/auth.rs
Normal file
84
src/redfish/auth.rs
Normal 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
3
src/redfish/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod auth;
|
||||
pub mod routes;
|
||||
pub mod schema;
|
||||
155
src/redfish/routes/account.rs
Normal file
155
src/redfish/routes/account.rs
Normal 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()
|
||||
}
|
||||
92
src/redfish/routes/chassis.rs
Normal file
92
src/redfish/routes/chassis.rs
Normal 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
102
src/redfish/routes/event.rs
Normal 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
|
||||
}
|
||||
120
src/redfish/routes/managers.rs
Normal file
120
src/redfish/routes/managers.rs
Normal 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
211
src/redfish/routes/mod.rs
Normal 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)
|
||||
}
|
||||
160
src/redfish/routes/session.rs
Normal file
160
src/redfish/routes/session.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
220
src/redfish/routes/systems.rs
Normal file
220
src/redfish/routes/systems.rs
Normal 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()
|
||||
}
|
||||
224
src/redfish/routes/virtual_media.rs
Normal file
224
src/redfish/routes/virtual_media.rs
Normal 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
627
src/redfish/schema.rs
Normal 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![],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
29
src/web/handlers/config/redfish.rs
Normal file
29
src/web/handlers/config/redfish.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
@@ -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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -18,6 +18,15 @@ use crate::hid::websocket::ws_hid_handler;
|
||||
use crate::state::AppState;
|
||||
|
||||
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()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
@@ -137,6 +146,9 @@ pub fn create_router(state: Arc<AppState>) -> 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<AppState>) -> 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
* 重启系统
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '当前生效',
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
streamApi,
|
||||
atxConfigApi,
|
||||
extensionsApi,
|
||||
redfishConfigApi,
|
||||
systemApi,
|
||||
updateApi,
|
||||
usbApi,
|
||||
@@ -295,6 +296,8 @@ const webServerConfig = ref<WebConfig>({
|
||||
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,6 +3265,34 @@ watch(() => route.query.tab, (tab) => {
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- MSD Section -->
|
||||
|
||||
Reference in New Issue
Block a user