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

@@ -126,6 +126,15 @@ impl SessionStore {
Ok(result.rows_affected())
}
/// Delete all sessions for a specific user
pub async fn delete_by_user_id(&self, user_id: &str) -> Result<u64> {
let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?1")
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
/// List all session IDs
pub async fn list_ids(&self) -> Result<Vec<String>> {
let rows: Vec<(String,)> = sqlx::query_as("SELECT id FROM sessions")

View File

@@ -703,7 +703,9 @@ impl StreamConfig {
}
}
/// Web server configuration
/// Web server configuration persisted in the database (includes on-disk TLS paths).
///
/// The HTTP API for `/api/config/web` uses `WebConfigResponse` instead: no path fields, includes `has_custom_cert`.
#[typeshare]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]

View File

@@ -280,6 +280,9 @@ impl ExtensionManager {
ExtensionId::Gostc => {
let c = &config.gostc;
if c.addr.trim().is_empty() {
return Err("GOSTC server address is required".into());
}
if c.key.is_empty() {
return Err("GOSTC client key is required".into());
}
@@ -291,10 +294,8 @@ impl ExtensionManager {
args.push("--tls=true".to_string());
}
// Add server address
if !c.addr.is_empty() {
args.extend(["-addr".to_string(), c.addr.clone()]);
}
// Server address (validated non-empty above)
args.extend(["-addr".to_string(), c.addr.trim().to_string()]);
// Add client key
args.extend(["-key".to_string(), c.key.clone()]);
@@ -375,7 +376,11 @@ impl ExtensionManager {
.filter_map(|id| {
let should_run = match id {
ExtensionId::Ttyd => config.ttyd.enabled,
ExtensionId::Gostc => config.gostc.enabled && !config.gostc.key.is_empty(),
ExtensionId::Gostc => {
config.gostc.enabled
&& !config.gostc.key.is_empty()
&& !config.gostc.addr.trim().is_empty()
}
ExtensionId::Easytier => {
config.easytier.enabled && !config.easytier.network_name.is_empty()
}
@@ -435,6 +440,7 @@ impl ExtensionManager {
if config.gostc.enabled
&& !config.gostc.key.is_empty()
&& !config.gostc.addr.trim().is_empty()
&& self.check_available(ExtensionId::Gostc)
{
start_futures.push(Box::pin(async {

View File

@@ -121,7 +121,7 @@ impl Default for TtydConfig {
pub struct GostcConfig {
/// Enable auto-start
pub enabled: bool,
/// Server address (e.g., gostc.mofeng.run)
/// Server address (hostname or IP)
pub addr: String,
/// Client key from GOSTC management panel
#[serde(skip_serializing_if = "String::is_empty")]
@@ -134,7 +134,7 @@ impl Default for GostcConfig {
fn default() -> Self {
Self {
enabled: false,
addr: "gostc.mofeng.run".to_string(),
addr: String::new(),
key: String::new(),
tls: true,
}

View File

@@ -1,10 +1,11 @@
use std::collections::HashSet;
use std::io::Write;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use axum_server::tls_rustls::RustlsConfig;
use clap::{Parser, ValueEnum};
use clap::{Args, Parser, Subcommand, ValueEnum};
use futures::{stream::FuturesUnordered, StreamExt};
use rustls::crypto::{ring, CryptoProvider};
use tokio::sync::{broadcast, mpsc};
@@ -49,6 +50,10 @@ enum LogLevel {
#[command(name = "one-kvm")]
#[command(version, about = "A open and lightweight IP-KVM solution", long_about = None)]
struct CliArgs {
/// User management commands
#[command(subcommand)]
command: Option<CliCommand>,
/// Listen address (overrides database config)
#[arg(short = 'a', long, value_name = "ADDRESS")]
address: Option<String>,
@@ -86,6 +91,24 @@ struct CliArgs {
verbose: u8,
}
#[derive(Subcommand, Debug)]
enum CliCommand {
/// Manage local users
User(UserCommand),
}
#[derive(Args, Debug)]
struct UserCommand {
#[command(subcommand)]
action: UserAction,
}
#[derive(Subcommand, Debug)]
enum UserAction {
/// Set password for the single local user (interactive terminal prompt)
SetPassword,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Parse command line arguments
@@ -101,9 +124,15 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Starting One-KVM v{}", env!("CARGO_PKG_VERSION"));
// Determine data directory (CLI arg takes precedence)
let data_dir = args.data_dir.unwrap_or_else(get_data_dir);
let data_dir = args.data_dir.clone().unwrap_or_else(get_data_dir);
tracing::info!("Data directory: {}", data_dir.display());
// Run one-off CLI command and exit.
if let Some(command) = args.command {
run_cli_command(command, data_dir).await?;
return Ok(());
}
// Ensure data directory exists
tokio::fs::create_dir_all(&data_dir).await?;
@@ -765,6 +794,81 @@ fn get_data_dir() -> PathBuf {
PathBuf::from("/etc/one-kvm")
}
async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Result<()> {
tokio::fs::create_dir_all(&data_dir).await?;
let db_path = data_dir.join("one-kvm.db");
let config_store = ConfigStore::new(&db_path).await?;
let users = UserStore::new(config_store.pool().clone());
let sessions = SessionStore::new(config_store.pool().clone(), 0);
match command {
CliCommand::User(user) => run_user_action(user.action, &users, &sessions).await,
}
}
async fn run_user_action(
action: UserAction,
users: &UserStore,
sessions: &SessionStore,
) -> anyhow::Result<()> {
match action {
UserAction::SetPassword => set_user_password(users, sessions).await,
}
}
async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> {
let all = users.list().await?;
let user = match all.len() {
0 => anyhow::bail!("No local user exists yet; complete setup in the web UI first."),
1 => &all[0],
_ => anyhow::bail!(
"Expected exactly one local user (single-user design), found {}. Remove extra users from the database or contact support.",
all.len()
),
};
let new_password = read_new_password_interactive()?;
if new_password.len() < 4 {
anyhow::bail!("Password must be at least 4 characters");
}
users.update_password(&user.id, &new_password).await?;
let revoked = sessions.delete_by_user_id(&user.id).await?;
tracing::info!(
"Password updated for user '{}' and {} sessions revoked",
user.username,
revoked
);
println!(
"Password updated for user '{}' (revoked {} sessions).",
user.username, revoked
);
Ok(())
}
fn read_new_password_interactive() -> anyhow::Result<String> {
let once = |label: &str| -> anyhow::Result<String> {
print!("{}", label);
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let s = line.trim_end_matches(['\r', '\n']).to_string();
if s.is_empty() {
anyhow::bail!("Password cannot be empty");
}
Ok(s)
};
let a = once("New password: ")?;
let b = once("Confirm password: ")?;
if a != b {
anyhow::bail!("Passwords do not match");
}
Ok(a)
}
/// Resolve bind IPs from config, preferring bind_addresses when set.
fn resolve_bind_addresses(web: &config::WebConfig) -> anyhow::Result<Vec<IpAddr>> {
let raw_addrs = if !web.bind_addresses.is_empty() {

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