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:
mofeng-git
2026-04-12 19:26:52 +08:00
parent d0c0852fbb
commit 9653e16a68
27 changed files with 1527 additions and 629 deletions

View File

@@ -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(&current).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());
}
}

View File

@@ -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();
}
}

View File

@@ -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