mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-06-14 19:51:58 +08:00
feat: CLI 改密、自定义 TLS、移动端适配与扩展校验
- 新增 one-kvm user set-password(交互式),改密后吊销该用户全部会话 - /api/config/web 支持 PEM 证书/密钥上传与清除,响应含 has_custom_cert - 移动端:ActionBar 溢出菜单、ATX/粘贴底部 Sheet、BrandMark 与控制台等响应式优化 - GOSTC:校验服务器地址非空,管理器启动条件与 HTTP 热更新一致 - RustDesk:中继密钥 relay_key 校验为标准 Base64 且解码后恰好 32 字节 - StatusCard、InfoBar:合并精简冗余状态信息
This commit is contained in:
@@ -3,7 +3,8 @@ use crate::error::AppError;
|
||||
use crate::rtsp::RtspServiceStatus;
|
||||
use crate::rustdesk::config::RustDeskConfig;
|
||||
use crate::video::encoder::BitratePreset;
|
||||
use serde::Deserialize;
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use typeshare::typeshare;
|
||||
|
||||
@@ -660,6 +661,26 @@ impl AudioConfigUpdate {
|
||||
}
|
||||
|
||||
// ===== RustDesk Config =====
|
||||
|
||||
/// hbbs/hbbr `-k` relay key: standard Base64 encoding of exactly 32 bytes (typically 44 chars with padding).
|
||||
fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> {
|
||||
let decoded = STANDARD.decode(key.as_bytes()).map_err(|_| {
|
||||
AppError::BadRequest(
|
||||
"Relay key must be standard Base64 (32 raw bytes, e.g. hbbs/hbbr -k output)".into(),
|
||||
)
|
||||
})?;
|
||||
if decoded.len() != 32 {
|
||||
return Err(AppError::BadRequest(
|
||||
format!(
|
||||
"Relay key must decode to exactly 32 bytes (got {} bytes after Base64 decode)",
|
||||
decoded.len()
|
||||
)
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RustDeskConfigUpdate {
|
||||
@@ -698,6 +719,12 @@ impl RustDeskConfigUpdate {
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(ref key) = self.relay_key {
|
||||
let trimmed = key.trim();
|
||||
if !trimmed.is_empty() {
|
||||
validate_rustdesk_relay_key(trimmed)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -716,10 +743,11 @@ impl RustDeskConfigUpdate {
|
||||
};
|
||||
}
|
||||
if let Some(ref key) = self.relay_key {
|
||||
config.relay_key = if key.is_empty() {
|
||||
let trimmed = key.trim();
|
||||
config.relay_key = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(key.clone())
|
||||
Some(trimmed.to_string())
|
||||
};
|
||||
}
|
||||
if let Some(ref password) = self.device_password {
|
||||
@@ -849,6 +877,45 @@ impl RtspConfigUpdate {
|
||||
}
|
||||
|
||||
// ===== Web Config =====
|
||||
|
||||
/// Web server settings returned by `GET` / `PATCH /api/config/web`.
|
||||
///
|
||||
/// Public API shape: certificate paths on disk are not exposed. The full stored model is `WebConfig` in `config::schema`.
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebConfigResponse {
|
||||
pub http_port: u16,
|
||||
pub https_port: u16,
|
||||
pub bind_addresses: Vec<String>,
|
||||
pub bind_address: String,
|
||||
pub https_enabled: bool,
|
||||
/// Whether a custom TLS certificate is active (non-empty cert + key paths in stored config).
|
||||
pub has_custom_cert: bool,
|
||||
}
|
||||
|
||||
impl WebConfigResponse {
|
||||
pub fn from_stored(web: &WebConfig) -> Self {
|
||||
let has_custom_cert = web
|
||||
.ssl_cert_path
|
||||
.as_deref()
|
||||
.map(|p| !p.is_empty())
|
||||
.unwrap_or(false)
|
||||
&& web
|
||||
.ssl_key_path
|
||||
.as_deref()
|
||||
.map(|p| !p.is_empty())
|
||||
.unwrap_or(false);
|
||||
Self {
|
||||
http_port: web.http_port,
|
||||
https_port: web.https_port,
|
||||
bind_addresses: web.bind_addresses.clone(),
|
||||
bind_address: web.bind_address.clone(),
|
||||
https_enabled: web.https_enabled,
|
||||
has_custom_cert,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WebConfigUpdate {
|
||||
@@ -857,6 +924,12 @@ pub struct WebConfigUpdate {
|
||||
pub bind_addresses: Option<Vec<String>>,
|
||||
pub bind_address: Option<String>,
|
||||
pub https_enabled: Option<bool>,
|
||||
/// PEM-encoded certificate content (must be provided together with ssl_key_pem)
|
||||
pub ssl_cert_pem: Option<String>,
|
||||
/// PEM-encoded private key content (must be provided together with ssl_cert_pem)
|
||||
pub ssl_key_pem: Option<String>,
|
||||
/// Set to true to remove the custom certificate and revert to self-signed
|
||||
pub clear_custom_cert: Option<bool>,
|
||||
}
|
||||
|
||||
impl WebConfigUpdate {
|
||||
@@ -883,6 +956,22 @@ impl WebConfigUpdate {
|
||||
return Err(AppError::BadRequest("Invalid bind address".into()));
|
||||
}
|
||||
}
|
||||
// Cert and key must be provided together (cryptographic validity is checked in the
|
||||
// handler via `RustlsConfig::from_pem`, same stack as the running HTTPS server).
|
||||
match (&self.ssl_cert_pem, &self.ssl_key_pem) {
|
||||
(Some(_cert), Some(_key)) => {}
|
||||
(Some(_), None) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"ssl_key_pem is required when ssl_cert_pem is provided".into(),
|
||||
));
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"ssl_cert_pem is required when ssl_key_pem is provided".into(),
|
||||
));
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -907,6 +996,8 @@ impl WebConfigUpdate {
|
||||
if let Some(enabled) = self.https_enabled {
|
||||
config.https_enabled = enabled;
|
||||
}
|
||||
// ssl_cert_pem, ssl_key_pem, clear_custom_cert are handled at the handler level
|
||||
// (they require async file I/O before updating config paths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -988,4 +1079,30 @@ mod tests {
|
||||
|
||||
assert!(update.validate_with_current(¤t).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() {
|
||||
let update = RustDeskConfigUpdate {
|
||||
enabled: None,
|
||||
rendezvous_server: None,
|
||||
relay_server: None,
|
||||
relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()),
|
||||
device_password: None,
|
||||
};
|
||||
assert!(update.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rustdesk_relay_key_rejects_non_32_byte_payload() {
|
||||
// Standard Base64 for 16 zero bytes (not 32).
|
||||
let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string();
|
||||
let update = RustDeskConfigUpdate {
|
||||
enabled: None,
|
||||
rendezvous_server: None,
|
||||
relay_server: None,
|
||||
relay_key: Some(not_32),
|
||||
device_password: None,
|
||||
};
|
||||
assert!(update.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,103 @@
|
||||
//! Web 服务器配置 Handler
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::WebConfig;
|
||||
use crate::error::Result;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::types::WebConfigUpdate;
|
||||
use super::types::{WebConfigResponse, WebConfigUpdate};
|
||||
|
||||
/// 获取 Web 配置
|
||||
pub async fn get_web_config(State(state): State<Arc<AppState>>) -> Json<WebConfig> {
|
||||
Json(state.config.get().web.clone())
|
||||
pub async fn get_web_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Json<WebConfigResponse> {
|
||||
Json(WebConfigResponse::from_stored(&state.config.get().web))
|
||||
}
|
||||
|
||||
/// 更新 Web 配置
|
||||
/// 更新 Web 配置(支持 PEM 证书上传)
|
||||
pub async fn update_web_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<WebConfigUpdate>,
|
||||
) -> Result<Json<WebConfig>> {
|
||||
) -> Result<Json<WebConfigResponse>> {
|
||||
req.validate()?;
|
||||
|
||||
// Determine certificate path changes (requires async file I/O before config update)
|
||||
// Some(Some((cert, key))) = write new cert
|
||||
// Some(None) = clear custom cert
|
||||
// None = no cert change
|
||||
let cert_path_update: Option<Option<(String, String)>> =
|
||||
if let (Some(cert_pem), Some(key_pem)) = (&req.ssl_cert_pem, &req.ssl_key_pem) {
|
||||
RustlsConfig::from_pem(cert_pem.as_bytes().to_vec(), key_pem.as_bytes().to_vec())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::BadRequest(
|
||||
format!(
|
||||
"Invalid TLS certificate or private key (PEM must match what the HTTPS server can load): {e}"
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let cert_dir = state.data_dir().join("certs");
|
||||
tokio::fs::create_dir_all(&cert_dir)
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to create cert dir: {e}")))?;
|
||||
let cert_path = cert_dir.join("custom.crt");
|
||||
let key_path = cert_dir.join("custom.key");
|
||||
tokio::fs::write(&cert_path, cert_pem.as_bytes())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to write certificate: {e}")))?;
|
||||
tokio::fs::write(&key_path, key_pem.as_bytes())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Failed to write private key: {e}")))?;
|
||||
Some(Some((
|
||||
cert_path.to_string_lossy().into_owned(),
|
||||
key_path.to_string_lossy().into_owned(),
|
||||
)))
|
||||
} else if req.clear_custom_cert.unwrap_or(false) {
|
||||
let cert_dir = state.data_dir().join("certs");
|
||||
let _ = tokio::fs::remove_file(cert_dir.join("custom.crt")).await;
|
||||
let _ = tokio::fs::remove_file(cert_dir.join("custom.key")).await;
|
||||
Some(None)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
.update(move |config| {
|
||||
req.apply_to(&mut config.web);
|
||||
match cert_path_update {
|
||||
Some(Some((cert_path, key_path))) => {
|
||||
config.web.ssl_cert_path = Some(cert_path);
|
||||
config.web.ssl_key_path = Some(key_path);
|
||||
}
|
||||
Some(None) => {
|
||||
config.web.ssl_cert_path = None;
|
||||
config.web.ssl_key_path = None;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(Json(state.config.get().web.clone()))
|
||||
Ok(Json(WebConfigResponse::from_stored(&state.config.get().web)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rustls::crypto::{ring, CryptoProvider};
|
||||
|
||||
#[tokio::test]
|
||||
async fn rustls_accepts_rcgen_self_signed_pem() {
|
||||
let _ = CryptoProvider::install_default(ring::default_provider());
|
||||
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
|
||||
let cert_pem = cert.cert.pem();
|
||||
let key_pem = cert.signing_key.serialize_pem();
|
||||
RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,12 +256,14 @@ pub async fn update_gostc_config(
|
||||
let new_config = state.config.get();
|
||||
let is_enabled = new_config.extensions.gostc.enabled;
|
||||
let has_key = !new_config.extensions.gostc.key.is_empty();
|
||||
let has_addr = !new_config.extensions.gostc.addr.trim().is_empty();
|
||||
|
||||
if was_enabled && !is_enabled {
|
||||
state.extensions.stop(ExtensionId::Gostc).await.ok();
|
||||
} else if !was_enabled
|
||||
&& is_enabled
|
||||
&& has_key
|
||||
&& has_addr
|
||||
&& state.extensions.check_available(ExtensionId::Gostc)
|
||||
{
|
||||
state
|
||||
|
||||
Reference in New Issue
Block a user