mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-11-01 18:30:51 +08:00
feat(system): 添加设备信息及重启关机控制
关于页展示宿主系统、处理器、架构、内核和资源占用,合并同一文件系统上的系统与 MSD 容量。 新增 Linux 宿主机重启和关机接口,在其他设置中提供二次确认、请求互斥及失败提示。 修正 Linux 磁盘已用空间计算,并补充设备信息和电源控制的后端单元测试。 验证:独立暂存快照前端构建通过;6 项后端测试通过;设置页保存、系统信息及模拟电源控制浏览器测试通过。
This commit is contained in:
143
src/diagnostics/host.rs
Normal file
143
src/diagnostics/host.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
//! Read-only host metadata and filesystem identity for the About page.
|
||||
use super::{get_disk_space, DiskSpaceInfo};
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct HostInfo {
|
||||
pub operating_system: String,
|
||||
pub architecture: &'static str,
|
||||
pub kernel: Option<String>,
|
||||
pub cpu_count: usize,
|
||||
pub storage: StorageInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct StorageInfo {
|
||||
pub system: Option<DiskSpaceInfo>,
|
||||
pub msd: Option<DiskSpaceInfo>,
|
||||
/// Unknown identity must never be interpreted as a shared filesystem.
|
||||
pub shared_partition: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn get_host_info(msd_path: &Path) -> HostInfo {
|
||||
let operating_system = std::fs::read_to_string("/etc/os-release")
|
||||
.ok()
|
||||
.and_then(|text| os_release_name(&text))
|
||||
.unwrap_or_else(|| std::env::consts::OS.to_string());
|
||||
let kernel = std::fs::read_to_string("/proc/sys/kernel/osrelease")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
HostInfo {
|
||||
operating_system,
|
||||
architecture: std::env::consts::ARCH,
|
||||
kernel,
|
||||
cpu_count: std::thread::available_parallelism()
|
||||
.map(usize::from)
|
||||
.unwrap_or(1),
|
||||
storage: storage_info(system_root().as_path(), msd_path),
|
||||
}
|
||||
}
|
||||
|
||||
fn system_root() -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
PathBuf::from(std::env::var_os("SystemDrive").unwrap_or_else(|| "C:".into())).join("\\")
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
PathBuf::from("/")
|
||||
}
|
||||
}
|
||||
|
||||
fn os_release_name(text: &str) -> Option<String> {
|
||||
text.lines().find_map(|line| {
|
||||
let value = line
|
||||
.strip_prefix("PRETTY_NAME=")?
|
||||
.trim()
|
||||
.trim_matches(['\'', '"']);
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
// MSD directories may not have been created yet. Read their containing filesystem
|
||||
// without creating directories or changing permissions.
|
||||
fn existing_ancestor(path: &Path) -> Option<PathBuf> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut candidate = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir().ok()?.join(path)
|
||||
};
|
||||
loop {
|
||||
match std::fs::metadata(&candidate) {
|
||||
Ok(_) => return Some(candidate),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
if !candidate.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_info(system: &Path, msd: &Path) -> StorageInfo {
|
||||
let system = existing_ancestor(system);
|
||||
let msd = existing_ancestor(msd);
|
||||
StorageInfo {
|
||||
system: system.as_deref().and_then(|path| get_disk_space(path).ok()),
|
||||
msd: msd.as_deref().and_then(|path| get_disk_space(path).ok()),
|
||||
shared_partition: system
|
||||
.as_deref()
|
||||
.zip(msd.as_deref())
|
||||
.and_then(|(a, b)| same_filesystem(a, b)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn same_filesystem(a: &Path, b: &Path) -> Option<bool> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
Some(std::fs::metadata(a).ok()?.dev() == std::fs::metadata(b).ok()?.dev())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn same_filesystem(_a: &Path, _b: &Path) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn distribution_name_handles_quotes_and_missing_values() {
|
||||
assert_eq!(
|
||||
os_release_name("NAME=Linux\nPRETTY_NAME=\"OpenWrt 24.10\"\n"),
|
||||
Some("OpenWrt 24.10".into())
|
||||
);
|
||||
assert_eq!(os_release_name("PRETTY_NAME=\n"), None);
|
||||
assert_eq!(os_release_name("NAME=Linux\n"), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn nested_msd_directory_shares_its_existing_parent_filesystem() {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let missing = parent.path().join("not-created/msd");
|
||||
let storage = storage_info(parent.path(), &missing);
|
||||
assert_eq!(storage.shared_partition, Some(true));
|
||||
assert!(storage.system.is_some() && storage.msd.is_some());
|
||||
assert!(!missing.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_msd_is_not_a_duplicate_system_partition() {
|
||||
let storage = storage_info(&system_root(), Path::new(""));
|
||||
assert!(storage.msd.is_none());
|
||||
assert_eq!(storage.shared_partition, None);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ pub fn get_disk_space(path: &std::path::Path) -> Result<DiskSpaceInfo> {
|
||||
let block_size = stat.block_size() as u64;
|
||||
let total = stat.blocks() as u64 * block_size;
|
||||
let available = stat.blocks_available() as u64 * block_size;
|
||||
let used = total - available;
|
||||
let used = total.saturating_sub(stat.blocks_free() as u64 * block_size);
|
||||
|
||||
Ok(DiskSpaceInfo {
|
||||
total,
|
||||
|
||||
@@ -4,6 +4,9 @@ use serde::Serialize;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
mod host;
|
||||
pub use host::{get_host_info, HostInfo};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeviceInfo {
|
||||
pub hostname: String,
|
||||
|
||||
151
src/web/handlers/host_power.rs
Normal file
151
src/web/handlers/host_power.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//! Host power controls are separate from restarting the One-KVM process.
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HostPowerRequest {
|
||||
#[serde(default)]
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum HostPowerAction {
|
||||
Reboot,
|
||||
Poweroff,
|
||||
}
|
||||
|
||||
impl HostPowerAction {
|
||||
fn command(self) -> &'static str {
|
||||
match self {
|
||||
Self::Reboot => "reboot",
|
||||
Self::Poweroff => "poweroff",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn confirmed_request(
|
||||
request: HostPowerRequest,
|
||||
action: HostPowerAction,
|
||||
execute: impl FnOnce(HostPowerAction) -> Result<()>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
if !request.confirmed {
|
||||
return Err(AppError::BadRequest(
|
||||
"Explicit confirmation is required for host power control.".into(),
|
||||
));
|
||||
}
|
||||
execute(action)?;
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: Some("Host power request accepted.".into()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn schedule_action(action: HostPowerAction) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
static PENDING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
// These are system commands; do not run them through a user-controlled shell.
|
||||
if unsafe { libc::geteuid() } != 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"Host power control requires the One-KVM service to run as root.".into(),
|
||||
));
|
||||
}
|
||||
let command = action.command();
|
||||
let executable = ["/sbin", "/usr/sbin", "/bin", "/usr/bin"]
|
||||
.into_iter()
|
||||
.map(|directory| std::path::Path::new(directory).join(command))
|
||||
.find(|path| {
|
||||
std::fs::metadata(path)
|
||||
.map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.ok_or_else(|| AppError::NotFound(format!("Host command {command} is unavailable.")))?;
|
||||
if PENDING.swap(true, Ordering::AcqRel) {
|
||||
return Err(AppError::Conflict(
|
||||
"A host power request is already pending.".into(),
|
||||
));
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
// Let the accepted response reach the browser before the connection ends.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(750)).await;
|
||||
let result = tokio::process::Command::new(executable)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.await;
|
||||
match result {
|
||||
Ok(output) if output.status.success() => info!("Host {command} command accepted"),
|
||||
Ok(output) => {
|
||||
PENDING.store(false, Ordering::Release);
|
||||
warn!(
|
||||
"Host {command} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
PENDING.store(false, Ordering::Release);
|
||||
warn!("Could not execute host {command}: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn schedule_action(_action: HostPowerAction) -> Result<()> {
|
||||
Err(AppError::BadRequest(
|
||||
"Host power control is only available on Linux.".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn host_reboot(Json(request): Json<HostPowerRequest>) -> Result<Json<LoginResponse>> {
|
||||
confirmed_request(request, HostPowerAction::Reboot, schedule_action)
|
||||
}
|
||||
|
||||
pub async fn host_poweroff(Json(request): Json<HostPowerRequest>) -> Result<Json<LoginResponse>> {
|
||||
confirmed_request(request, HostPowerAction::Poweroff, schedule_action)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_or_false_confirmation_cannot_reach_the_executor() {
|
||||
for body in ["{}", r#"{"confirmed":false}"#] {
|
||||
let request = serde_json::from_str(body).unwrap();
|
||||
let result = confirmed_request(request, HostPowerAction::Poweroff, |_| {
|
||||
panic!("Unconfirmed request must never execute a system command")
|
||||
});
|
||||
assert!(matches!(result, Err(AppError::BadRequest(_))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmation_dispatches_only_the_selected_host_command() {
|
||||
for (action, expected) in [
|
||||
(HostPowerAction::Reboot, "reboot"),
|
||||
(HostPowerAction::Poweroff, "poweroff"),
|
||||
] {
|
||||
let mut dispatched = None;
|
||||
let Json(response) =
|
||||
confirmed_request(HostPowerRequest { confirmed: true }, action, |a| {
|
||||
dispatched = Some(a.command());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(response.success);
|
||||
assert_eq!(dispatched, Some(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn executor_errors_are_not_reported_as_accepted() {
|
||||
let result = confirmed_request(
|
||||
HostPowerRequest { confirmed: true },
|
||||
HostPowerAction::Reboot,
|
||||
|_| Err(AppError::Conflict("pending".into())),
|
||||
);
|
||||
assert!(matches!(result, Err(AppError::Conflict(_))));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod atx_api;
|
||||
mod audio_api;
|
||||
mod auth;
|
||||
mod hid_api;
|
||||
mod host_power;
|
||||
mod inventory;
|
||||
mod mcp;
|
||||
#[cfg(unix)]
|
||||
@@ -23,6 +24,7 @@ pub use atx_api::*;
|
||||
pub use audio_api::*;
|
||||
pub use auth::*;
|
||||
pub use hid_api::*;
|
||||
pub use host_power::*;
|
||||
pub use inventory::*;
|
||||
pub use mcp::*;
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -26,6 +26,7 @@ pub struct SystemInfo {
|
||||
pub disk_space: Option<DiskSpaceInfo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub device_info: Option<DeviceInfo>,
|
||||
pub host: crate::diagnostics::HostInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -112,5 +113,6 @@ pub async fn system_info(State(state): State<Arc<AppState>>) -> Json<SystemInfo>
|
||||
},
|
||||
disk_space,
|
||||
device_info,
|
||||
host: crate::diagnostics::get_host_info(&config.msd.msd_dir_path()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -221,6 +221,8 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
)
|
||||
// System control
|
||||
.route("/system/restart", post(handlers::system_restart))
|
||||
.route("/system/reboot", post(handlers::host_reboot))
|
||||
.route("/system/poweroff", post(handlers::host_poweroff))
|
||||
.route("/update/overview", get(handlers::update_overview))
|
||||
.route("/update/upgrade", post(handlers::update_upgrade))
|
||||
.route("/update/status", get(handlers::update_status))
|
||||
|
||||
Reference in New Issue
Block a user