mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-13 02:54:26 +08:00
@@ -1,11 +1,7 @@
|
||||
#[cfg(all(unix, not(feature = "android")))]
|
||||
#[cfg(unix)]
|
||||
#[path = "capture_linux.rs"]
|
||||
mod imp;
|
||||
|
||||
#[cfg(feature = "android")]
|
||||
#[path = "capture_android.rs"]
|
||||
mod imp;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[path = "capture_windows.rs"]
|
||||
mod imp;
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
use alsa::pcm::{Access, Format, Frames, HwParams};
|
||||
use alsa::{Direction, ValueOr, PCM};
|
||||
use bytes::Bytes;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::{broadcast, watch, Mutex};
|
||||
use tracing::{debug, info};
|
||||
|
||||
use crate::audio::device::AudioDeviceInfo;
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::utils::LogThrottler;
|
||||
use crate::{error_throttled, warn_throttled};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioConfig {
|
||||
pub device_name: String,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u32,
|
||||
pub frame_size: u32,
|
||||
pub buffer_frames: u32,
|
||||
pub period_frames: u32,
|
||||
}
|
||||
|
||||
impl Default for AudioConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
device_name: String::new(),
|
||||
sample_rate: 48_000,
|
||||
channels: 2,
|
||||
frame_size: 960,
|
||||
buffer_frames: 4096,
|
||||
period_frames: 960,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioConfig {
|
||||
pub fn for_device(device: &AudioDeviceInfo) -> Self {
|
||||
Self {
|
||||
device_name: device.name.clone(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bytes_per_sample(&self) -> u32 {
|
||||
2 * self.channels
|
||||
}
|
||||
|
||||
pub fn bytes_per_frame(&self) -> usize {
|
||||
(self.frame_size * self.bytes_per_sample()) as usize
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AudioFrame {
|
||||
pub data: Bytes,
|
||||
pub sample_rate: u32,
|
||||
pub channels: u32,
|
||||
pub samples: u32,
|
||||
pub sequence: u64,
|
||||
pub timestamp: Instant,
|
||||
}
|
||||
|
||||
impl AudioFrame {
|
||||
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
|
||||
let bps = 2 * channels;
|
||||
Self {
|
||||
samples: data.len() as u32 / bps,
|
||||
data,
|
||||
sample_rate,
|
||||
channels,
|
||||
sequence,
|
||||
timestamp: Instant::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CaptureState {
|
||||
Stopped,
|
||||
Running,
|
||||
Error,
|
||||
}
|
||||
|
||||
pub struct AudioCapturer {
|
||||
config: AudioConfig,
|
||||
state: Arc<watch::Sender<CaptureState>>,
|
||||
state_rx: watch::Receiver<CaptureState>,
|
||||
frame_tx: broadcast::Sender<AudioFrame>,
|
||||
stop_flag: Arc<AtomicBool>,
|
||||
sequence: Arc<AtomicU64>,
|
||||
capture_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
log_throttler: LogThrottler,
|
||||
}
|
||||
|
||||
impl AudioCapturer {
|
||||
pub fn new(config: AudioConfig) -> Self {
|
||||
let (state_tx, state_rx) = watch::channel(CaptureState::Stopped);
|
||||
let (frame_tx, _) = broadcast::channel(16);
|
||||
|
||||
Self {
|
||||
config,
|
||||
state: Arc::new(state_tx),
|
||||
state_rx,
|
||||
frame_tx,
|
||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
sequence: Arc::new(AtomicU64::new(0)),
|
||||
capture_handle: Mutex::new(None),
|
||||
log_throttler: LogThrottler::with_secs(5),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> CaptureState {
|
||||
*self.state_rx.borrow()
|
||||
}
|
||||
|
||||
pub fn state_watch(&self) -> watch::Receiver<CaptureState> {
|
||||
self.state_rx.clone()
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<AudioFrame> {
|
||||
self.frame_tx.subscribe()
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> Result<()> {
|
||||
if self.state() == CaptureState::Running {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Starting audio capture on {} at {}Hz {}ch",
|
||||
self.config.device_name, self.config.sample_rate, self.config.channels
|
||||
);
|
||||
|
||||
self.stop_flag.store(false, Ordering::SeqCst);
|
||||
|
||||
let config = self.config.clone();
|
||||
let state = self.state.clone();
|
||||
let frame_tx = self.frame_tx.clone();
|
||||
let stop_flag = self.stop_flag.clone();
|
||||
let sequence = self.sequence.clone();
|
||||
let log_throttler = self.log_throttler.clone();
|
||||
|
||||
let handle = tokio::task::spawn_blocking(move || {
|
||||
let result = run_capture(
|
||||
&config,
|
||||
&state,
|
||||
&frame_tx,
|
||||
&stop_flag,
|
||||
&sequence,
|
||||
&log_throttler,
|
||||
);
|
||||
|
||||
if let Err(e) = result {
|
||||
error_throttled!(log_throttler, "capture_error", "Audio capture error: {}", e);
|
||||
let _ = state.send(CaptureState::Error);
|
||||
} else {
|
||||
let _ = state.send(CaptureState::Stopped);
|
||||
}
|
||||
});
|
||||
|
||||
*self.capture_handle.lock().await = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> Result<()> {
|
||||
info!("Stopping audio capture");
|
||||
self.stop_flag.store(true, Ordering::SeqCst);
|
||||
|
||||
if let Some(handle) = self.capture_handle.lock().await.take() {
|
||||
let _ = handle.await;
|
||||
}
|
||||
|
||||
let _ = self.state.send(CaptureState::Stopped);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.state() == CaptureState::Running
|
||||
}
|
||||
}
|
||||
|
||||
fn run_capture(
|
||||
config: &AudioConfig,
|
||||
state: &watch::Sender<CaptureState>,
|
||||
frame_tx: &broadcast::Sender<AudioFrame>,
|
||||
stop_flag: &AtomicBool,
|
||||
sequence: &AtomicU64,
|
||||
log_throttler: &LogThrottler,
|
||||
) -> Result<()> {
|
||||
let pcm = PCM::new(&config.device_name, Direction::Capture, false).map_err(|e| {
|
||||
AppError::AudioError(format!(
|
||||
"Failed to open audio device {}: {}",
|
||||
config.device_name, e
|
||||
))
|
||||
})?;
|
||||
|
||||
{
|
||||
let hwp = HwParams::any(&pcm)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to get HwParams: {}", e)))?;
|
||||
|
||||
hwp.set_channels(config.channels)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to set channels: {}", e)))?;
|
||||
hwp.set_rate(config.sample_rate, ValueOr::Nearest)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to set sample rate: {}", e)))?;
|
||||
hwp.set_format(Format::s16())
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to set format: {}", e)))?;
|
||||
hwp.set_access(Access::RWInterleaved)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to set access: {}", e)))?;
|
||||
hwp.set_buffer_size_near(config.buffer_frames as Frames)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to set buffer size: {}", e)))?;
|
||||
hwp.set_period_size_near(config.period_frames as Frames, ValueOr::Nearest)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to set period size: {}", e)))?;
|
||||
pcm.hw_params(&hwp)
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to apply hw params: {}", e)))?;
|
||||
}
|
||||
|
||||
let hw_now = pcm.hw_params_current().map_err(|e| {
|
||||
AppError::AudioError(format!("Failed to read hw_params after apply: {}", e))
|
||||
})?;
|
||||
let actual_rate = hw_now
|
||||
.get_rate()
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to read sample rate: {}", e)))?;
|
||||
let actual_ch = hw_now
|
||||
.get_channels()
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to read channels: {}", e)))?;
|
||||
if actual_rate != 48_000 {
|
||||
return Err(AppError::AudioError(format!(
|
||||
"Audio capture requires 48000 Hz; device is {} Hz",
|
||||
actual_rate
|
||||
)));
|
||||
}
|
||||
if actual_ch != 2 {
|
||||
return Err(AppError::AudioError(format!(
|
||||
"Audio capture requires 2 channels (stereo); device has {}",
|
||||
actual_ch
|
||||
)));
|
||||
}
|
||||
debug!("Audio capture: 48000 Hz, 2 ch");
|
||||
|
||||
pcm.prepare()
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to prepare PCM: {}", e)))?;
|
||||
let _ = state.send(CaptureState::Running);
|
||||
|
||||
let period_frames = pcm
|
||||
.hw_params_current()
|
||||
.ok()
|
||||
.and_then(|h| h.get_period_size().ok())
|
||||
.map(|f| f as usize)
|
||||
.unwrap_or(1024)
|
||||
.max(256);
|
||||
let buf_frames = period_frames.saturating_mul(4).max(2048);
|
||||
let io = pcm
|
||||
.io_i16()
|
||||
.map_err(|e| AppError::AudioError(format!("Failed to get PCM IO: {}", e)))?;
|
||||
|
||||
let mut buffer = vec![0i16; buf_frames * 2];
|
||||
let mut next_log = Instant::now();
|
||||
|
||||
while !stop_flag.load(Ordering::SeqCst) {
|
||||
match io.readi(&mut buffer[..period_frames * 2]) {
|
||||
Ok(frames_read) => {
|
||||
if frames_read == 0 {
|
||||
continue;
|
||||
}
|
||||
let samples = frames_read * 2;
|
||||
let data = Bytes::copy_from_slice(bytemuck::cast_slice(&buffer[..samples]));
|
||||
let seq = sequence.fetch_add(1, Ordering::SeqCst);
|
||||
let frame = AudioFrame::new_interleaved(data, 2, 48_000, seq);
|
||||
let _ = frame_tx.send(frame);
|
||||
if next_log.elapsed().as_secs() >= 5 {
|
||||
debug!("Captured audio frame {} ({} samples)", seq, samples / 2);
|
||||
next_log = Instant::now();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn_throttled!(
|
||||
log_throttler,
|
||||
"alsa_read",
|
||||
"ALSA read error on {}: {}",
|
||||
config.device_name,
|
||||
err
|
||||
);
|
||||
let _ = pcm.try_recover(err, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = pcm.drain();
|
||||
Ok(())
|
||||
}
|
||||
@@ -220,7 +220,7 @@ fn run_capture(
|
||||
let stream = match sample_format {
|
||||
SampleFormat::F32 => build_stream::<f32>(
|
||||
&device,
|
||||
&stream_config,
|
||||
stream_config,
|
||||
input_channels,
|
||||
input_rate,
|
||||
tx.clone(),
|
||||
@@ -229,7 +229,7 @@ fn run_capture(
|
||||
),
|
||||
SampleFormat::I16 => build_stream::<i16>(
|
||||
&device,
|
||||
&stream_config,
|
||||
stream_config,
|
||||
input_channels,
|
||||
input_rate,
|
||||
tx.clone(),
|
||||
@@ -238,7 +238,7 @@ fn run_capture(
|
||||
),
|
||||
SampleFormat::U16 => build_stream::<u16>(
|
||||
&device,
|
||||
&stream_config,
|
||||
stream_config,
|
||||
input_channels,
|
||||
input_rate,
|
||||
tx.clone(),
|
||||
@@ -361,7 +361,7 @@ fn select_input_config(
|
||||
|
||||
fn build_stream<T>(
|
||||
device: &cpal::Device,
|
||||
config: &StreamConfig,
|
||||
config: StreamConfig,
|
||||
input_channels: u32,
|
||||
input_rate: u32,
|
||||
tx: mpsc::SyncSender<Vec<i16>>,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
#[cfg(all(unix, not(feature = "android")))]
|
||||
#[cfg(unix)]
|
||||
#[path = "device_linux.rs"]
|
||||
mod imp;
|
||||
|
||||
#[cfg(feature = "android")]
|
||||
#[path = "device_android.rs"]
|
||||
mod imp;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[path = "device_windows.rs"]
|
||||
mod imp;
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
use alsa::pcm::HwParams;
|
||||
use alsa::{Direction, PCM};
|
||||
use serde::Serialize;
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AudioDeviceInfo {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub card_index: i32,
|
||||
pub device_index: i32,
|
||||
pub sample_rates: Vec<u32>,
|
||||
pub channels: Vec<u32>,
|
||||
pub is_capture: bool,
|
||||
pub is_hdmi: bool,
|
||||
pub usb_bus: Option<String>,
|
||||
}
|
||||
|
||||
fn get_usb_bus_info(card_index: i32) -> Option<String> {
|
||||
if card_index < 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let device_path = format!("/sys/class/sound/card{}/device", card_index);
|
||||
let link_target = std::fs::read_link(&device_path).ok()?;
|
||||
let link_str = link_target.to_string_lossy();
|
||||
|
||||
for component in link_str.split('/') {
|
||||
if component.contains('-') && !component.contains(':') {
|
||||
if component
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_digit())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Some(component.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn enumerate_audio_devices() -> Result<Vec<AudioDeviceInfo>> {
|
||||
enumerate_audio_devices_with_current(None)
|
||||
}
|
||||
|
||||
pub fn enumerate_audio_devices_with_current(
|
||||
current_device: Option<&str>,
|
||||
) -> Result<Vec<AudioDeviceInfo>> {
|
||||
let mut devices = Vec::new();
|
||||
|
||||
for card_result in alsa::card::Iter::new() {
|
||||
let card = match card_result {
|
||||
Ok(card) => card,
|
||||
Err(err) => {
|
||||
debug!("Error iterating card: {}", err);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let card_index = card.get_index();
|
||||
let card_name = card.get_name().unwrap_or_else(|_| "Unknown".to_string());
|
||||
let card_longname = card.get_longname().unwrap_or_else(|_| card_name.clone());
|
||||
|
||||
debug!("Found audio card {}: {}", card_index, card_longname);
|
||||
|
||||
let long_lower = card_longname.to_lowercase();
|
||||
let is_hdmi = long_lower.contains("hdmi")
|
||||
|| long_lower.contains("capture")
|
||||
|| long_lower.contains("usb");
|
||||
let usb_bus = get_usb_bus_info(card_index);
|
||||
|
||||
for device_index in 0..8 {
|
||||
let device_name = format!("hw:{},{}", card_index, device_index);
|
||||
let is_current_device = current_device == Some(device_name.as_str());
|
||||
|
||||
let mut push_info =
|
||||
|sample_rates: Vec<u32>, channels: Vec<u32>, description: String| {
|
||||
devices.push(AudioDeviceInfo {
|
||||
name: device_name.clone(),
|
||||
description,
|
||||
card_index,
|
||||
device_index,
|
||||
sample_rates,
|
||||
channels,
|
||||
is_capture: true,
|
||||
is_hdmi,
|
||||
usb_bus: usb_bus.clone(),
|
||||
});
|
||||
};
|
||||
|
||||
match PCM::new(&device_name, Direction::Capture, false) {
|
||||
Ok(pcm) => {
|
||||
let (sample_rates, channels) = query_device_caps(&pcm);
|
||||
if !sample_rates.is_empty() && !channels.is_empty() {
|
||||
push_info(
|
||||
sample_rates,
|
||||
channels,
|
||||
format!("{} - Device {}", card_longname, device_index),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(_) if is_current_device => {
|
||||
debug!(
|
||||
"Device {} is busy (in use by us), adding with default caps",
|
||||
device_name
|
||||
);
|
||||
push_info(
|
||||
vec![44_100, 48_000],
|
||||
vec![2],
|
||||
format!("{} - Device {} (in use)", card_longname, device_index),
|
||||
);
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("Found {} audio capture devices", devices.len());
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
fn query_device_caps(pcm: &PCM) -> (Vec<u32>, Vec<u32>) {
|
||||
let hwp = match HwParams::any(pcm) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return (vec![], vec![]),
|
||||
};
|
||||
|
||||
let common_rates = [8000, 16000, 22050, 44100, 48000, 96000];
|
||||
let mut supported_rates = Vec::new();
|
||||
|
||||
for rate in &common_rates {
|
||||
if hwp.test_rate(*rate).is_ok() {
|
||||
supported_rates.push(*rate);
|
||||
}
|
||||
}
|
||||
|
||||
let mut supported_channels = Vec::new();
|
||||
for ch in 1..=8 {
|
||||
if hwp.test_channels(ch).is_ok() {
|
||||
supported_channels.push(ch);
|
||||
}
|
||||
}
|
||||
|
||||
(supported_rates, supported_channels)
|
||||
}
|
||||
|
||||
pub fn find_best_audio_device() -> Result<AudioDeviceInfo> {
|
||||
let devices = enumerate_audio_devices()?;
|
||||
|
||||
if devices.is_empty() {
|
||||
return Err(AppError::AudioError(
|
||||
"No audio capture devices found".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut first_48k_stereo: Option<&AudioDeviceInfo> = None;
|
||||
for device in &devices {
|
||||
if !device.sample_rates.contains(&48_000) || !device.channels.contains(&2) {
|
||||
continue;
|
||||
}
|
||||
if device.is_hdmi {
|
||||
info!("Selected HDMI audio device: {}", device.description);
|
||||
return Ok(device.clone());
|
||||
}
|
||||
if first_48k_stereo.is_none() {
|
||||
first_48k_stereo = Some(device);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(device) = first_48k_stereo {
|
||||
info!("Selected audio device: {}", device.description);
|
||||
return Ok(device.clone());
|
||||
}
|
||||
|
||||
let device = devices.into_iter().next().unwrap();
|
||||
warn!(
|
||||
"Using fallback audio device: {} (may not support optimal settings)",
|
||||
device.description
|
||||
);
|
||||
Ok(device)
|
||||
}
|
||||
@@ -130,11 +130,11 @@ fn device_labels(device: &cpal::Device) -> DeviceLabels {
|
||||
let formatted = desc.to_string();
|
||||
let display = desc
|
||||
.extended()
|
||||
.first()
|
||||
.cloned()
|
||||
.next()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| formatted.clone());
|
||||
let mut parts = vec![formatted, desc.name().to_string(), display.clone()];
|
||||
parts.extend(desc.extended().iter().cloned());
|
||||
parts.extend(desc.extended().map(str::to_owned));
|
||||
|
||||
DeviceLabels {
|
||||
display,
|
||||
|
||||
@@ -79,7 +79,7 @@ fn unauthorized_response(message: &str) -> Response {
|
||||
fn is_public_endpoint(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/" | "/auth/login" | "/health" | "/setup" | "/setup/init"
|
||||
"/" | "/auth/login" | "/auth/login/totp" | "/health" | "/setup" | "/setup/init"
|
||||
) || path.starts_with("/assets/")
|
||||
|| path.starts_with("/static/")
|
||||
|| path.ends_with(".js")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod middleware;
|
||||
mod password;
|
||||
mod session;
|
||||
mod two_factor;
|
||||
mod user;
|
||||
|
||||
pub use middleware::{auth_middleware, SESSION_COOKIE};
|
||||
pub use password::{hash_password, verify_password};
|
||||
pub use session::{Session, SessionStore};
|
||||
pub use two_factor::{server_time_unix_ms, ChallengeInfo, EnrollmentInfo, TwoFactorService};
|
||||
pub use user::{User, UserStore};
|
||||
|
||||
@@ -39,18 +39,39 @@ impl SessionStore {
|
||||
}
|
||||
|
||||
pub async fn create(&self, user_id: &str) -> Result<Session> {
|
||||
let session = self.new_session(user_id);
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.insert(session.id.clone(), session.clone());
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn create_for_login(
|
||||
&self,
|
||||
user_id: &str,
|
||||
allow_multiple_sessions: bool,
|
||||
) -> Result<(Session, Vec<String>)> {
|
||||
let session = self.new_session(user_id);
|
||||
let mut guard = self.inner.write().await;
|
||||
let revoked = if allow_multiple_sessions {
|
||||
Vec::new()
|
||||
} else {
|
||||
let ids = guard.keys().cloned().collect();
|
||||
guard.clear();
|
||||
ids
|
||||
};
|
||||
guard.insert(session.id.clone(), session.clone());
|
||||
Ok((session, revoked))
|
||||
}
|
||||
|
||||
fn new_session(&self, user_id: &str) -> Session {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let session = Session {
|
||||
Session {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
created_at: now,
|
||||
expires_at: now + self.default_ttl,
|
||||
data: None,
|
||||
};
|
||||
|
||||
let mut guard = self.inner.write().await;
|
||||
guard.insert(session.id.clone(), session.clone());
|
||||
Ok(session)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(&self, session_id: &str) -> Result<Option<Session>> {
|
||||
@@ -85,6 +106,17 @@ impl SessionStore {
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
pub async fn delete_all_except(&self, session_id: &str) -> Result<Vec<String>> {
|
||||
let mut guard = self.inner.write().await;
|
||||
let revoked: Vec<String> = guard
|
||||
.keys()
|
||||
.filter(|id| id.as_str() != session_id)
|
||||
.cloned()
|
||||
.collect();
|
||||
guard.retain(|id, _| id == session_id);
|
||||
Ok(revoked)
|
||||
}
|
||||
|
||||
pub async fn list_ids(&self) -> Result<Vec<String>> {
|
||||
let guard = self.inner.read().await;
|
||||
Ok(guard.keys().cloned().collect())
|
||||
@@ -102,3 +134,38 @@ impl SessionStore {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_all_except_preserves_only_current_session() {
|
||||
let sessions = SessionStore::new(60);
|
||||
let current = sessions.create("user").await.unwrap();
|
||||
let other = sessions.create("user").await.unwrap();
|
||||
|
||||
let revoked = sessions.delete_all_except(¤t.id).await.unwrap();
|
||||
assert_eq!(revoked, vec![other.id.clone()]);
|
||||
assert!(sessions.get(¤t.id).await.unwrap().is_some());
|
||||
assert!(sessions.get(&other.id).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_creation_applies_session_policy_atomically() {
|
||||
let sessions = SessionStore::new(60);
|
||||
let existing = sessions.create("user").await.unwrap();
|
||||
|
||||
let (multiple, revoked) = sessions.create_for_login("user", true).await.unwrap();
|
||||
assert!(revoked.is_empty());
|
||||
assert!(sessions.get(&existing.id).await.unwrap().is_some());
|
||||
assert!(sessions.get(&multiple.id).await.unwrap().is_some());
|
||||
|
||||
let (single, mut revoked) = sessions.create_for_login("user", false).await.unwrap();
|
||||
revoked.sort();
|
||||
let mut expected = vec![existing.id, multiple.id];
|
||||
expected.sort();
|
||||
assert_eq!(revoked, expected);
|
||||
assert_eq!(sessions.list_ids().await.unwrap(), vec![single.id]);
|
||||
}
|
||||
}
|
||||
|
||||
498
src/auth/two_factor.rs
Normal file
498
src/auth/two_factor.rs
Normal file
@@ -0,0 +1,498 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use tokio::sync::Mutex;
|
||||
use totp_rs::{Algorithm, Secret, TOTP};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
const LOGIN_TTL: Duration = Duration::from_secs(5 * 60);
|
||||
const ENROLLMENT_TTL: Duration = Duration::from_secs(10 * 60);
|
||||
const FAILURE_WINDOW: Duration = Duration::from_secs(60);
|
||||
const MAX_FAILURES: usize = 5;
|
||||
|
||||
struct LoginChallenge {
|
||||
id: String,
|
||||
user_id: String,
|
||||
expires_at: Instant,
|
||||
expires_at_unix_ms: u64,
|
||||
failures: usize,
|
||||
}
|
||||
|
||||
struct EnrollmentChallenge {
|
||||
id: String,
|
||||
user_id: String,
|
||||
secret: Secret,
|
||||
expires_at: Instant,
|
||||
expires_at_unix_ms: u64,
|
||||
failures: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ChallengeInfo {
|
||||
pub id: String,
|
||||
pub expires_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct EnrollmentInfo {
|
||||
pub id: String,
|
||||
pub secret: String,
|
||||
pub otpauth_uri: String,
|
||||
pub expires_at_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TwoFactorService {
|
||||
pool: Pool<Sqlite>,
|
||||
login_challenges: std::sync::Arc<Mutex<HashMap<String, LoginChallenge>>>,
|
||||
enrollment_challenges: std::sync::Arc<Mutex<HashMap<String, EnrollmentChallenge>>>,
|
||||
failures: std::sync::Arc<Mutex<HashMap<String, VecDeque<Instant>>>>,
|
||||
}
|
||||
|
||||
impl TwoFactorService {
|
||||
pub fn new(pool: Pool<Sqlite>) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
login_challenges: Default::default(),
|
||||
enrollment_challenges: Default::default(),
|
||||
failures: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_enabled(&self, user_id: &str) -> Result<bool> {
|
||||
let exists: Option<(i64,)> =
|
||||
sqlx::query_as("SELECT 1 FROM user_totp_credentials WHERE user_id = ?1 LIMIT 1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(exists.is_some())
|
||||
}
|
||||
|
||||
pub async fn begin_login(&self, user_id: &str) -> Result<Option<ChallengeInfo>> {
|
||||
if !self.is_enabled(user_id).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let challenge = LoginChallenge {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
expires_at: Instant::now() + LOGIN_TTL,
|
||||
expires_at_unix_ms: expires_at_unix_ms(LOGIN_TTL),
|
||||
failures: 0,
|
||||
};
|
||||
let info = ChallengeInfo {
|
||||
id: challenge.id.clone(),
|
||||
expires_at_unix_ms: challenge.expires_at_unix_ms,
|
||||
};
|
||||
self.login_challenges
|
||||
.lock()
|
||||
.await
|
||||
.insert(user_id.to_string(), challenge);
|
||||
Ok(Some(info))
|
||||
}
|
||||
|
||||
pub async fn complete_login(&self, challenge_id: &str, code: &str) -> Result<String> {
|
||||
validate_code_format(code)?;
|
||||
|
||||
let (user_id, expired) = {
|
||||
let challenges = self.login_challenges.lock().await;
|
||||
let challenge = challenges
|
||||
.values()
|
||||
.find(|challenge| challenge.id == challenge_id)
|
||||
.ok_or_else(|| AppError::AuthError("TOTP challenge expired".to_string()))?;
|
||||
(
|
||||
challenge.user_id.clone(),
|
||||
Instant::now() >= challenge.expires_at,
|
||||
)
|
||||
};
|
||||
|
||||
if expired {
|
||||
self.login_challenges.lock().await.remove(&user_id);
|
||||
return Err(AppError::AuthError("TOTP challenge expired".to_string()));
|
||||
}
|
||||
self.enforce_failure_limit(&user_id).await?;
|
||||
|
||||
let valid = match self.credential_secret(&user_id).await? {
|
||||
Some(secret) => verify_at(&secret, code, unix_time_secs())?,
|
||||
None => {
|
||||
self.login_challenges.lock().await.remove(&user_id);
|
||||
return Err(AppError::AuthError("TOTP challenge expired".to_string()));
|
||||
}
|
||||
};
|
||||
if !valid {
|
||||
self.record_failure(&user_id).await;
|
||||
let mut challenges = self.login_challenges.lock().await;
|
||||
let mut exhausted = false;
|
||||
if let Some(challenge) = challenges.get_mut(&user_id) {
|
||||
challenge.failures += 1;
|
||||
if challenge.failures >= MAX_FAILURES {
|
||||
exhausted = true;
|
||||
challenges.remove(&user_id);
|
||||
}
|
||||
}
|
||||
if exhausted {
|
||||
return Err(AppError::AuthError("TOTP challenge expired".to_string()));
|
||||
}
|
||||
return Err(AppError::AuthError("Invalid TOTP code".to_string()));
|
||||
}
|
||||
|
||||
let consumed = self
|
||||
.login_challenges
|
||||
.lock()
|
||||
.await
|
||||
.remove(&user_id)
|
||||
.is_some_and(|challenge| challenge.id == challenge_id);
|
||||
if !consumed {
|
||||
return Err(AppError::AuthError("TOTP challenge expired".to_string()));
|
||||
}
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
pub async fn begin_enrollment(
|
||||
&self,
|
||||
session_id: &str,
|
||||
user_id: &str,
|
||||
username: &str,
|
||||
) -> Result<EnrollmentInfo> {
|
||||
if self.is_enabled(user_id).await? {
|
||||
return Err(AppError::Conflict("TOTP is already enabled".to_string()));
|
||||
}
|
||||
|
||||
let secret = Secret::generate_secret().to_encoded();
|
||||
let totp = totp(&secret, username)?;
|
||||
let challenge = EnrollmentChallenge {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
user_id: user_id.to_string(),
|
||||
secret,
|
||||
expires_at: Instant::now() + ENROLLMENT_TTL,
|
||||
expires_at_unix_ms: expires_at_unix_ms(ENROLLMENT_TTL),
|
||||
failures: 0,
|
||||
};
|
||||
let info = EnrollmentInfo {
|
||||
id: challenge.id.clone(),
|
||||
secret: challenge.secret.to_string(),
|
||||
otpauth_uri: totp.get_url(),
|
||||
expires_at_unix_ms: challenge.expires_at_unix_ms,
|
||||
};
|
||||
self.enrollment_challenges
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_id.to_string(), challenge);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub async fn confirm_enrollment(
|
||||
&self,
|
||||
session_id: &str,
|
||||
user_id: &str,
|
||||
enrollment_id: &str,
|
||||
code: &str,
|
||||
) -> Result<()> {
|
||||
validate_code_format(code)?;
|
||||
self.enforce_failure_limit(user_id).await?;
|
||||
|
||||
let (secret, expired) = {
|
||||
let challenges = self.enrollment_challenges.lock().await;
|
||||
let challenge = challenges
|
||||
.get(session_id)
|
||||
.filter(|challenge| challenge.id == enrollment_id && challenge.user_id == user_id)
|
||||
.ok_or_else(|| AppError::AuthError("TOTP enrollment expired".to_string()))?;
|
||||
(
|
||||
challenge.secret.clone(),
|
||||
Instant::now() >= challenge.expires_at,
|
||||
)
|
||||
};
|
||||
if expired {
|
||||
self.enrollment_challenges.lock().await.remove(session_id);
|
||||
return Err(AppError::AuthError("TOTP enrollment expired".to_string()));
|
||||
}
|
||||
|
||||
if !verify_at(&secret, code, unix_time_secs())? {
|
||||
self.record_failure(user_id).await;
|
||||
let mut challenges = self.enrollment_challenges.lock().await;
|
||||
let mut exhausted = false;
|
||||
if let Some(challenge) = challenges.get_mut(session_id) {
|
||||
challenge.failures += 1;
|
||||
if challenge.failures >= MAX_FAILURES {
|
||||
exhausted = true;
|
||||
challenges.remove(session_id);
|
||||
}
|
||||
}
|
||||
if exhausted {
|
||||
return Err(AppError::AuthError("TOTP enrollment expired".to_string()));
|
||||
}
|
||||
return Err(AppError::AuthError("Invalid TOTP code".to_string()));
|
||||
}
|
||||
|
||||
let mut transaction = self.pool.begin().await?;
|
||||
let result =
|
||||
sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)")
|
||||
.bind(user_id)
|
||||
.bind(secret.to_string())
|
||||
.execute(&mut *transaction)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => transaction.commit().await?,
|
||||
Err(sqlx::Error::Database(error)) if error.is_unique_violation() => {
|
||||
return Err(AppError::Conflict("TOTP is already enabled".to_string()));
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
self.enrollment_challenges.lock().await.remove(session_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn disable(&self, user_id: &str, code: &str) -> Result<()> {
|
||||
validate_code_format(code)?;
|
||||
self.enforce_failure_limit(user_id).await?;
|
||||
let secret = self
|
||||
.credential_secret(user_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Conflict("TOTP is not enabled".to_string()))?;
|
||||
if !verify_at(&secret, code, unix_time_secs())? {
|
||||
self.record_failure(user_id).await;
|
||||
return Err(AppError::AuthError("Invalid TOTP code".to_string()));
|
||||
}
|
||||
|
||||
let result = sqlx::query("DELETE FROM user_totp_credentials WHERE user_id = ?1")
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::Conflict("TOTP is not enabled".to_string()));
|
||||
}
|
||||
self.clear_user_challenges(user_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn disable_without_code(&self, user_id: &str) -> Result<bool> {
|
||||
let result = sqlx::query("DELETE FROM user_totp_credentials WHERE user_id = ?1")
|
||||
.bind(user_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
self.clear_user_challenges(user_id).await;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn credential_secret(&self, user_id: &str) -> Result<Option<Secret>> {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT secret FROM user_totp_credentials WHERE user_id = ?1")
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(secret,)| Secret::Encoded(secret)))
|
||||
}
|
||||
|
||||
async fn enforce_failure_limit(&self, user_id: &str) -> Result<()> {
|
||||
let now = Instant::now();
|
||||
let mut failures = self.failures.lock().await;
|
||||
let attempts = failures.entry(user_id.to_string()).or_default();
|
||||
while attempts
|
||||
.front()
|
||||
.is_some_and(|attempt| now.duration_since(*attempt) >= FAILURE_WINDOW)
|
||||
{
|
||||
attempts.pop_front();
|
||||
}
|
||||
if attempts.len() >= MAX_FAILURES {
|
||||
return Err(AppError::RateLimited(
|
||||
"TOTP verification is temporarily limited".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_failure(&self, user_id: &str) {
|
||||
self.failures
|
||||
.lock()
|
||||
.await
|
||||
.entry(user_id.to_string())
|
||||
.or_default()
|
||||
.push_back(Instant::now());
|
||||
}
|
||||
|
||||
async fn clear_user_challenges(&self, user_id: &str) {
|
||||
self.login_challenges.lock().await.remove(user_id);
|
||||
self.enrollment_challenges
|
||||
.lock()
|
||||
.await
|
||||
.retain(|_, challenge| challenge.user_id != user_id);
|
||||
self.failures.lock().await.remove(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn totp(secret: &Secret, account_name: &str) -> Result<TOTP> {
|
||||
let account_name = account_name.replace(':', "_");
|
||||
TOTP::new(
|
||||
Algorithm::SHA1,
|
||||
6,
|
||||
1,
|
||||
30,
|
||||
secret
|
||||
.to_bytes()
|
||||
.map_err(|error| AppError::Internal(error.to_string()))?,
|
||||
Some("One-KVM".to_string()),
|
||||
account_name,
|
||||
)
|
||||
.map_err(|error| AppError::Internal(error.to_string()))
|
||||
}
|
||||
|
||||
fn verify_at(secret: &Secret, code: &str, unix_time: u64) -> Result<bool> {
|
||||
validate_code_format(code)?;
|
||||
Ok(totp(secret, "user")?.check(code, unix_time))
|
||||
}
|
||||
|
||||
fn validate_code_format(code: &str) -> Result<()> {
|
||||
if code.len() != 6 || !code.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(AppError::BadRequest(
|
||||
"TOTP code must contain exactly 6 digits".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn server_time_unix_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
fn unix_time_secs() -> u64 {
|
||||
server_time_unix_ms() / 1000
|
||||
}
|
||||
|
||||
fn expires_at_unix_ms(ttl: Duration) -> u64 {
|
||||
server_time_unix_ms().saturating_add(ttl.as_millis() as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::DatabasePool;
|
||||
use tempfile::tempdir;
|
||||
|
||||
async fn test_service() -> (tempfile::TempDir, TwoFactorService, String) {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = DatabasePool::new(&dir.path().join("test.db"))
|
||||
.await
|
||||
.unwrap();
|
||||
db.init_schema().await.unwrap();
|
||||
let user_id = "user-1".to_string();
|
||||
sqlx::query("INSERT INTO users (id, username, password_hash) VALUES (?1, 'admin', 'hash')")
|
||||
.bind(&user_id)
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
let service = TwoFactorService::new(db.clone_pool());
|
||||
(dir, service, user_id)
|
||||
}
|
||||
|
||||
async fn install_known_credential(service: &TwoFactorService, user_id: &str) -> Secret {
|
||||
let secret = Secret::Raw(b"12345678901234567890".to_vec()).to_encoded();
|
||||
sqlx::query("INSERT INTO user_totp_credentials (user_id, secret) VALUES (?1, ?2)")
|
||||
.bind(user_id)
|
||||
.bind(secret.to_string())
|
||||
.execute(&service.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
secret
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_rfc_vector_and_adjacent_window() {
|
||||
let secret = Secret::Raw(b"12345678901234567890".to_vec());
|
||||
assert!(verify_at(&secret, "287082", 59).unwrap());
|
||||
let code = totp(&secret, "user").unwrap().generate(30);
|
||||
assert!(verify_at(&secret, &code, 60).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_codes() {
|
||||
let secret = Secret::Raw(b"12345678901234567890".to_vec());
|
||||
assert!(verify_at(&secret, "12345", 59).is_err());
|
||||
assert!(verify_at(&secret, "12345x", 59).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_challenges_are_replaced_expire_and_are_consumed_once() {
|
||||
let (_dir, service, user_id) = test_service().await;
|
||||
let secret = install_known_credential(&service, &user_id).await;
|
||||
let first = service.begin_login(&user_id).await.unwrap().unwrap();
|
||||
let second = service.begin_login(&user_id).await.unwrap().unwrap();
|
||||
let code = totp(&secret, "user").unwrap().generate_current().unwrap();
|
||||
|
||||
assert!(service.complete_login(&first.id, &code).await.is_err());
|
||||
assert_eq!(
|
||||
service.complete_login(&second.id, &code).await.unwrap(),
|
||||
user_id
|
||||
);
|
||||
assert!(service.complete_login(&second.id, &code).await.is_err());
|
||||
|
||||
let expired = service.begin_login(&user_id).await.unwrap().unwrap();
|
||||
service
|
||||
.login_challenges
|
||||
.lock()
|
||||
.await
|
||||
.get_mut(&user_id)
|
||||
.unwrap()
|
||||
.expires_at = Instant::now() - Duration::from_secs(1);
|
||||
assert!(service.complete_login(&expired.id, &code).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn challenge_failure_limit_is_shared_across_new_challenges() {
|
||||
let (_dir, service, user_id) = test_service().await;
|
||||
let secret = install_known_credential(&service, &user_id).await;
|
||||
let valid = totp(&secret, "user").unwrap().generate_current().unwrap();
|
||||
let invalid = if valid == "000000" {
|
||||
"000001"
|
||||
} else {
|
||||
"000000"
|
||||
};
|
||||
let challenge = service.begin_login(&user_id).await.unwrap().unwrap();
|
||||
|
||||
for _ in 0..4 {
|
||||
let error = service
|
||||
.complete_login(&challenge.id, invalid)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, AppError::AuthError(_)));
|
||||
}
|
||||
let error = service
|
||||
.complete_login(&challenge.id, invalid)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("challenge expired"));
|
||||
|
||||
let replacement = service.begin_login(&user_id).await.unwrap().unwrap();
|
||||
let error = service
|
||||
.complete_login(&replacement.id, &valid)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, AppError::RateLimited(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enrollment_persists_and_disable_is_idempotent_for_cli() {
|
||||
let (_dir, service, user_id) = test_service().await;
|
||||
let enrollment = service
|
||||
.begin_enrollment("session-1", &user_id, "admin")
|
||||
.await
|
||||
.unwrap();
|
||||
let secret = Secret::Encoded(enrollment.secret.clone());
|
||||
let code = totp(&secret, "admin").unwrap().generate_current().unwrap();
|
||||
service
|
||||
.confirm_enrollment("session-1", &user_id, &enrollment.id, &code)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let restarted = TwoFactorService::new(service.pool.clone());
|
||||
assert!(restarted.is_enabled(&user_id).await.unwrap());
|
||||
restarted.disable(&user_id, &code).await.unwrap();
|
||||
assert!(!restarted.is_enabled(&user_id).await.unwrap());
|
||||
assert!(!restarted.disable_without_code(&user_id).await.unwrap());
|
||||
}
|
||||
}
|
||||
@@ -104,19 +104,14 @@ pub struct ComputerUseStartRequest {
|
||||
#[serde(default)]
|
||||
pub continue_conversation: bool,
|
||||
pub client_id: String,
|
||||
pub max_steps: Option<u32>,
|
||||
pub timeout_seconds: Option<u32>,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ComputerUseConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub provider: String,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
pub max_steps: u32,
|
||||
pub timeout_seconds: u32,
|
||||
pub api_key_configured: bool,
|
||||
pub api_key_source: String,
|
||||
}
|
||||
@@ -127,10 +122,10 @@ pub struct ComputerUseConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub base_url: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub max_steps: Option<u32>,
|
||||
pub timeout_seconds: Option<u32>,
|
||||
pub openai_api_key: Option<String>,
|
||||
pub clear_openai_api_key: Option<bool>,
|
||||
#[serde(alias = "openai_api_key")]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(alias = "clear_openai_api_key")]
|
||||
pub clear_api_key: Option<bool>,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
@@ -140,7 +135,6 @@ pub struct ComputerUseSessionSummary {
|
||||
pub status: ComputerUseSessionStatus,
|
||||
pub prompt: Option<String>,
|
||||
pub step: u32,
|
||||
pub max_steps: u32,
|
||||
pub last_error: Option<String>,
|
||||
pub final_message: Option<String>,
|
||||
}
|
||||
@@ -152,6 +146,10 @@ pub enum ComputerUseWsClientMessage {
|
||||
request_id: String,
|
||||
screenshot: ComputerUseScreenshot,
|
||||
},
|
||||
ScreenshotError {
|
||||
request_id: String,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -161,6 +159,8 @@ pub enum ComputerUseWsServerMessage {
|
||||
ScreenshotRequested { request_id: String },
|
||||
ScreenshotCaptured { screenshot: ComputerUseScreenshot },
|
||||
StepStarted { step: u32 },
|
||||
ReasoningDelta { delta: String },
|
||||
ReasoningCompleted { failed: bool },
|
||||
ActionsExecuted { actions: Vec<ComputerUseAction> },
|
||||
Error { message: String },
|
||||
}
|
||||
@@ -203,4 +203,16 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_update_accepts_legacy_api_key_names() {
|
||||
let update: ComputerUseConfigUpdate = serde_json::from_value(json!({
|
||||
"openai_api_key": "legacy-key",
|
||||
"clear_openai_api_key": true
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(update.api_key.as_deref(), Some("legacy-key"));
|
||||
assert_eq!(update.clear_api_key, Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::{broadcast, oneshot, watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
@@ -43,7 +42,7 @@ struct ManagerState {
|
||||
struct ScreenshotWaiter {
|
||||
request_id: String,
|
||||
client_id: String,
|
||||
tx: oneshot::Sender<ComputerUseScreenshot>,
|
||||
tx: oneshot::Sender<Result<ComputerUseScreenshot>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -74,24 +73,16 @@ impl ComputerUseManager {
|
||||
|
||||
pub fn config_response(&self) -> ComputerUseConfigResponse {
|
||||
let config = self.config.get();
|
||||
let key_env = std::env::var("OPENAI_API_KEY")
|
||||
.ok()
|
||||
.filter(|key| !key.is_empty());
|
||||
let key_env = cua_api_key_env();
|
||||
let key_db = config
|
||||
.computer_use
|
||||
.openai_api_key
|
||||
.api_key
|
||||
.as_ref()
|
||||
.filter(|key| !key.is_empty());
|
||||
ComputerUseConfigResponse {
|
||||
enabled: config.computer_use.enabled,
|
||||
provider: config.computer_use.provider.clone(),
|
||||
base_url: std::env::var("ONE_KVM_OPENAI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|url| !url.trim().is_empty())
|
||||
.unwrap_or_else(|| config.computer_use.base_url.clone()),
|
||||
base_url: cua_base_url_env().unwrap_or_else(|| config.computer_use.base_url.clone()),
|
||||
model: config.computer_use.model.clone(),
|
||||
max_steps: config.computer_use.max_steps,
|
||||
timeout_seconds: config.computer_use.timeout_seconds,
|
||||
api_key_configured: key_env.is_some() || key_db.is_some(),
|
||||
api_key_source: if key_env.is_some() {
|
||||
"env".to_string()
|
||||
@@ -107,7 +98,6 @@ impl ComputerUseManager {
|
||||
&self,
|
||||
req: ComputerUseConfigUpdate,
|
||||
) -> Result<ComputerUseConfigResponse> {
|
||||
validate_limits(req.max_steps, req.timeout_seconds)?;
|
||||
if let Some(base_url) = req
|
||||
.base_url
|
||||
.as_ref()
|
||||
@@ -131,17 +121,11 @@ impl ComputerUseManager {
|
||||
{
|
||||
config.computer_use.base_url = base_url.trim().to_string();
|
||||
}
|
||||
if let Some(max_steps) = req.max_steps {
|
||||
config.computer_use.max_steps = max_steps;
|
||||
if req.clear_api_key.unwrap_or(false) {
|
||||
config.computer_use.api_key = None;
|
||||
}
|
||||
if let Some(timeout_seconds) = req.timeout_seconds {
|
||||
config.computer_use.timeout_seconds = timeout_seconds;
|
||||
}
|
||||
if req.clear_openai_api_key.unwrap_or(false) {
|
||||
config.computer_use.openai_api_key = None;
|
||||
}
|
||||
if let Some(key) = req.openai_api_key.as_ref() {
|
||||
config.computer_use.openai_api_key = if key.trim().is_empty() {
|
||||
if let Some(key) = req.api_key.as_ref() {
|
||||
config.computer_use.api_key = if key.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(key.trim().to_string())
|
||||
@@ -169,7 +153,6 @@ impl ComputerUseManager {
|
||||
if req.prompt.trim().is_empty() {
|
||||
return Err(AppError::BadRequest("Task prompt is required".to_string()));
|
||||
}
|
||||
validate_limits(req.max_steps, req.timeout_seconds)?;
|
||||
let client_id = req.client_id.trim();
|
||||
if client_id.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
@@ -184,15 +167,12 @@ impl ComputerUseManager {
|
||||
));
|
||||
}
|
||||
|
||||
let api_key = std::env::var("OPENAI_API_KEY")
|
||||
.ok()
|
||||
.filter(|key| !key.is_empty())
|
||||
.or(config.openai_api_key.clone())
|
||||
.ok_or_else(|| AppError::BadRequest("OpenAI API key is not configured".to_string()))?;
|
||||
let base_url = std::env::var("ONE_KVM_OPENAI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|url| !url.trim().is_empty())
|
||||
.unwrap_or_else(|| config.base_url.clone());
|
||||
let api_key = cua_api_key_env()
|
||||
.or(config.api_key.clone())
|
||||
.ok_or_else(|| {
|
||||
AppError::BadRequest("Computer Use API key is not configured".to_string())
|
||||
})?;
|
||||
let base_url = cua_base_url_env().unwrap_or_else(|| config.base_url.clone());
|
||||
validate_endpoint_url(&base_url)?;
|
||||
|
||||
let mut state = self.state.lock().await;
|
||||
@@ -225,10 +205,9 @@ impl ComputerUseManager {
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
state.session = ComputerUseSessionSummary {
|
||||
id: Some(session_id),
|
||||
status: ComputerUseSessionStatus::WaitingScreenshot,
|
||||
status: ComputerUseSessionStatus::Thinking,
|
||||
prompt: Some(req.prompt.trim().to_string()),
|
||||
step: 0,
|
||||
max_steps: req.max_steps.unwrap_or(config.max_steps),
|
||||
last_error: None,
|
||||
final_message: None,
|
||||
};
|
||||
@@ -240,9 +219,6 @@ impl ComputerUseManager {
|
||||
self.publish_session().await;
|
||||
let manager = self.clone();
|
||||
let prompt = req.prompt.trim().to_string();
|
||||
let max_steps = summary.max_steps;
|
||||
let timeout =
|
||||
Duration::from_secs(req.timeout_seconds.unwrap_or(config.timeout_seconds) as u64);
|
||||
let model = config.model.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
manager
|
||||
@@ -253,8 +229,6 @@ impl ComputerUseManager {
|
||||
model,
|
||||
conversation,
|
||||
client_id,
|
||||
max_steps,
|
||||
timeout,
|
||||
cancel_rx,
|
||||
stop_rx,
|
||||
)
|
||||
@@ -304,10 +278,30 @@ impl ComputerUseManager {
|
||||
state.screenshot_waiter = Some(waiter);
|
||||
return Ok(());
|
||||
}
|
||||
let _ = waiter.tx.send(screenshot);
|
||||
let _ = waiter.tx.send(Ok(screenshot));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn submit_screenshot_error(&self, client_id: &str, request_id: String, message: String) {
|
||||
let mut state = self.state.lock().await;
|
||||
let Some(waiter) = state.screenshot_waiter.take() else {
|
||||
return;
|
||||
};
|
||||
if waiter.request_id != request_id || waiter.client_id != client_id {
|
||||
state.screenshot_waiter = Some(waiter);
|
||||
return;
|
||||
}
|
||||
let message: String = message.chars().take(300).collect();
|
||||
let _ = waiter.tx.send(Err(AppError::ServiceUnavailable(format!(
|
||||
"Screenshot capture failed: {}",
|
||||
if message.trim().is_empty() {
|
||||
"client did not provide an error"
|
||||
} else {
|
||||
message.trim()
|
||||
}
|
||||
))));
|
||||
}
|
||||
|
||||
pub async fn handle_socket(self: Arc<Self>, socket: WebSocket, client_id: Option<String>) {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
let mut event_rx = self.event_tx.subscribe();
|
||||
@@ -352,10 +346,14 @@ impl ComputerUseManager {
|
||||
msg = receiver.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
if let Ok(ComputerUseWsClientMessage::ScreenshotResult { request_id, screenshot }) =
|
||||
serde_json::from_str::<ComputerUseWsClientMessage>(&text)
|
||||
{
|
||||
let _ = self.submit_screenshot(&client_id, request_id, screenshot).await;
|
||||
match serde_json::from_str::<ComputerUseWsClientMessage>(&text) {
|
||||
Ok(ComputerUseWsClientMessage::ScreenshotResult { request_id, screenshot }) => {
|
||||
let _ = self.submit_screenshot(&client_id, request_id, screenshot).await;
|
||||
}
|
||||
Ok(ComputerUseWsClientMessage::ScreenshotError { request_id, message }) => {
|
||||
self.submit_screenshot_error(&client_id, request_id, message).await;
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
@@ -375,23 +373,101 @@ impl ComputerUseManager {
|
||||
model: String,
|
||||
conversation: Vec<ComputerUseConversationMessage>,
|
||||
client_id: String,
|
||||
max_steps: u32,
|
||||
timeout: Duration,
|
||||
cancel_rx: watch::Receiver<bool>,
|
||||
mut stop_rx: oneshot::Receiver<()>,
|
||||
) {
|
||||
let provider = OpenAiComputerProvider::new(api_key, base_url, model);
|
||||
let started_at = Instant::now();
|
||||
let mut previous_response_id: Option<String> = None;
|
||||
let mut previous_call_id: Option<String> = None;
|
||||
let mut safety_checks: Vec<Value> = Vec::new();
|
||||
let mut latest_screenshot: Option<ComputerUseScreenshot> = None;
|
||||
let mut action_history: Vec<String> = Vec::new();
|
||||
let mut step = 0_u32;
|
||||
|
||||
for step in 1..=max_steps {
|
||||
if started_at.elapsed() > timeout {
|
||||
self.fail("Computer use task timed out").await;
|
||||
loop {
|
||||
step = step.saturating_add(1);
|
||||
self.set_status(ComputerUseSessionStatus::Thinking, step, None)
|
||||
.await;
|
||||
let response = tokio::select! {
|
||||
_ = &mut stop_rx => {
|
||||
let _ = self.event_tx.send(ComputerUseWsServerMessage::ReasoningCompleted {
|
||||
failed: true,
|
||||
});
|
||||
self.set_stopped().await;
|
||||
return;
|
||||
}
|
||||
response = provider.next_actions(
|
||||
&prompt,
|
||||
&conversation,
|
||||
&action_history,
|
||||
latest_screenshot.as_ref(),
|
||||
|delta| {
|
||||
let _ = self.event_tx.send(ComputerUseWsServerMessage::ReasoningDelta {
|
||||
delta: delta.to_string(),
|
||||
});
|
||||
},
|
||||
) => response,
|
||||
};
|
||||
|
||||
let response = match response {
|
||||
Ok(response) => {
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(ComputerUseWsServerMessage::ReasoningCompleted { failed: false });
|
||||
response
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(ComputerUseWsServerMessage::ReasoningCompleted { failed: true });
|
||||
self.fail(&err.to_string()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if *cancel_rx.borrow() {
|
||||
self.set_stopped().await;
|
||||
return;
|
||||
}
|
||||
|
||||
if response.done {
|
||||
self.complete(response.message).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let executable = &response.actions[..response.actions.len().saturating_sub(1)];
|
||||
action_history.push(format!(
|
||||
"Step {step}: {}",
|
||||
serde_json::to_string(&response.actions).unwrap_or_else(|_| "[]".to_string())
|
||||
));
|
||||
if !executable.is_empty() {
|
||||
let Some(screenshot) = latest_screenshot.as_ref() else {
|
||||
self.fail("Computer Use protocol error: actions require a screenshot")
|
||||
.await;
|
||||
return;
|
||||
};
|
||||
self.set_status(ComputerUseSessionStatus::Executing, step, None)
|
||||
.await;
|
||||
if let Err(err) = self
|
||||
.execute_actions(
|
||||
executable,
|
||||
screenshot.width,
|
||||
screenshot.height,
|
||||
cancel_rx.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
if *cancel_rx.borrow() {
|
||||
self.set_stopped().await;
|
||||
} else {
|
||||
self.fail(&err.to_string()).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(ComputerUseWsServerMessage::ActionsExecuted {
|
||||
actions: executable.to_vec(),
|
||||
});
|
||||
}
|
||||
|
||||
self.set_status(ComputerUseSessionStatus::WaitingScreenshot, step, None)
|
||||
.await;
|
||||
let screenshot = tokio::select! {
|
||||
@@ -401,7 +477,6 @@ impl ComputerUseManager {
|
||||
}
|
||||
screenshot = self.request_screenshot(&client_id) => screenshot,
|
||||
};
|
||||
|
||||
let screenshot = match screenshot {
|
||||
Ok(screenshot) => screenshot,
|
||||
Err(err) => {
|
||||
@@ -414,67 +489,8 @@ impl ComputerUseManager {
|
||||
.send(ComputerUseWsServerMessage::ScreenshotCaptured {
|
||||
screenshot: screenshot.clone(),
|
||||
});
|
||||
|
||||
self.set_status(ComputerUseSessionStatus::Thinking, step, None)
|
||||
.await;
|
||||
let response = tokio::select! {
|
||||
_ = &mut stop_rx => {
|
||||
self.set_stopped().await;
|
||||
return;
|
||||
}
|
||||
response = provider.next_actions(
|
||||
&prompt,
|
||||
&conversation,
|
||||
&screenshot,
|
||||
previous_response_id.as_deref(),
|
||||
previous_call_id.as_deref(),
|
||||
safety_checks.clone(),
|
||||
) => response,
|
||||
};
|
||||
|
||||
let response = match response {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
self.fail(&err.to_string()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
previous_response_id = response.response_id;
|
||||
previous_call_id = response.call_id;
|
||||
safety_checks = response.safety_checks;
|
||||
|
||||
if response.actions.is_empty() {
|
||||
self.complete(response.final_message).await;
|
||||
return;
|
||||
}
|
||||
|
||||
self.set_status(ComputerUseSessionStatus::Executing, step, None)
|
||||
.await;
|
||||
if let Err(err) = self
|
||||
.execute_actions(
|
||||
&response.actions,
|
||||
screenshot.width,
|
||||
screenshot.height,
|
||||
cancel_rx.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
if *cancel_rx.borrow() {
|
||||
self.set_stopped().await;
|
||||
} else {
|
||||
self.fail(&err.to_string()).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(ComputerUseWsServerMessage::ActionsExecuted {
|
||||
actions: response.actions,
|
||||
});
|
||||
latest_screenshot = Some(screenshot);
|
||||
}
|
||||
|
||||
self.complete(Some("Reached the maximum number of steps.".to_string()))
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn request_screenshot(&self, client_id: &str) -> Result<ComputerUseScreenshot> {
|
||||
@@ -492,14 +508,15 @@ impl ComputerUseManager {
|
||||
request_id,
|
||||
client_id: client_id.to_string(),
|
||||
});
|
||||
tokio::time::timeout(SCREENSHOT_TIMEOUT, rx)
|
||||
let reply = tokio::time::timeout(SCREENSHOT_TIMEOUT, rx)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::ServiceUnavailable("Timed out waiting for screenshot".to_string())
|
||||
})?
|
||||
.map_err(|_| {
|
||||
AppError::ServiceUnavailable("Screenshot request was cancelled".to_string())
|
||||
})
|
||||
})?;
|
||||
reply
|
||||
}
|
||||
|
||||
async fn execute_actions(
|
||||
@@ -742,36 +759,39 @@ fn stopped_error() -> AppError {
|
||||
AppError::BadRequest(STOPPED_MESSAGE.to_string())
|
||||
}
|
||||
|
||||
fn validate_limits(max_steps: Option<u32>, timeout_seconds: Option<u32>) -> Result<()> {
|
||||
if let Some(max_steps) = max_steps {
|
||||
if !(1..=100).contains(&max_steps) {
|
||||
return Err(AppError::BadRequest(
|
||||
"max_steps must be between 1 and 100".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(timeout_seconds) = timeout_seconds {
|
||||
if !(30..=3600).contains(&timeout_seconds) {
|
||||
return Err(AppError::BadRequest(
|
||||
"timeout_seconds must be between 30 and 3600".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn empty_session() -> ComputerUseSessionSummary {
|
||||
ComputerUseSessionSummary {
|
||||
id: None,
|
||||
status: ComputerUseSessionStatus::Idle,
|
||||
prompt: None,
|
||||
step: 0,
|
||||
max_steps: 0,
|
||||
last_error: None,
|
||||
final_message: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn cua_api_key_env() -> Option<String> {
|
||||
std::env::var("ONE_KVM_CUA_API_KEY")
|
||||
.ok()
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("OPENAI_API_KEY")
|
||||
.ok()
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn cua_base_url_env() -> Option<String> {
|
||||
std::env::var("ONE_KVM_CUA_BASE_URL")
|
||||
.ok()
|
||||
.filter(|url| !url.trim().is_empty())
|
||||
.or_else(|| {
|
||||
std::env::var("ONE_KVM_OPENAI_BASE_URL")
|
||||
.ok()
|
||||
.filter(|url| !url.trim().is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_endpoint_url(url: &str) -> Result<()> {
|
||||
let trimmed = url.trim();
|
||||
if !(trimmed.starts_with("https://") || trimmed.starts_with("http://")) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,25 +6,42 @@ use typeshare::typeshare;
|
||||
#[serde(default)]
|
||||
pub struct ComputerUseConfig {
|
||||
pub enabled: bool,
|
||||
pub provider: String,
|
||||
pub base_url: String,
|
||||
pub model: String,
|
||||
#[typeshare(skip)]
|
||||
pub openai_api_key: Option<String>,
|
||||
pub max_steps: u32,
|
||||
pub timeout_seconds: u32,
|
||||
#[serde(alias = "openai_api_key")]
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ComputerUseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
provider: "openai".to_string(),
|
||||
base_url: "https://api.openai.com/v1/responses".to_string(),
|
||||
model: "gpt-5.5".to_string(),
|
||||
openai_api_key: None,
|
||||
max_steps: 30,
|
||||
timeout_seconds: 600,
|
||||
api_key: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_openai_api_key_migrates_to_generic_key() {
|
||||
let config: ComputerUseConfig = serde_json::from_value(serde_json::json!({
|
||||
"enabled": true,
|
||||
"provider": "openai",
|
||||
"base_url": "https://example.test/v1/chat/completions",
|
||||
"model": "vision-model",
|
||||
"openai_api_key": "legacy-key",
|
||||
"max_steps": 30,
|
||||
"timeout_seconds": 600
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.api_key.as_deref(), Some("legacy-key"));
|
||||
assert_eq!(config.model, "vision-model");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,29 +81,6 @@ pub enum OtgHidProfile {
|
||||
Custom,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[derive(Default)]
|
||||
pub enum OtgEndpointBudget {
|
||||
#[default]
|
||||
Auto,
|
||||
Five,
|
||||
Six,
|
||||
Unlimited,
|
||||
}
|
||||
|
||||
impl OtgEndpointBudget {
|
||||
pub fn endpoint_limit_raw(&self) -> Option<u8> {
|
||||
match self {
|
||||
Self::Five => Some(5),
|
||||
Self::Six => Some(6),
|
||||
Self::Unlimited => None,
|
||||
Self::Auto => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(default)]
|
||||
@@ -154,26 +131,6 @@ impl OtgHidFunctions {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.keyboard && !self.mouse_relative && !self.mouse_absolute && !self.consumer
|
||||
}
|
||||
|
||||
pub fn endpoint_cost(&self, keyboard_leds: bool) -> u8 {
|
||||
let mut endpoints = 0;
|
||||
if self.keyboard {
|
||||
endpoints += 1;
|
||||
if keyboard_leds {
|
||||
endpoints += 1;
|
||||
}
|
||||
}
|
||||
if self.mouse_relative {
|
||||
endpoints += 1;
|
||||
}
|
||||
if self.mouse_absolute {
|
||||
endpoints += 1;
|
||||
}
|
||||
if self.consumer {
|
||||
endpoints += 1;
|
||||
}
|
||||
endpoints
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OtgHidFunctions {
|
||||
@@ -216,8 +173,6 @@ pub struct HidConfig {
|
||||
#[serde(default)]
|
||||
pub otg_profile: OtgHidProfile,
|
||||
#[serde(default)]
|
||||
pub otg_endpoint_budget: OtgEndpointBudget,
|
||||
#[serde(default)]
|
||||
pub otg_functions: OtgHidFunctions,
|
||||
#[serde(default)]
|
||||
pub otg_keyboard_leds: bool,
|
||||
@@ -237,7 +192,6 @@ impl Default for HidConfig {
|
||||
otg_udc: None,
|
||||
otg_descriptor: OtgDescriptorConfig::default(),
|
||||
otg_profile: OtgHidProfile::default(),
|
||||
otg_endpoint_budget: OtgEndpointBudget::default(),
|
||||
otg_functions: OtgHidFunctions::default(),
|
||||
otg_keyboard_leds: false,
|
||||
ch9329_port: "/dev/ttyUSB0".to_string(),
|
||||
@@ -262,16 +216,7 @@ impl HidConfig {
|
||||
self.effective_otg_functions()
|
||||
}
|
||||
|
||||
pub fn effective_otg_required_endpoints(&self, msd_enabled: bool) -> u8 {
|
||||
let functions = self.effective_otg_functions();
|
||||
let mut endpoints = functions.endpoint_cost(self.effective_otg_keyboard_leds());
|
||||
if msd_enabled {
|
||||
endpoints += 2;
|
||||
}
|
||||
endpoints
|
||||
}
|
||||
|
||||
pub fn validate_otg_endpoint_budget(&self, msd_enabled: bool) -> crate::error::Result<()> {
|
||||
pub fn validate_otg_functions(&self) -> crate::error::Result<()> {
|
||||
if self.backend != HidBackend::Otg {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -283,17 +228,6 @@ impl HidConfig {
|
||||
));
|
||||
}
|
||||
|
||||
let resolved_limit = self.resolved_otg_endpoint_limit();
|
||||
let required = self.effective_otg_required_endpoints(msd_enabled);
|
||||
if let Some(limit) = resolved_limit {
|
||||
if required > limit {
|
||||
return Err(crate::error::AppError::BadRequest(format!(
|
||||
"OTG selection requires {} endpoints, but the configured limit is {}",
|
||||
required, limit
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -317,30 +251,4 @@ impl HidConfig {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn resolved_otg_endpoint_limit(&self) -> Option<u8> {
|
||||
if self.backend != HidBackend::Otg {
|
||||
return None;
|
||||
}
|
||||
match self.otg_endpoint_budget {
|
||||
OtgEndpointBudget::Five => Some(5),
|
||||
OtgEndpointBudget::Six => Some(6),
|
||||
OtgEndpointBudget::Unlimited => None,
|
||||
OtgEndpointBudget::Auto => {
|
||||
#[cfg(unix)]
|
||||
let udc = self.resolved_otg_udc().unwrap_or_default();
|
||||
#[cfg(unix)]
|
||||
if crate::otg::configfs::is_low_endpoint_udc(&udc) {
|
||||
Some(5)
|
||||
} else {
|
||||
Some(6)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Some(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,18 @@ mod atx;
|
||||
mod common;
|
||||
mod computer_use;
|
||||
mod hid;
|
||||
mod otg_network;
|
||||
mod stream;
|
||||
mod watchdog;
|
||||
mod web;
|
||||
|
||||
pub use atx::*;
|
||||
pub use common::*;
|
||||
pub use computer_use::*;
|
||||
pub use hid::*;
|
||||
pub use otg_network::*;
|
||||
pub use stream::*;
|
||||
pub use watchdog::*;
|
||||
pub use web::*;
|
||||
|
||||
#[typeshare]
|
||||
@@ -27,6 +31,7 @@ pub struct AppConfig {
|
||||
pub auth: AuthConfig,
|
||||
pub video: VideoConfig,
|
||||
pub hid: HidConfig,
|
||||
pub otg_network: OtgNetworkConfig,
|
||||
pub msd: MsdConfig,
|
||||
pub atx: AtxConfig,
|
||||
pub audio: AudioConfig,
|
||||
@@ -38,12 +43,14 @@ pub struct AppConfig {
|
||||
pub vnc: VncConfig,
|
||||
pub rtsp: RtspConfig,
|
||||
pub redfish: RedfishConfig,
|
||||
pub watchdog: WatchdogConfig,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn enforce_invariants(&mut self) {
|
||||
if self.hid.backend != HidBackend::Otg {
|
||||
self.msd.enabled = false;
|
||||
self.otg_network.enabled = false;
|
||||
}
|
||||
self.atx.normalize();
|
||||
}
|
||||
@@ -53,3 +60,18 @@ impl AppConfig {
|
||||
self.enforce_invariants();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_watchdog_config_defaults_to_disabled() {
|
||||
let value = serde_json::to_value(AppConfig::default()).unwrap();
|
||||
let mut object = value.as_object().unwrap().clone();
|
||||
object.remove("watchdog");
|
||||
|
||||
let config: AppConfig = serde_json::from_value(object.into()).unwrap();
|
||||
assert!(!config.watchdog.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
85
src/config/schema/otg_network.rs
Normal file
85
src/config/schema/otg_network.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum OtgNetworkDriverMode {
|
||||
#[default]
|
||||
Ncm,
|
||||
Ecm,
|
||||
Rndis,
|
||||
}
|
||||
|
||||
impl OtgNetworkDriverMode {
|
||||
pub fn function_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Ncm => "ncm",
|
||||
Self::Ecm => "ecm",
|
||||
Self::Rndis => "rndis",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(default)]
|
||||
pub struct OtgNetworkConfig {
|
||||
pub enabled: bool,
|
||||
pub driver_mode: OtgNetworkDriverMode,
|
||||
/// Empty means select the connected NetworkManager Ethernet interface.
|
||||
pub bridge_interface: String,
|
||||
/// Empty values are resolved from the machine identity at runtime.
|
||||
pub host_mac: String,
|
||||
pub device_mac: String,
|
||||
}
|
||||
|
||||
impl OtgNetworkConfig {
|
||||
pub fn validate(&self) -> crate::error::Result<()> {
|
||||
for (name, value) in [
|
||||
("host_mac", self.host_mac.as_str()),
|
||||
("device_mac", self.device_mac.as_str()),
|
||||
] {
|
||||
if !value.is_empty() && !is_valid_unicast_mac(value) {
|
||||
return Err(crate::error::AppError::BadRequest(format!(
|
||||
"OTG network {name} must be a locally administered unicast MAC address"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if !self.host_mac.is_empty()
|
||||
&& !self.device_mac.is_empty()
|
||||
&& self.host_mac.eq_ignore_ascii_case(&self.device_mac)
|
||||
{
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"OTG network host_mac and device_mac must be different".to_string(),
|
||||
));
|
||||
}
|
||||
if self.bridge_interface.contains('/') || self.bridge_interface.contains('\0') {
|
||||
return Err(crate::error::AppError::BadRequest(
|
||||
"Invalid OTG network bridge interface".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid_unicast_mac(value: &str) -> bool {
|
||||
let bytes = value
|
||||
.split(':')
|
||||
.map(|part| u8::from_str_radix(part, 16))
|
||||
.collect::<Result<Vec<_>, _>>();
|
||||
matches!(bytes, Ok(ref bytes) if bytes.len() == 6 && bytes[0] & 0x03 == 0x02)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn validates_local_unicast_mac_addresses() {
|
||||
assert!(is_valid_unicast_mac("02:00:00:00:10:01"));
|
||||
assert!(!is_valid_unicast_mac("01:00:00:00:10:01"));
|
||||
assert!(!is_valid_unicast_mac("00:00:00:00:10:01"));
|
||||
assert!(!is_valid_unicast_mac("bad"));
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,6 @@ pub struct VncConfig {
|
||||
pub bind: String,
|
||||
pub port: u16,
|
||||
pub encoding: VncEncoding,
|
||||
pub jpeg_quality: u8,
|
||||
pub allow_one_client: bool,
|
||||
#[typeshare(skip)]
|
||||
pub password: Option<String>,
|
||||
@@ -54,7 +53,6 @@ impl Default for VncConfig {
|
||||
bind: "0.0.0.0".to_string(),
|
||||
port: 5900,
|
||||
encoding: VncEncoding::TightJpeg,
|
||||
jpeg_quality: 80,
|
||||
allow_one_client: true,
|
||||
password: None,
|
||||
}
|
||||
|
||||
9
src/config/schema/watchdog.rs
Normal file
9
src/config/schema/watchdog.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WatchdogConfig {
|
||||
pub enabled: bool,
|
||||
}
|
||||
@@ -7,8 +7,6 @@ use typeshare::typeshare;
|
||||
pub struct AuthConfig {
|
||||
pub session_timeout_secs: u32,
|
||||
pub single_user_allow_multiple_sessions: bool,
|
||||
pub totp_enabled: bool,
|
||||
pub totp_secret: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
@@ -16,8 +14,6 @@ impl Default for AuthConfig {
|
||||
Self {
|
||||
session_timeout_secs: 3600 * 24,
|
||||
single_user_allow_multiple_sessions: false,
|
||||
totp_enabled: false,
|
||||
totp_secret: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,16 @@ impl ConfigStore {
|
||||
}
|
||||
|
||||
pub async fn load(&self) -> Result<()> {
|
||||
let mut config = Self::load_config(&self.pool).await?;
|
||||
let (mut config, removed_legacy_totp) = Self::load_config(&self.pool).await?;
|
||||
config.enforce_invariants();
|
||||
if removed_legacy_totp {
|
||||
Self::save_config_to_db(&self.pool, &config).await?;
|
||||
}
|
||||
self.cache.store(Arc::new(config));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_config(pool: &Pool<Sqlite>) -> Result<AppConfig> {
|
||||
async fn load_config(pool: &Pool<Sqlite>) -> Result<(AppConfig, bool)> {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'")
|
||||
.fetch_optional(pool)
|
||||
@@ -41,12 +44,24 @@ impl ConfigStore {
|
||||
|
||||
match row {
|
||||
Some((json,)) => {
|
||||
serde_json::from_str(&json).map_err(|e| AppError::Config(e.to_string()))
|
||||
let mut value: serde_json::Value =
|
||||
serde_json::from_str(&json).map_err(|e| AppError::Config(e.to_string()))?;
|
||||
let mut removed = false;
|
||||
if let Some(auth) = value
|
||||
.get_mut("auth")
|
||||
.and_then(|value| value.as_object_mut())
|
||||
{
|
||||
removed |= auth.remove("totp_enabled").is_some();
|
||||
removed |= auth.remove("totp_secret").is_some();
|
||||
}
|
||||
let config =
|
||||
serde_json::from_value(value).map_err(|e| AppError::Config(e.to_string()))?;
|
||||
Ok((config, removed))
|
||||
}
|
||||
None => {
|
||||
let config = AppConfig::default();
|
||||
Self::save_config_to_db(pool, &config).await?;
|
||||
Ok(config)
|
||||
Ok((config, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,4 +169,55 @@ mod tests {
|
||||
assert!(config.initialized);
|
||||
assert_eq!(config.web.http_port, 9000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_watchdog_persistence_does_not_update_cache() {
|
||||
let dir = tempdir().unwrap();
|
||||
let db_path = dir.path().join("test.db");
|
||||
let db = DatabasePool::new(&db_path).await.unwrap();
|
||||
db.init_schema().await.unwrap();
|
||||
let store = ConfigStore::new(db.clone_pool()).unwrap();
|
||||
store.load().await.unwrap();
|
||||
|
||||
sqlx::query("DROP TABLE config")
|
||||
.execute(&db.clone_pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(store
|
||||
.update(|config| config.watchdog.enabled = true)
|
||||
.await
|
||||
.is_err());
|
||||
assert!(!store.get().watchdog.enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_removes_legacy_totp_fields_from_persisted_config() {
|
||||
let dir = tempdir().unwrap();
|
||||
let db = DatabasePool::new(&dir.path().join("test.db"))
|
||||
.await
|
||||
.unwrap();
|
||||
db.init_schema().await.unwrap();
|
||||
let mut value = serde_json::to_value(AppConfig::default()).unwrap();
|
||||
let auth = value.get_mut("auth").unwrap().as_object_mut().unwrap();
|
||||
auth.insert("totp_enabled".to_string(), serde_json::json!(true));
|
||||
auth.insert(
|
||||
"totp_secret".to_string(),
|
||||
serde_json::json!("legacy-secret"),
|
||||
);
|
||||
sqlx::query("INSERT INTO config (key, value) VALUES ('app_config', ?1)")
|
||||
.bind(value.to_string())
|
||||
.execute(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let store = ConfigStore::new(db.clone_pool()).unwrap();
|
||||
store.load().await.unwrap();
|
||||
let (persisted,): (String,) =
|
||||
sqlx::query_as("SELECT value FROM config WHERE key = 'app_config'")
|
||||
.fetch_one(db.pool())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!persisted.contains("totp_enabled"));
|
||||
assert!(!persisted.contains("totp_secret"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ impl DatabasePool {
|
||||
pub async fn init_schema(&self) -> Result<()> {
|
||||
self.create_config_table().await?;
|
||||
self.create_users_table().await?;
|
||||
self.create_user_totp_credentials_table().await?;
|
||||
self.create_api_tokens_table().await?;
|
||||
self.create_wol_history_table().await?;
|
||||
Ok(())
|
||||
@@ -86,6 +87,22 @@ impl DatabasePool {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_user_totp_credentials_table(&self) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS user_totp_credentials (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
secret TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_wol_history_table(&self) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
|
||||
@@ -176,18 +176,9 @@ fn get_meminfo() -> MemInfo {
|
||||
}
|
||||
|
||||
fn get_network_addresses() -> Vec<NetworkAddress> {
|
||||
#[cfg(target_os = "android")]
|
||||
{
|
||||
return get_network_addresses_android();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
{
|
||||
get_network_addresses_ifaddrs()
|
||||
}
|
||||
get_network_addresses_ifaddrs()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn get_network_addresses_ifaddrs() -> Vec<NetworkAddress> {
|
||||
let all_addrs = match nix::ifaddrs::getifaddrs() {
|
||||
Ok(addrs) => addrs,
|
||||
@@ -260,101 +251,6 @@ fn get_network_addresses_ifaddrs() -> Vec<NetworkAddress> {
|
||||
addresses
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn get_network_addresses_android() -> Vec<NetworkAddress> {
|
||||
let net_dir = match std::fs::read_dir("/sys/class/net") {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut addresses = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
|
||||
for entry in net_dir.flatten() {
|
||||
let iface_name = match entry.file_name().into_string() {
|
||||
Ok(name) => name,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if iface_name == "lo" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let operstate_path = entry.path().join("operstate");
|
||||
let is_up = std::fs::read_to_string(&operstate_path)
|
||||
.map(|s| s.trim() == "up")
|
||||
.unwrap_or(false);
|
||||
if !is_up {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(ip) = android_ipv4_for_interface(&iface_name) else {
|
||||
continue;
|
||||
};
|
||||
if ip.is_loopback() || ip.is_unspecified() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ip_str = ip.to_string();
|
||||
if seen.insert((iface_name.clone(), ip_str.clone())) {
|
||||
addresses.push(NetworkAddress {
|
||||
interface: iface_name,
|
||||
ip: ip_str,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addresses
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
fn android_ipv4_for_interface(iface_name: &str) -> Option<std::net::Ipv4Addr> {
|
||||
use std::ffi::CString;
|
||||
use std::mem::{size_of, zeroed};
|
||||
|
||||
let name = CString::new(iface_name).ok()?;
|
||||
if name.as_bytes().len() >= libc::IFNAMSIZ {
|
||||
return None;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0);
|
||||
if fd < 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut request: libc::ifreq = zeroed();
|
||||
std::ptr::copy_nonoverlapping(
|
||||
name.as_ptr(),
|
||||
request.ifr_name.as_mut_ptr(),
|
||||
name.as_bytes_with_nul().len(),
|
||||
);
|
||||
|
||||
let request_code = libc::SIOCGIFADDR.try_into().ok()?;
|
||||
let result = libc::ioctl(fd, request_code, &mut request);
|
||||
libc::close(fd);
|
||||
if result < 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let sockaddr = request.ifr_ifru.ifru_addr;
|
||||
if sockaddr.sa_family as libc::c_int != libc::AF_INET {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut storage = [0u8; size_of::<libc::sockaddr_in>()];
|
||||
std::ptr::copy_nonoverlapping(
|
||||
&sockaddr as *const libc::sockaddr as *const u8,
|
||||
storage.as_mut_ptr(),
|
||||
size_of::<libc::sockaddr>(),
|
||||
);
|
||||
let sockaddr_in = &*(storage.as_ptr() as *const libc::sockaddr_in);
|
||||
Some(std::net::Ipv4Addr::from(u32::from_be(
|
||||
sockaddr_in.sin_addr.s_addr,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_cpu_model_from_cpuinfo_content, parse_device_tree_model_bytes};
|
||||
|
||||
@@ -14,6 +14,12 @@ pub enum AppError {
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("Too many attempts: {0}")]
|
||||
RateLimited(String),
|
||||
|
||||
#[error("Persistence error: {0}")]
|
||||
Persistence(String),
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use self::types::EXACT_EVENT_TOPICS;
|
||||
|
||||
pub use types::{
|
||||
AtxDeviceInfo, AudioDeviceInfo, ClientStats, HidDeviceInfo, LedState, MsdDeviceInfo,
|
||||
StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
|
||||
MsdDeviceMediaInfo, StreamDeviceLostKind, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
|
||||
};
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
@@ -42,12 +42,24 @@ pub struct HidDeviceInfo {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MsdDeviceInfo {
|
||||
pub available: bool,
|
||||
pub mode: String,
|
||||
pub connected: bool,
|
||||
pub image_id: Option<String>,
|
||||
pub disk_mode: String,
|
||||
pub slot_capacity: u8,
|
||||
pub mounted_count: u8,
|
||||
pub mounted_media: Vec<MsdDeviceMediaInfo>,
|
||||
pub usb_reenumerating: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MsdDeviceMediaInfo {
|
||||
pub id: String,
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub cdrom: bool,
|
||||
pub read_only: bool,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AtxDeviceInfo {
|
||||
pub available: bool,
|
||||
|
||||
@@ -83,6 +83,10 @@ pub trait HidBackend: Send + Sync {
|
||||
|
||||
async fn reset(&self) -> Result<()>;
|
||||
|
||||
async fn prepare_rebuild(&self) -> Result<()> {
|
||||
self.shutdown().await
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<()>;
|
||||
|
||||
fn runtime_snapshot(&self) -> HidBackendRuntimeSnapshot;
|
||||
|
||||
@@ -234,6 +234,30 @@ impl HidController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn prepare_otg_rebuild(&self) -> Result<()> {
|
||||
if !matches!(*self.backend_type.read().await, HidBackendType::Otg) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Preparing OTG HID backend for gadget rebuild");
|
||||
self.backend_available.store(false, Ordering::Release);
|
||||
self.stop_runtime_worker().await;
|
||||
|
||||
if let Some(backend) = self.backend.write().await.take() {
|
||||
backend.prepare_rebuild().await?;
|
||||
}
|
||||
|
||||
let current = self.runtime_state.read().await.clone();
|
||||
let rebuilding_state = HidRuntimeState::with_error(
|
||||
&HidBackendType::Otg,
|
||||
¤t,
|
||||
"OTG gadget is rebuilding",
|
||||
"rebuilding",
|
||||
);
|
||||
self.apply_runtime_state(rebuilding_state).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_keyboard(&self, event: KeyboardEvent) -> Result<()> {
|
||||
if !self.backend_available.load(Ordering::Acquire) {
|
||||
return Err(AppError::BadRequest(
|
||||
|
||||
@@ -903,6 +903,19 @@ impl HidBackend for OtgBackend {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn prepare_rebuild(&self) -> Result<()> {
|
||||
self.stop_runtime_worker();
|
||||
*self.keyboard_dev.lock() = None;
|
||||
*self.mouse_rel_dev.lock() = None;
|
||||
*self.mouse_abs_dev.lock() = None;
|
||||
*self.consumer_dev.lock() = None;
|
||||
self.initialized.store(false, Ordering::Relaxed);
|
||||
self.online.store(false, Ordering::Relaxed);
|
||||
self.notify_runtime_changed();
|
||||
info!("OTG backend prepared for gadget rebuild");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown(&self) -> Result<()> {
|
||||
self.stop_runtime_worker();
|
||||
|
||||
@@ -957,6 +970,7 @@ impl Drop for OtgBackend {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
#[test]
|
||||
fn test_led_state() {
|
||||
@@ -973,4 +987,22 @@ mod tests {
|
||||
let kb_report = KeyboardReport::default();
|
||||
assert_eq!(kb_report.to_bytes().len(), 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prepare_rebuild_closes_devices_without_writing_reset_reports() {
|
||||
let mut file = tempfile::tempfile().unwrap();
|
||||
file.write_all(b"sentinel").unwrap();
|
||||
file.seek(SeekFrom::Start(0)).unwrap();
|
||||
|
||||
let backend = OtgBackend::from_handles(HidDevicePaths::default()).unwrap();
|
||||
*backend.keyboard_dev.lock() = Some(file);
|
||||
backend.initialized.store(true, Ordering::Relaxed);
|
||||
backend.online.store(true, Ordering::Relaxed);
|
||||
|
||||
backend.prepare_rebuild().await.unwrap();
|
||||
|
||||
assert!(backend.keyboard_dev.lock().is_none());
|
||||
assert!(!backend.initialized.load(Ordering::Relaxed));
|
||||
assert!(!backend.online.load(Ordering::Relaxed));
|
||||
}
|
||||
}
|
||||
|
||||
63
src/lib.rs
63
src/lib.rs
@@ -1,68 +1,67 @@
|
||||
//! Core library for One-KVM (IP‑KVM: capture, HID, OTG, streaming, Web UI glue).
|
||||
|
||||
#[cfg(not(any(feature = "android", unix, windows)))]
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
compile_error!("One-KVM supports Linux and Windows targets only.");
|
||||
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
pub mod runtime;
|
||||
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod atx;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod audio;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod auth;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod computer_use;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod config;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod db;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod diagnostics;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod error;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod events;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod extensions;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod hid;
|
||||
#[cfg(all(unix, any(feature = "android", feature = "desktop")))]
|
||||
#[cfg(all(unix, feature = "desktop"))]
|
||||
pub mod msd;
|
||||
#[cfg(all(unix, any(feature = "android", feature = "desktop")))]
|
||||
#[cfg(all(unix, feature = "desktop"))]
|
||||
pub mod otg;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod platform;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod redfish;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod rtsp;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod rustdesk;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod state;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod stream;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod stream_encoder;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod update;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod utils;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod video;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod vnc;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod watchdog;
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod web;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod webrtc;
|
||||
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod secrets {
|
||||
include!(concat!(env!("OUT_DIR"), "/secrets_generated.rs"));
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub use error::{AppError, Result};
|
||||
|
||||
112
src/main.rs
112
src/main.rs
@@ -14,7 +14,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use one_kvm::atx::AtxController;
|
||||
use one_kvm::audio::{AudioController, AudioControllerConfig, AudioQuality};
|
||||
use one_kvm::auth::{SessionStore, UserStore};
|
||||
use one_kvm::auth::{SessionStore, TwoFactorService, UserStore};
|
||||
use one_kvm::computer_use::ComputerUseManager;
|
||||
use one_kvm::config::{self, AppConfig, ConfigStore};
|
||||
use one_kvm::db::DatabasePool;
|
||||
@@ -65,7 +65,12 @@ struct CliArgs {
|
||||
address: Option<String>,
|
||||
|
||||
/// HTTP port (overrides database config)
|
||||
#[arg(short = 'p', long, value_name = "PORT")]
|
||||
#[arg(
|
||||
short = 'p',
|
||||
long = "port",
|
||||
visible_alias = "http-port",
|
||||
value_name = "PORT"
|
||||
)]
|
||||
http_port: Option<u16>,
|
||||
|
||||
/// HTTPS port (overrides database config)
|
||||
@@ -84,7 +89,7 @@ struct CliArgs {
|
||||
#[arg(long, value_name = "FILE", requires = "ssl_cert")]
|
||||
ssl_key: Option<PathBuf>,
|
||||
|
||||
/// Data directory path (default: /etc/one-kvm, or the executable directory on Windows)
|
||||
/// Data directory path
|
||||
#[arg(short = 'd', long, value_name = "DIR")]
|
||||
data_dir: Option<PathBuf>,
|
||||
|
||||
@@ -113,6 +118,8 @@ struct UserCommand {
|
||||
enum UserAction {
|
||||
/// Set password for the single local user (interactive terminal prompt)
|
||||
SetPassword,
|
||||
/// Disable TOTP for the single local user
|
||||
DisableTotp,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -183,6 +190,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
let session_store = SessionStore::new(config.auth.session_timeout_secs as i64);
|
||||
|
||||
let user_store = UserStore::new(db.clone_pool());
|
||||
let two_factor = TwoFactorService::new(db.clone_pool());
|
||||
|
||||
let (shutdown_tx, _) = broadcast::channel::<ShutdownAction>(1);
|
||||
|
||||
@@ -299,7 +307,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("OTG Service created");
|
||||
|
||||
#[cfg(unix)]
|
||||
if let Err(e) = otg_service.apply_config(&config.hid, &config.msd).await {
|
||||
if let Err(e) = otg_service
|
||||
.apply_config(&config.hid, &config.msd, &config.otg_network)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to apply OTG config: {}", e);
|
||||
}
|
||||
|
||||
@@ -324,24 +335,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
#[cfg(unix)]
|
||||
let msd = if config.msd.enabled {
|
||||
let ventoy_resource_dir = data_dir.join("ventoy");
|
||||
if ventoy_resource_dir.exists() {
|
||||
if let Err(e) = ventoy_img::init_resources(&ventoy_resource_dir) {
|
||||
tracing::warn!("Failed to initialize Ventoy resources: {}", e);
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Ventoy resources initialized from {}",
|
||||
ventoy_resource_dir.display()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Ventoy resource directory not found: {}",
|
||||
ventoy_resource_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
let controller = MsdController::new(otg_service.clone(), config.msd.msd_dir_path());
|
||||
if let Err(e) = controller.init().await {
|
||||
if let Err(e) = controller.init(&ventoy_resource_dir).await {
|
||||
tracing::warn!("Failed to initialize MSD controller: {}", e);
|
||||
None
|
||||
} else {
|
||||
@@ -563,6 +558,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
config_store.clone(),
|
||||
session_store,
|
||||
user_store,
|
||||
two_factor,
|
||||
#[cfg(unix)]
|
||||
otg_service,
|
||||
stream_manager,
|
||||
@@ -583,6 +579,17 @@ async fn main() -> anyhow::Result<()> {
|
||||
data_dir.clone(),
|
||||
);
|
||||
|
||||
if config.watchdog.enabled {
|
||||
if let Err(error) = state.watchdog.enable().await {
|
||||
tracing::error!(
|
||||
"Configured hardware watchdog failed to start; web service will continue: {}",
|
||||
error
|
||||
);
|
||||
} else {
|
||||
tracing::info!("Hardware watchdog started");
|
||||
}
|
||||
}
|
||||
|
||||
extensions.set_event_bus(events.clone()).await;
|
||||
|
||||
if let Some(ref service) = rustdesk {
|
||||
@@ -624,8 +631,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
{
|
||||
let runtime_config = state.config.get();
|
||||
let runtime_config = state.runtime_third_party_config().await;
|
||||
let constraints = StreamCodecConstraints::from_config(&runtime_config);
|
||||
state
|
||||
.stream_manager
|
||||
.set_runtime_codec_constraints(constraints.clone())
|
||||
.await;
|
||||
match enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await {
|
||||
Ok(result) if result.changed => {
|
||||
if let Some(message) = result.message {
|
||||
@@ -668,9 +679,11 @@ async fn main() -> anyhow::Result<()> {
|
||||
let mut shutdown_rx = shutdown_tx.subscribe();
|
||||
async move {
|
||||
tokio::select! {
|
||||
result = tokio::signal::ctrl_c() => {
|
||||
result.expect("Failed to install CTRL+C handler");
|
||||
tracing::info!("Shutdown signal received");
|
||||
result = shutdown_signal() => {
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Failed while waiting for shutdown signal: {}", e);
|
||||
}
|
||||
tracing::info!("SIGINT or SIGTERM received");
|
||||
ShutdownAction::Exit
|
||||
}
|
||||
request = shutdown_rx.recv() => {
|
||||
@@ -794,6 +807,24 @@ fn get_data_dir() -> PathBuf {
|
||||
PathBuf::from("/etc/one-kvm")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn shutdown_signal() -> anyhow::Result<()> {
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
|
||||
let mut terminate = signal(SignalKind::terminate())?;
|
||||
tokio::select! {
|
||||
result = tokio::signal::ctrl_c() => result?,
|
||||
_ = terminate.recv() => {},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
async fn shutdown_signal() -> anyhow::Result<()> {
|
||||
tokio::signal::ctrl_c().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn open_database_pool(data_dir: &Path) -> anyhow::Result<DatabasePool> {
|
||||
let db_path = data_dir.join("one-kvm.db");
|
||||
let db = DatabasePool::new(&db_path).await?;
|
||||
@@ -850,10 +881,13 @@ async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Resu
|
||||
tokio::fs::create_dir_all(&data_dir).await?;
|
||||
let db = open_database_pool(&data_dir).await?;
|
||||
let users = UserStore::new(db.clone_pool());
|
||||
let two_factor = TwoFactorService::new(db.clone_pool());
|
||||
let sessions = SessionStore::new(0);
|
||||
|
||||
match command {
|
||||
CliCommand::User(user) => run_user_action(user.action, &users, &sessions).await,
|
||||
CliCommand::User(user) => {
|
||||
run_user_action(user.action, &users, &sessions, &two_factor).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -919,12 +953,26 @@ async fn run_user_action(
|
||||
action: UserAction,
|
||||
users: &UserStore,
|
||||
sessions: &SessionStore,
|
||||
two_factor: &TwoFactorService,
|
||||
) -> anyhow::Result<()> {
|
||||
match action {
|
||||
UserAction::SetPassword => set_user_password(users, sessions).await,
|
||||
UserAction::DisableTotp => disable_user_totp(users, two_factor).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn disable_user_totp(users: &UserStore, two_factor: &TwoFactorService) -> anyhow::Result<()> {
|
||||
let user = users.single_user().await?.ok_or_else(|| {
|
||||
anyhow::anyhow!("No local user exists yet; complete setup in the web UI first.")
|
||||
})?;
|
||||
if two_factor.disable_without_code(&user.id).await? {
|
||||
println!("TOTP disabled for user '{}'.", user.username);
|
||||
} else {
|
||||
println!("TOTP is already disabled for user '{}'.", user.username);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> {
|
||||
let user = users.single_user().await?.ok_or_else(|| {
|
||||
anyhow::anyhow!("No local user exists yet; complete setup in the web UI first.")
|
||||
@@ -1207,6 +1255,11 @@ async fn cleanup(state: &Arc<AppState>) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
if let Err(e) = state.otg_service.shutdown().await {
|
||||
tracing::warn!("Failed to shutdown OTG: {}", e);
|
||||
}
|
||||
|
||||
if let Some(atx) = state.atx.write().await.as_mut() {
|
||||
if let Err(e) = atx.shutdown().await {
|
||||
tracing::warn!("Failed to shutdown ATX: {}", e);
|
||||
@@ -1216,4 +1269,11 @@ async fn cleanup(state: &Arc<AppState>) {
|
||||
if let Err(e) = state.audio.shutdown().await {
|
||||
tracing::warn!("Failed to shutdown audio: {}", e);
|
||||
}
|
||||
|
||||
if let Err(error) = state.watchdog.disable().await {
|
||||
tracing::error!(
|
||||
"CRITICAL: failed to disable hardware watchdog during shutdown: {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
@@ -7,7 +7,10 @@ use tracing::{debug, info, warn};
|
||||
|
||||
use super::image::ImageManager;
|
||||
use super::monitor::MsdHealthMonitor;
|
||||
use super::types::{DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MsdMode, MsdState};
|
||||
use super::types::{
|
||||
DiskMode, DownloadProgress, DownloadStatus, DriveInfo, ImageInfo, MountedMedia,
|
||||
MountedMediaKind, MsdState,
|
||||
};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::otg::{MsdFunction, MsdLunConfig, OtgService};
|
||||
|
||||
@@ -44,9 +47,21 @@ impl MsdController {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn init(&self) -> Result<()> {
|
||||
pub async fn init(&self, ventoy_resource_dir: &Path) -> Result<()> {
|
||||
info!("Initializing MSD controller");
|
||||
|
||||
match ventoy_img::init_resources(ventoy_resource_dir) {
|
||||
Ok(()) => info!(
|
||||
"Ventoy resources ready from {}",
|
||||
ventoy_resource_dir.display()
|
||||
),
|
||||
Err(e) => warn!(
|
||||
"Failed to initialize Ventoy resources from {}: {}. Ventoy drive creation will be unavailable, but regular ISO/IMG MSD remains available",
|
||||
ventoy_resource_dir.display(),
|
||||
e
|
||||
),
|
||||
}
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(&self.images_path) {
|
||||
warn!("Failed to create images directory: {}", e);
|
||||
}
|
||||
@@ -62,17 +77,23 @@ impl MsdController {
|
||||
*self.msd_function.write().await = Some(msd_func);
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
state.disk_mode = if self.otg_service.msd_lun_capacity().await == 1 {
|
||||
DiskMode::Single
|
||||
} else {
|
||||
DiskMode::Multi
|
||||
};
|
||||
state.available = true;
|
||||
|
||||
if self.drive_path.exists() {
|
||||
if let Ok(metadata) = std::fs::metadata(&self.drive_path) {
|
||||
state.drive_info = Some(DriveInfo {
|
||||
let drive_info = DriveInfo {
|
||||
size: metadata.len(),
|
||||
used: 0,
|
||||
free: metadata.len(),
|
||||
initialized: true,
|
||||
path: self.drive_path.clone(),
|
||||
});
|
||||
};
|
||||
state.drive_info = Some(drive_info.clone());
|
||||
debug!(
|
||||
"Found existing virtual drive: {}",
|
||||
self.drive_path.display()
|
||||
@@ -104,20 +125,34 @@ impl MsdController {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_available(&self) -> bool {
|
||||
self.state.read().await.available
|
||||
pub async fn mount_image(&self, image: &ImageInfo, cdrom: bool, read_only: bool) -> Result<()> {
|
||||
self.mount_image_in_slot(image, cdrom, read_only, None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn connect_image(
|
||||
pub async fn mount_image_at_lun(
|
||||
&self,
|
||||
image: &ImageInfo,
|
||||
cdrom: bool,
|
||||
read_only: bool,
|
||||
lun: u8,
|
||||
) -> Result<()> {
|
||||
self.mount_image_in_slot(image, cdrom, read_only, Some(lun))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn mount_image_in_slot(
|
||||
&self,
|
||||
image: &ImageInfo,
|
||||
cdrom: bool,
|
||||
read_only: bool,
|
||||
requested_lun: Option<u8>,
|
||||
) -> Result<()> {
|
||||
let _op_guard = self.operation_lock.write().await;
|
||||
let mut state = self.state.write().await;
|
||||
let previous_state = state.clone();
|
||||
|
||||
self.assert_can_connect(&state).await?;
|
||||
self.assert_available(&state).await?;
|
||||
|
||||
if !image.path.exists() {
|
||||
let error_msg = format!("Image file not found: {}", image.path.display());
|
||||
@@ -127,20 +162,29 @@ impl MsdController {
|
||||
return Err(AppError::Internal(error_msg));
|
||||
}
|
||||
|
||||
let config = if cdrom {
|
||||
MsdLunConfig::cdrom(image.path.clone())
|
||||
} else {
|
||||
MsdLunConfig::disk(image.path.clone(), read_only)
|
||||
};
|
||||
self.configure_lun_now(&config).await?;
|
||||
if state
|
||||
.mounted_media
|
||||
.iter()
|
||||
.any(|media| media.kind == MountedMediaKind::Image && media.id == image.id)
|
||||
{
|
||||
return Err(AppError::BadRequest("Image is already mounted".to_string()));
|
||||
}
|
||||
|
||||
state.connected = true;
|
||||
state.mode = MsdMode::Image;
|
||||
state.current_image = Some(image.clone());
|
||||
let lun = Self::select_lun(&state, requested_lun)?;
|
||||
|
||||
let media = MountedMedia::image(lun, image, cdrom, read_only);
|
||||
if let Err(e) = self.configure_media(&media).await {
|
||||
*state = previous_state;
|
||||
return Err(e);
|
||||
}
|
||||
state.mounted_media.push(media);
|
||||
|
||||
info!(
|
||||
"Connected image: {} (cdrom={}, ro={})",
|
||||
image.name, cdrom, read_only
|
||||
"Mounted image: {} on LUN {} (cdrom={}, ro={})",
|
||||
image.name,
|
||||
lun,
|
||||
cdrom,
|
||||
cdrom || read_only
|
||||
);
|
||||
|
||||
drop(state);
|
||||
@@ -150,11 +194,12 @@ impl MsdController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn connect_drive(&self) -> Result<()> {
|
||||
pub async fn mount_drive(&self) -> Result<()> {
|
||||
let _op_guard = self.operation_lock.write().await;
|
||||
let mut state = self.state.write().await;
|
||||
let previous_state = state.clone();
|
||||
|
||||
self.assert_can_connect(&state).await?;
|
||||
self.assert_available(&state).await?;
|
||||
|
||||
if !self.drive_path.exists() {
|
||||
let err =
|
||||
@@ -165,14 +210,48 @@ impl MsdController {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let config = MsdLunConfig::disk(self.drive_path.clone(), false);
|
||||
self.configure_lun_now(&config).await?;
|
||||
let drive_info = state.drive_info.clone().or_else(|| {
|
||||
std::fs::metadata(&self.drive_path)
|
||||
.ok()
|
||||
.map(|metadata| DriveInfo {
|
||||
size: metadata.len(),
|
||||
used: 0,
|
||||
free: metadata.len(),
|
||||
initialized: true,
|
||||
path: self.drive_path.clone(),
|
||||
})
|
||||
});
|
||||
if state.drive_info.is_none() {
|
||||
state.drive_info = drive_info.clone();
|
||||
}
|
||||
|
||||
state.connected = true;
|
||||
state.mode = MsdMode::Drive;
|
||||
state.current_image = None;
|
||||
if state
|
||||
.mounted_media
|
||||
.iter()
|
||||
.any(|media| media.kind == MountedMediaKind::Drive)
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Virtual drive is already mounted".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
info!("Connected virtual drive: {}", self.drive_path.display());
|
||||
let drive_info = drive_info
|
||||
.ok_or_else(|| AppError::Internal("Virtual drive info is unavailable".to_string()))?;
|
||||
let lun = Self::lowest_free_lun(&state)
|
||||
.ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()))?;
|
||||
|
||||
let media = MountedMedia::drive(lun, &drive_info);
|
||||
if let Err(e) = self.configure_media(&media).await {
|
||||
*state = previous_state;
|
||||
return Err(e);
|
||||
}
|
||||
state.mounted_media.push(media);
|
||||
|
||||
info!(
|
||||
"Mounted virtual drive on LUN {}: {}",
|
||||
lun,
|
||||
self.drive_path.display()
|
||||
);
|
||||
|
||||
drop(state);
|
||||
drop(_op_guard);
|
||||
@@ -181,22 +260,165 @@ impl MsdController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_can_connect(&self, state: &MsdState) -> Result<()> {
|
||||
async fn assert_available(&self, state: &MsdState) -> Result<()> {
|
||||
if !state.available {
|
||||
self.monitor
|
||||
.report_error("MSD not available", "not_available")
|
||||
.await;
|
||||
return Err(AppError::Internal("MSD not available".to_string()));
|
||||
}
|
||||
if state.connected {
|
||||
return Err(AppError::Internal(
|
||||
"Already connected. Disconnect first.".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn configure_lun_now(&self, config: &MsdLunConfig) -> Result<()> {
|
||||
fn media_config(media: &MountedMedia) -> MsdLunConfig {
|
||||
if media.cdrom {
|
||||
MsdLunConfig::cdrom(media.path.clone())
|
||||
} else {
|
||||
MsdLunConfig::disk(media.path.clone(), media.read_only)
|
||||
}
|
||||
}
|
||||
|
||||
fn lowest_free_lun(state: &MsdState) -> Option<u8> {
|
||||
(0..state.disk_mode.capacity())
|
||||
.find(|lun| !state.mounted_media.iter().any(|media| media.lun == *lun))
|
||||
}
|
||||
|
||||
fn select_lun(state: &MsdState, requested_lun: Option<u8>) -> Result<u8> {
|
||||
let Some(lun) = requested_lun else {
|
||||
return Self::lowest_free_lun(state)
|
||||
.ok_or_else(|| AppError::BadRequest("Media slots are full".to_string()));
|
||||
};
|
||||
|
||||
if lun >= state.disk_mode.capacity() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Media slot {} is outside the current disk mode capacity",
|
||||
lun + 1
|
||||
)));
|
||||
}
|
||||
if state.mounted_media.iter().any(|media| media.lun == lun) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Media slot {} is already occupied",
|
||||
lun + 1
|
||||
)));
|
||||
}
|
||||
Ok(lun)
|
||||
}
|
||||
|
||||
fn reset_mounts_for_mode(state: &mut MsdState, disk_mode: DiskMode) {
|
||||
state.disk_mode = disk_mode;
|
||||
state.mounted_media.clear();
|
||||
}
|
||||
|
||||
pub async fn set_disk_mode(&self, disk_mode: DiskMode) -> Result<bool> {
|
||||
let _op_guard = self.operation_lock.write().await;
|
||||
let previous_state = {
|
||||
let mut state = self.state.write().await;
|
||||
self.assert_available(&state).await?;
|
||||
if state.disk_mode == disk_mode {
|
||||
return Ok(false);
|
||||
}
|
||||
let previous_state = state.clone();
|
||||
state.usb_reenumerating = true;
|
||||
previous_state
|
||||
};
|
||||
self.mark_device_info_dirty().await;
|
||||
|
||||
let switch_result = async {
|
||||
self.otg_service
|
||||
.set_msd_lun_capacity(disk_mode.capacity())
|
||||
.await?;
|
||||
self.otg_service.msd_function().await.ok_or_else(|| {
|
||||
AppError::Internal("MSD function missing after OTG rebuild".to_string())
|
||||
})
|
||||
}
|
||||
.await;
|
||||
|
||||
let msd_function = match switch_result {
|
||||
Ok(msd_function) => msd_function,
|
||||
Err(switch_error) => {
|
||||
if let Err(rollback_error) = self.rollback_mode_switch(&previous_state).await {
|
||||
let mut state = self.state.write().await;
|
||||
state.available = false;
|
||||
state.mounted_media.clear();
|
||||
state.usb_reenumerating = false;
|
||||
*self.msd_function.write().await = None;
|
||||
let error_msg = format!(
|
||||
"Failed to switch MSD disk mode: {switch_error}; rollback failed: {rollback_error}"
|
||||
);
|
||||
self.monitor
|
||||
.report_error(&error_msg, "disk_mode_rollback_failed")
|
||||
.await;
|
||||
self.mark_device_info_dirty().await;
|
||||
return Err(AppError::Internal(error_msg));
|
||||
}
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
*state = previous_state;
|
||||
state.usb_reenumerating = false;
|
||||
let error_msg = format!("Failed to switch MSD disk mode: {switch_error}");
|
||||
self.monitor
|
||||
.report_error(&error_msg, "disk_mode_switch_failed")
|
||||
.await;
|
||||
self.mark_device_info_dirty().await;
|
||||
return Err(AppError::Internal(error_msg));
|
||||
}
|
||||
};
|
||||
*self.msd_function.write().await = Some(msd_function);
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
Self::reset_mounts_for_mode(&mut state, disk_mode);
|
||||
state.usb_reenumerating = false;
|
||||
info!("Switched MSD disk mode to {:?}", disk_mode);
|
||||
|
||||
drop(state);
|
||||
drop(_op_guard);
|
||||
|
||||
self.mark_device_info_dirty().await;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn unmount_image(&self, image_id: &str) -> Result<()> {
|
||||
self.unmount_media(|media| media.kind == MountedMediaKind::Image && media.id == image_id)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn unmount_drive(&self) -> Result<()> {
|
||||
self.unmount_media(|media| media.kind == MountedMediaKind::Drive)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn unmount_lun(&self, lun: u8) -> Result<bool> {
|
||||
self.unmount_media(|media| media.lun == lun).await
|
||||
}
|
||||
|
||||
async fn unmount_media<F>(&self, predicate: F) -> Result<bool>
|
||||
where
|
||||
F: Fn(&MountedMedia) -> bool,
|
||||
{
|
||||
let _op_guard = self.operation_lock.write().await;
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
let Some(index) = state.mounted_media.iter().position(predicate) else {
|
||||
debug!("Requested media was not mounted, skipping unmount");
|
||||
return Ok(false);
|
||||
};
|
||||
let media = state.mounted_media[index].clone();
|
||||
|
||||
self.disconnect_lun(media.lun).await?;
|
||||
state.mounted_media.remove(index);
|
||||
info!("Unmounted media");
|
||||
|
||||
drop(state);
|
||||
drop(_op_guard);
|
||||
|
||||
self.mark_device_info_dirty().await;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn configure_media(&self, media: &MountedMedia) -> Result<()> {
|
||||
let gadget_path = self.active_gadget_path().await?;
|
||||
let msd_hold = self.msd_function.read().await;
|
||||
let Some(ref msd) = *msd_hold else {
|
||||
@@ -207,8 +429,11 @@ impl MsdController {
|
||||
"MSD function not initialized".to_string(),
|
||||
));
|
||||
};
|
||||
if let Err(e) = msd.configure_lun_async(&gadget_path, 0, config).await {
|
||||
let error_msg = format!("Failed to configure LUN: {}", e);
|
||||
if let Err(e) = msd
|
||||
.configure_lun_async(&gadget_path, media.lun, &Self::media_config(media))
|
||||
.await
|
||||
{
|
||||
let error_msg = format!("Failed to configure LUN {}: {}", media.lun, e);
|
||||
self.monitor
|
||||
.report_error(&error_msg, "configfs_error")
|
||||
.await;
|
||||
@@ -217,6 +442,29 @@ impl MsdController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect_lun(&self, lun: u8) -> Result<()> {
|
||||
let gadget_path = self.active_gadget_path().await?;
|
||||
let msd_hold = self.msd_function.read().await;
|
||||
let msd = msd_hold
|
||||
.as_ref()
|
||||
.ok_or_else(|| AppError::Internal("MSD function not initialized".to_string()))?;
|
||||
msd.disconnect_lun_async(&gadget_path, lun).await
|
||||
}
|
||||
|
||||
async fn rollback_mode_switch(&self, previous_state: &MsdState) -> Result<()> {
|
||||
self.otg_service
|
||||
.set_msd_lun_capacity(previous_state.disk_mode.capacity())
|
||||
.await?;
|
||||
let msd_function = self.otg_service.msd_function().await.ok_or_else(|| {
|
||||
AppError::Internal("MSD function missing after OTG rollback".to_string())
|
||||
})?;
|
||||
*self.msd_function.write().await = Some(msd_function);
|
||||
for media in &previous_state.mounted_media {
|
||||
self.configure_media(media).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn finish_connect_success(&self) {
|
||||
if self.monitor.is_error().await {
|
||||
self.monitor.report_recovered().await;
|
||||
@@ -228,22 +476,31 @@ impl MsdController {
|
||||
let _op_guard = self.operation_lock.write().await;
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
|
||||
if !state.connected {
|
||||
debug!("Nothing connected, skipping disconnect");
|
||||
if state.mounted_media.is_empty() {
|
||||
debug!("Nothing mounted, skipping disconnect");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let gadget_path = self.active_gadget_path().await?;
|
||||
if let Some(ref msd) = *self.msd_function.read().await {
|
||||
msd.disconnect_lun_async(&gadget_path, 0).await?;
|
||||
let mounted_media = state.mounted_media.clone();
|
||||
let mut disconnected = Vec::new();
|
||||
for media in &mounted_media {
|
||||
if let Err(error) = self.disconnect_lun(media.lun).await {
|
||||
for prior in &disconnected {
|
||||
if let Err(restore_error) = self.configure_media(prior).await {
|
||||
state.available = false;
|
||||
return Err(AppError::Internal(format!(
|
||||
"Failed to disconnect LUN {}: {error}; restore failed: {restore_error}",
|
||||
media.lun
|
||||
)));
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
disconnected.push(media.clone());
|
||||
}
|
||||
|
||||
state.connected = false;
|
||||
state.mode = MsdMode::None;
|
||||
state.current_image = None;
|
||||
|
||||
info!("Disconnected storage");
|
||||
state.mounted_media.clear();
|
||||
info!("Disconnected all mounted media");
|
||||
|
||||
drop(state);
|
||||
drop(_op_guard);
|
||||
@@ -253,29 +510,29 @@ impl MsdController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn images_path(&self) -> &PathBuf {
|
||||
&self.images_path
|
||||
pub async fn is_drive_connected(&self) -> bool {
|
||||
self.state
|
||||
.read()
|
||||
.await
|
||||
.mounted_media
|
||||
.iter()
|
||||
.any(|media| media.kind == MountedMediaKind::Drive)
|
||||
}
|
||||
|
||||
pub fn ventoy_dir(&self) -> &PathBuf {
|
||||
&self.ventoy_dir
|
||||
}
|
||||
pub async fn delete_image(&self, image_id: &str) -> Result<()> {
|
||||
let _op_guard = self.operation_lock.write().await;
|
||||
let state = self.state.read().await;
|
||||
if state
|
||||
.mounted_media
|
||||
.iter()
|
||||
.any(|media| media.kind == MountedMediaKind::Image && media.id == image_id)
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Cannot delete image while it is mounted".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
pub fn drive_path(&self) -> &PathBuf {
|
||||
&self.drive_path
|
||||
}
|
||||
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
self.state.read().await.connected
|
||||
}
|
||||
|
||||
pub async fn mode(&self) -> MsdMode {
|
||||
self.state.read().await.mode.clone()
|
||||
}
|
||||
|
||||
pub async fn update_drive_info(&self, info: DriveInfo) {
|
||||
let mut state = self.state.write().await;
|
||||
state.drive_info = Some(info);
|
||||
ImageManager::new(self.images_path.clone()).delete(image_id)
|
||||
}
|
||||
|
||||
pub async fn download_image(
|
||||
@@ -423,6 +680,8 @@ impl MsdController {
|
||||
|
||||
let mut state = self.state.write().await;
|
||||
state.available = false;
|
||||
state.mounted_media.clear();
|
||||
state.usb_reenumerating = false;
|
||||
|
||||
info!("MSD controller shutdown complete");
|
||||
Ok(())
|
||||
@@ -436,6 +695,7 @@ impl MsdController {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::msd::MULTI_DISK_MSD_LUNS;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
@@ -462,7 +722,228 @@ mod tests {
|
||||
|
||||
let state = controller.state().await;
|
||||
assert!(!state.available);
|
||||
assert!(!state.connected);
|
||||
assert_eq!(state.mode, MsdMode::None);
|
||||
assert_eq!(state.disk_mode, DiskMode::Single);
|
||||
assert!(state.mounted_media.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_disk_mode_only_exposes_lun_zero() {
|
||||
let mut state = MsdState::default();
|
||||
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Single);
|
||||
assert_eq!(state.disk_mode.capacity(), 1);
|
||||
assert_eq!(MsdController::lowest_free_lun(&state), Some(0));
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let image_path = temp_dir.path().join("test.iso");
|
||||
std::fs::write(&image_path, b"iso").unwrap();
|
||||
let image = ImageInfo::new("test".into(), "test.iso".into(), image_path, 3);
|
||||
state
|
||||
.mounted_media
|
||||
.push(MountedMedia::image(0, &image, true, false));
|
||||
|
||||
assert_eq!(MsdController::lowest_free_lun(&state), None);
|
||||
let config = MsdController::media_config(&state.mounted_media[0]);
|
||||
assert!(config.cdrom);
|
||||
assert!(config.ro);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_disk_mode_allocates_lowest_free_lun() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut state = MsdState::default();
|
||||
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
|
||||
|
||||
for lun in [0, 1, 3] {
|
||||
let image_path = temp_dir.path().join(format!("test{lun}.img"));
|
||||
std::fs::write(&image_path, b"img").unwrap();
|
||||
let image = ImageInfo::new(
|
||||
format!("test{lun}"),
|
||||
format!("test{lun}.img"),
|
||||
image_path,
|
||||
3,
|
||||
);
|
||||
state
|
||||
.mounted_media
|
||||
.push(MountedMedia::image(lun, &image, false, false));
|
||||
}
|
||||
|
||||
assert_eq!(MsdController::lowest_free_lun(&state), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_lun_selection_rejects_occupied_and_out_of_range_slots() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let image_path = temp_dir.path().join("test.img");
|
||||
std::fs::write(&image_path, b"img").unwrap();
|
||||
let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3);
|
||||
let mut state = MsdState::default();
|
||||
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
|
||||
state
|
||||
.mounted_media
|
||||
.push(MountedMedia::image(3, &image, false, true));
|
||||
|
||||
assert_eq!(MsdController::select_lun(&state, Some(5)).unwrap(), 5);
|
||||
assert!(MsdController::select_lun(&state, Some(3))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("already occupied"));
|
||||
assert!(MsdController::select_lun(&state, Some(8))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("outside"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_disk_mode_supports_eight_images_and_rejects_ninth_slot() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut state = MsdState::default();
|
||||
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
|
||||
|
||||
for lun in 0..MULTI_DISK_MSD_LUNS {
|
||||
let image_path = temp_dir.path().join(format!("test{lun}.img"));
|
||||
std::fs::write(&image_path, b"img").unwrap();
|
||||
let image = ImageInfo::new(
|
||||
format!("test{lun}"),
|
||||
format!("test{lun}.img"),
|
||||
image_path,
|
||||
3,
|
||||
);
|
||||
let next_lun = MsdController::lowest_free_lun(&state).unwrap();
|
||||
assert_eq!(next_lun, lun);
|
||||
state
|
||||
.mounted_media
|
||||
.push(MountedMedia::image(next_lun, &image, false, false));
|
||||
}
|
||||
|
||||
assert_eq!(state.mounted_media.len(), 8);
|
||||
assert_eq!(MsdController::lowest_free_lun(&state), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_disk_mode_supports_drive_plus_seven_images() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let drive_path = temp_dir.path().join("ventoy.img");
|
||||
std::fs::write(&drive_path, b"drive").unwrap();
|
||||
let drive = DriveInfo {
|
||||
size: 5,
|
||||
used: 0,
|
||||
free: 5,
|
||||
initialized: true,
|
||||
path: drive_path,
|
||||
};
|
||||
let mut state = MsdState::default();
|
||||
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
|
||||
state.mounted_media.push(MountedMedia::drive(0, &drive));
|
||||
|
||||
for lun in 1..MULTI_DISK_MSD_LUNS {
|
||||
let image_path = temp_dir.path().join(format!("test{lun}.img"));
|
||||
std::fs::write(&image_path, b"img").unwrap();
|
||||
let image = ImageInfo::new(
|
||||
format!("test{lun}"),
|
||||
format!("test{lun}.img"),
|
||||
image_path,
|
||||
3,
|
||||
);
|
||||
state
|
||||
.mounted_media
|
||||
.push(MountedMedia::image(lun, &image, false, false));
|
||||
}
|
||||
|
||||
assert_eq!(state.mounted_media.len(), 8);
|
||||
assert_eq!(MsdController::lowest_free_lun(&state), None);
|
||||
assert!(state
|
||||
.mounted_media
|
||||
.iter()
|
||||
.any(|media| media.kind == MountedMediaKind::Drive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_switch_clears_mount_state() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let image_path = temp_dir.path().join("test.img");
|
||||
std::fs::write(&image_path, b"img").unwrap();
|
||||
let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3);
|
||||
let mut state = MsdState::default();
|
||||
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Multi);
|
||||
state
|
||||
.mounted_media
|
||||
.push(MountedMedia::image(0, &image, false, false));
|
||||
|
||||
MsdController::reset_mounts_for_mode(&mut state, DiskMode::Single);
|
||||
|
||||
assert_eq!(state.disk_mode, DiskMode::Single);
|
||||
assert_eq!(state.disk_mode.capacity(), 1);
|
||||
assert!(state.mounted_media.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_image_and_drive_detection_use_media_identity() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let image_path = temp_dir.path().join("test.img");
|
||||
std::fs::write(&image_path, b"img").unwrap();
|
||||
let image = ImageInfo::new("test".into(), "test.img".into(), image_path, 3);
|
||||
let drive = DriveInfo {
|
||||
size: 5,
|
||||
used: 0,
|
||||
free: 5,
|
||||
initialized: true,
|
||||
path: temp_dir.path().join("ventoy.img"),
|
||||
};
|
||||
let mut state = MsdState::default();
|
||||
state
|
||||
.mounted_media
|
||||
.push(MountedMedia::image(0, &image, false, false));
|
||||
state.mounted_media.push(MountedMedia::drive(1, &drive));
|
||||
|
||||
assert!(state
|
||||
.mounted_media
|
||||
.iter()
|
||||
.any(|media| media.kind == MountedMediaKind::Image && media.id == "test"));
|
||||
assert!(state
|
||||
.mounted_media
|
||||
.iter()
|
||||
.any(|media| media.kind == MountedMediaKind::Drive));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_image_is_serialized_with_mount_operations() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let otg_service = Arc::new(OtgService::new());
|
||||
let controller = MsdController::new(otg_service, temp_dir.path());
|
||||
std::fs::create_dir_all(&controller.images_path).unwrap();
|
||||
let image_path = controller.images_path.join("test.img");
|
||||
std::fs::write(&image_path, b"img").unwrap();
|
||||
let image = ImageManager::new(controller.images_path.clone())
|
||||
.get_by_name("test.img")
|
||||
.unwrap();
|
||||
|
||||
controller
|
||||
.state
|
||||
.write()
|
||||
.await
|
||||
.mounted_media
|
||||
.push(MountedMedia::image(0, &image, false, false));
|
||||
assert!(controller.delete_image(&image.id).await.is_err());
|
||||
assert!(image_path.exists());
|
||||
|
||||
controller.state.write().await.mounted_media.clear();
|
||||
controller.delete_image(&image.id).await.unwrap();
|
||||
assert!(!image_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_configs_force_cdrom_read_only() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let image_path = temp_dir.path().join("test.iso");
|
||||
std::fs::write(&image_path, b"iso").unwrap();
|
||||
let image = ImageInfo::new("test".into(), "test.iso".into(), image_path, 3);
|
||||
let mut state = MsdState::default();
|
||||
state
|
||||
.mounted_media
|
||||
.push(MountedMedia::image(0, &image, true, false));
|
||||
|
||||
let config = MsdController::media_config(&state.mounted_media[0]);
|
||||
assert!(config.cdrom);
|
||||
assert!(config.ro);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,10 +393,6 @@ impl ImageManager {
|
||||
|
||||
self.get_by_name(&final_filename)
|
||||
}
|
||||
|
||||
pub fn images_path(&self) -> &PathBuf {
|
||||
&self.images_path
|
||||
}
|
||||
}
|
||||
|
||||
fn stable_image_id_from_filename(name: &str) -> String {
|
||||
|
||||
@@ -8,8 +8,9 @@ pub use controller::MsdController;
|
||||
pub use image::ImageManager;
|
||||
pub use monitor::MsdHealthMonitor;
|
||||
pub use types::{
|
||||
DownloadProgress, DownloadStatus, DriveFile, DriveInfo, DriveInitRequest, ImageDownloadRequest,
|
||||
ImageInfo, MsdConnectRequest, MsdMode, MsdState,
|
||||
DiskMode, DiskModeRequest, DownloadProgress, DownloadStatus, DriveFile, DriveInfo,
|
||||
DriveInitRequest, ImageDownloadRequest, ImageInfo, ImageMountRequest, MountedMedia,
|
||||
MountedMediaKind, MsdState, MsdStateResponse, MULTI_DISK_MSD_LUNS, SINGLE_DISK_MSD_LUNS,
|
||||
};
|
||||
pub use ventoy_drive::{VentoyDrive, MIN_DRIVE_SIZE_MB};
|
||||
|
||||
|
||||
137
src/msd/types.rs
137
src/msd/types.rs
@@ -2,13 +2,12 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use time::OffsetDateTime;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MsdMode {
|
||||
pub enum DiskMode {
|
||||
#[default]
|
||||
None,
|
||||
Image,
|
||||
Drive,
|
||||
Single,
|
||||
Multi,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -50,23 +49,109 @@ impl ImageInfo {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MsdState {
|
||||
pub available: bool,
|
||||
pub mode: MsdMode,
|
||||
pub connected: bool,
|
||||
pub current_image: Option<ImageInfo>,
|
||||
pub disk_mode: DiskMode,
|
||||
pub mounted_media: Vec<MountedMedia>,
|
||||
pub drive_info: Option<DriveInfo>,
|
||||
pub usb_reenumerating: bool,
|
||||
}
|
||||
|
||||
impl Default for MsdState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
available: false,
|
||||
mode: MsdMode::None,
|
||||
connected: false,
|
||||
current_image: None,
|
||||
disk_mode: DiskMode::Single,
|
||||
mounted_media: Vec::new(),
|
||||
drive_info: None,
|
||||
usb_reenumerating: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct MsdStateResponse {
|
||||
pub available: bool,
|
||||
pub disk_mode: DiskMode,
|
||||
pub slot_capacity: u8,
|
||||
pub mounted_count: u8,
|
||||
pub mounted_media: Vec<MountedMedia>,
|
||||
pub drive_info: Option<DriveInfo>,
|
||||
pub usb_reenumerating: bool,
|
||||
}
|
||||
|
||||
impl From<&MsdState> for MsdStateResponse {
|
||||
fn from(state: &MsdState) -> Self {
|
||||
Self {
|
||||
available: state.available,
|
||||
disk_mode: state.disk_mode,
|
||||
slot_capacity: state.disk_mode.capacity(),
|
||||
mounted_count: state.mounted_media.len() as u8,
|
||||
mounted_media: state.mounted_media.clone(),
|
||||
drive_info: state.drive_info.clone(),
|
||||
usb_reenumerating: state.usb_reenumerating,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const SINGLE_DISK_MSD_LUNS: u8 = 1;
|
||||
pub const MULTI_DISK_MSD_LUNS: u8 = 8;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MountedMediaKind {
|
||||
Drive,
|
||||
Image,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MountedMedia {
|
||||
pub id: String,
|
||||
pub kind: MountedMediaKind,
|
||||
pub name: String,
|
||||
pub cdrom: bool,
|
||||
pub read_only: bool,
|
||||
pub size: u64,
|
||||
#[serde(skip)]
|
||||
pub lun: u8,
|
||||
#[serde(skip)]
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl MountedMedia {
|
||||
pub fn image(lun: u8, image: &ImageInfo, cdrom: bool, read_only: bool) -> Self {
|
||||
Self {
|
||||
id: image.id.clone(),
|
||||
lun,
|
||||
kind: MountedMediaKind::Image,
|
||||
name: image.name.clone(),
|
||||
cdrom,
|
||||
read_only: cdrom || read_only,
|
||||
size: image.size,
|
||||
path: image.path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drive(lun: u8, info: &DriveInfo) -> Self {
|
||||
Self {
|
||||
id: "drive".to_string(),
|
||||
lun,
|
||||
kind: MountedMediaKind::Drive,
|
||||
name: "Virtual USB".to_string(),
|
||||
cdrom: false,
|
||||
read_only: false,
|
||||
size: info.size,
|
||||
path: info.path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiskMode {
|
||||
pub fn capacity(self) -> u8 {
|
||||
match self {
|
||||
DiskMode::Single => SINGLE_DISK_MSD_LUNS,
|
||||
DiskMode::Multi => MULTI_DISK_MSD_LUNS,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,13 +189,16 @@ pub struct DriveFile {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MsdConnectRequest {
|
||||
pub mode: MsdMode,
|
||||
pub image_id: Option<String>,
|
||||
pub struct DiskModeRequest {
|
||||
pub disk_mode: DiskMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ImageMountRequest {
|
||||
#[serde(default)]
|
||||
pub cdrom: Option<bool>,
|
||||
pub cdrom: bool,
|
||||
#[serde(default)]
|
||||
pub read_only: Option<bool>,
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
@@ -164,4 +252,19 @@ mod tests {
|
||||
);
|
||||
assert!(info.size_display().contains("GB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_state_serializes_single_disk_mode() {
|
||||
assert_eq!(DiskMode::default(), DiskMode::Single);
|
||||
|
||||
let state = MsdState::default();
|
||||
assert_eq!(state.disk_mode, DiskMode::Single);
|
||||
|
||||
let json = serde_json::to_value(MsdStateResponse::from(&state)).unwrap();
|
||||
assert_eq!(json["disk_mode"], "single");
|
||||
assert_eq!(json["slot_capacity"], 1);
|
||||
assert!(json.get("mode").is_none());
|
||||
assert!(json.get("current_image").is_none());
|
||||
assert!(json.get("slots").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ impl VentoyDrive {
|
||||
info!("Creating {} MB Ventoy drive at {}", size_mb, path.display());
|
||||
|
||||
let info = tokio::task::spawn_blocking(move || {
|
||||
VentoyImage::create(&path, &size_str, DEFAULT_LABEL).map_err(ventoy_to_app_error)?;
|
||||
VentoyImage::create(&path, &size_str, DEFAULT_LABEL).map_err(drive_init_error)?;
|
||||
|
||||
let metadata = std::fs::metadata(&path)
|
||||
.map_err(|e| AppError::Internal(format!("Failed to read drive metadata: {}", e)))?;
|
||||
@@ -354,6 +354,30 @@ fn ventoy_to_app_error(err: VentoyError) -> AppError {
|
||||
}
|
||||
}
|
||||
|
||||
fn drive_init_error(err: VentoyError) -> AppError {
|
||||
let VentoyError::Io(error) = err else {
|
||||
return ventoy_to_app_error(err);
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
match error.raw_os_error() {
|
||||
Some(libc::EFBIG) => AppError::BadRequest(
|
||||
"MSD directory filesystem does not support a virtual drive file of this size".into(),
|
||||
),
|
||||
Some(libc::ENOSPC) => AppError::BadRequest(
|
||||
"MSD directory does not have enough free space for the virtual drive".into(),
|
||||
),
|
||||
Some(libc::EROFS) => AppError::BadRequest("MSD directory filesystem is read-only".into()),
|
||||
Some(libc::EACCES | libc::EPERM) => AppError::BadRequest(
|
||||
"One-KVM does not have permission to write to the MSD directory".into(),
|
||||
),
|
||||
_ => AppError::Io(error),
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
AppError::Io(error)
|
||||
}
|
||||
|
||||
fn ventoy_file_to_drive_file(info: VentoyFileInfo, parent_path: &str) -> DriveFile {
|
||||
let full_path = if parent_path.is_empty() || parent_path == "/" {
|
||||
format!("/{}", info.name)
|
||||
@@ -436,12 +460,26 @@ impl Drop for ChannelWriter {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::AppError;
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
use tempfile::TempDir;
|
||||
|
||||
static RESOURCE_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../ventoy-img-rs/resources");
|
||||
|
||||
#[test]
|
||||
fn classifies_drive_creation_io_errors() {
|
||||
for (errno, expected) in [
|
||||
(libc::EFBIG, "does not support"),
|
||||
(libc::ENOSPC, "enough free space"),
|
||||
(libc::EROFS, "read-only"),
|
||||
(libc::EACCES, "permission"),
|
||||
] {
|
||||
let error = drive_init_error(VentoyError::Io(std::io::Error::from_raw_os_error(errno)));
|
||||
assert!(matches!(error, AppError::BadRequest(message) if message.contains(expected)));
|
||||
}
|
||||
}
|
||||
|
||||
fn init_ventoy_resources() -> bool {
|
||||
static INIT: OnceLock<bool> = OnceLock::new();
|
||||
*INIT.get_or_init(|| {
|
||||
|
||||
945
src/otg/bridge.rs
Normal file
945
src/otg/bridge.rs
Normal file
@@ -0,0 +1,945 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
const BRIDGE_IF: &str = "okvm-br0";
|
||||
const PROFILE_PREFIX: &str = "one-kvm-otg";
|
||||
const JOURNAL_PATH: &str = "/run/one-kvm/otg-network-bridge.json";
|
||||
const JOURNAL_VERSION: u8 = 2;
|
||||
const NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const DHCP_IDENTITY_PROPERTIES: &[&str] = &[
|
||||
"ipv4.dhcp-client-id",
|
||||
"ipv4.dhcp-iaid",
|
||||
"ipv4.dhcp-hostname",
|
||||
"ipv4.dhcp-fqdn",
|
||||
"ipv4.dhcp-send-hostname",
|
||||
"ipv4.dhcp-hostname-flags",
|
||||
];
|
||||
const STATIC_IPV4_PROPERTIES: &[&str] = &[
|
||||
"ipv4.dns",
|
||||
"ipv4.dns-search",
|
||||
"ipv4.dns-options",
|
||||
"ipv4.dns-priority",
|
||||
"ipv4.routes",
|
||||
"ipv4.route-table",
|
||||
"ipv4.routing-rules",
|
||||
"ipv4.never-default",
|
||||
"ipv4.may-fail",
|
||||
"ipv4.ignore-auto-routes",
|
||||
"ipv4.ignore-auto-dns",
|
||||
];
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct NetworkInterfaceInfo {
|
||||
pub name: String,
|
||||
pub interface_type: String,
|
||||
pub state: String,
|
||||
pub connection: String,
|
||||
pub addresses: Vec<String>,
|
||||
pub has_default_route: bool,
|
||||
pub bridge_supported: bool,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct NetworkManagerDevice {
|
||||
name: String,
|
||||
interface_type: String,
|
||||
state: String,
|
||||
connection: String,
|
||||
addresses: Vec<String>,
|
||||
has_default_route: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct BridgeJournal {
|
||||
version: u8,
|
||||
uplink: String,
|
||||
existing_bridge: bool,
|
||||
original_connection_uuid: Option<String>,
|
||||
bridge_profile_uuid: Option<String>,
|
||||
uplink_profile_uuid: Option<String>,
|
||||
usb_profile_uuid: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TransactionProfiles {
|
||||
bridge_name: String,
|
||||
bridge_uuid: String,
|
||||
uplink_name: String,
|
||||
uplink_uuid: String,
|
||||
usb_name: String,
|
||||
usb_uuid: String,
|
||||
}
|
||||
|
||||
impl TransactionProfiles {
|
||||
fn new() -> Self {
|
||||
let transaction = Uuid::new_v4().simple().to_string();
|
||||
let suffix = &transaction[..12];
|
||||
Self {
|
||||
bridge_name: format!("{PROFILE_PREFIX}-bridge-{suffix}"),
|
||||
bridge_uuid: Uuid::new_v4().to_string(),
|
||||
uplink_name: format!("{PROFILE_PREFIX}-uplink-{suffix}"),
|
||||
uplink_uuid: Uuid::new_v4().to_string(),
|
||||
usb_name: format!("{PROFILE_PREFIX}-usb-{suffix}"),
|
||||
usb_uuid: Uuid::new_v4().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkBridgeRuntime {
|
||||
journal: BridgeJournal,
|
||||
}
|
||||
|
||||
impl NetworkBridgeRuntime {
|
||||
pub fn activate(requested: &str, usb_interface: &str) -> Result<Self> {
|
||||
ensure_command("nmcli")?;
|
||||
ensure_command("ip")?;
|
||||
|
||||
let interfaces = list_network_interfaces()?;
|
||||
let selected = select_bridge_candidate(&interfaces, requested)?;
|
||||
|
||||
prepare_device_for_network_manager(usb_interface, "ethernet")?;
|
||||
|
||||
Self::activate_physical_uplink(&selected.name, &selected.connection, usb_interface)
|
||||
}
|
||||
|
||||
fn activate_physical_uplink(
|
||||
uplink: &str,
|
||||
original_connection: &str,
|
||||
usb_interface: &str,
|
||||
) -> Result<Self> {
|
||||
if original_connection.is_empty() || original_connection == "--" {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Ethernet interface {uplink} has no active NetworkManager connection"
|
||||
)));
|
||||
}
|
||||
|
||||
reset_bridge_interface()?;
|
||||
let original_connection_uuid = active_connection_uuid(uplink)?;
|
||||
let ipv4_method = connection_value(&original_connection_uuid, "ipv4.method")?;
|
||||
if !matches!(ipv4_method.as_str(), "auto" | "manual") {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Connection {original_connection} uses unsupported ipv4.method={ipv4_method}"
|
||||
)));
|
||||
}
|
||||
let ipv6_method = connection_value(&original_connection_uuid, "ipv6.method")?;
|
||||
if !matches!(ipv6_method.as_str(), "auto" | "disabled" | "ignore") {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Connection {original_connection} uses unsupported ipv6.method={ipv6_method}"
|
||||
)));
|
||||
}
|
||||
let ipv4_metric = connection_value(&original_connection_uuid, "ipv4.route-metric")?;
|
||||
let ipv6_metric = connection_value(&original_connection_uuid, "ipv6.route-metric")?;
|
||||
let original_had_default_route = default_route(uplink).is_some();
|
||||
|
||||
let mac_path = Path::new("/sys/class/net").join(uplink).join("address");
|
||||
let uplink_mac = fs::read_to_string(&mac_path)
|
||||
.map_err(|e| {
|
||||
AppError::Internal(format!("Failed to read {}: {}", mac_path.display(), e))
|
||||
})?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let profiles = TransactionProfiles::new();
|
||||
let journal = BridgeJournal {
|
||||
version: JOURNAL_VERSION,
|
||||
uplink: uplink.to_string(),
|
||||
existing_bridge: false,
|
||||
original_connection_uuid: Some(original_connection_uuid.clone()),
|
||||
bridge_profile_uuid: Some(profiles.bridge_uuid.clone()),
|
||||
uplink_profile_uuid: Some(profiles.uplink_uuid.clone()),
|
||||
usb_profile_uuid: profiles.usb_uuid.clone(),
|
||||
};
|
||||
write_journal(&journal)?;
|
||||
|
||||
let prepare_result: Result<()> = (|| {
|
||||
create_bridge_interface(&uplink_mac)?;
|
||||
run_nmcli(&[
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"bridge",
|
||||
"ifname",
|
||||
BRIDGE_IF,
|
||||
"con-name",
|
||||
&profiles.bridge_name,
|
||||
"connection.uuid",
|
||||
&profiles.bridge_uuid,
|
||||
])?;
|
||||
run_nmcli(&[
|
||||
"connection",
|
||||
"modify",
|
||||
&profiles.bridge_uuid,
|
||||
"connection.interface-name",
|
||||
BRIDGE_IF,
|
||||
"bridge.mac-address",
|
||||
&uplink_mac,
|
||||
"bridge.stp",
|
||||
"no",
|
||||
"ipv6.method",
|
||||
&ipv6_method,
|
||||
"connection.autoconnect",
|
||||
"no",
|
||||
])?;
|
||||
configure_ipv4_profile(
|
||||
&original_connection_uuid,
|
||||
&profiles.bridge_uuid,
|
||||
&ipv4_method,
|
||||
)?;
|
||||
for (property, value) in [
|
||||
("ipv4.route-metric", ipv4_metric.as_str()),
|
||||
("ipv6.route-metric", ipv6_metric.as_str()),
|
||||
] {
|
||||
if !value.is_empty() && value != "-1" {
|
||||
run_nmcli(&[
|
||||
"connection",
|
||||
"modify",
|
||||
&profiles.bridge_uuid,
|
||||
property,
|
||||
value,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
run_nmcli(&[
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"ethernet",
|
||||
"ifname",
|
||||
uplink,
|
||||
"con-name",
|
||||
&profiles.uplink_name,
|
||||
"connection.uuid",
|
||||
&profiles.uplink_uuid,
|
||||
"master",
|
||||
BRIDGE_IF,
|
||||
"slave-type",
|
||||
"bridge",
|
||||
"connection.autoconnect",
|
||||
"no",
|
||||
])?;
|
||||
run_nmcli(&[
|
||||
"connection",
|
||||
"add",
|
||||
"type",
|
||||
"ethernet",
|
||||
"ifname",
|
||||
usb_interface,
|
||||
"con-name",
|
||||
&profiles.usb_name,
|
||||
"connection.uuid",
|
||||
&profiles.usb_uuid,
|
||||
"master",
|
||||
BRIDGE_IF,
|
||||
"slave-type",
|
||||
"bridge",
|
||||
"connection.autoconnect",
|
||||
"no",
|
||||
])?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = prepare_result {
|
||||
return Err(restore_or_combine(&journal, error));
|
||||
}
|
||||
|
||||
let result = (|| {
|
||||
run_nmcli(&["connection", "down", "uuid", &original_connection_uuid])?;
|
||||
activate_connection(
|
||||
"bridge",
|
||||
&profiles.bridge_name,
|
||||
&profiles.bridge_uuid,
|
||||
Some(BRIDGE_IF),
|
||||
)?;
|
||||
activate_connection(
|
||||
"uplink",
|
||||
&profiles.uplink_name,
|
||||
&profiles.uplink_uuid,
|
||||
Some(uplink),
|
||||
)?;
|
||||
activate_connection(
|
||||
"USB",
|
||||
&profiles.usb_name,
|
||||
&profiles.usb_uuid,
|
||||
Some(usb_interface),
|
||||
)?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(35);
|
||||
while Instant::now() < deadline {
|
||||
if first_ipv4_address(BRIDGE_IF).is_some()
|
||||
&& (!original_had_default_route || default_route(BRIDGE_IF).is_some())
|
||||
{
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
let address = first_ipv4_address(BRIDGE_IF).ok_or_else(|| {
|
||||
AppError::Internal(
|
||||
"OTG bridge did not obtain an IPv4 address from upstream DHCP".to_string(),
|
||||
)
|
||||
})?;
|
||||
let route = default_route(BRIDGE_IF);
|
||||
if original_had_default_route && route.is_none() {
|
||||
return Err(AppError::Internal(
|
||||
"OTG bridge did not obtain the original default route".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(route) = route.as_deref() {
|
||||
if let Some(gateway) = gateway_from_route(route) {
|
||||
if let Err(error) = run_command("ping", &["-c", "1", "-W", "2", gateway]) {
|
||||
tracing::warn!(
|
||||
"OTG bridge gateway ICMP diagnostic failed for {}: {}",
|
||||
gateway,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(address)
|
||||
})();
|
||||
|
||||
match result {
|
||||
Ok(_address) => Ok(Self { journal }),
|
||||
Err(error) => Err(restore_or_combine(&journal, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deactivate(&self) -> Result<()> {
|
||||
restore_from_journal(&self.journal)
|
||||
}
|
||||
|
||||
pub fn recover_stale_transaction() -> Result<()> {
|
||||
let value = match fs::read_to_string(JOURNAL_PATH) {
|
||||
Ok(value) => value,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Failed to read OTG network recovery journal: {error}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
match serde_json::from_str::<BridgeJournal>(&value) {
|
||||
Ok(journal) if journal.version == JOURNAL_VERSION => restore_from_journal(&journal),
|
||||
Ok(journal) => Err(AppError::Config(format!(
|
||||
"Unsupported OTG network recovery journal version {}",
|
||||
journal.version
|
||||
))),
|
||||
Err(error) => Err(AppError::Config(format!(
|
||||
"Invalid OTG network recovery journal: {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_network_interfaces() -> Result<Vec<NetworkInterfaceInfo>> {
|
||||
let devices = enumerate_network_manager_devices()?;
|
||||
Ok(bridge_candidates(devices, is_physical_network_interface))
|
||||
}
|
||||
|
||||
fn enumerate_network_manager_devices() -> Result<Vec<NetworkManagerDevice>> {
|
||||
ensure_command("nmcli")?;
|
||||
let output = run_command(
|
||||
"nmcli",
|
||||
&[
|
||||
"-t",
|
||||
"--escape",
|
||||
"no",
|
||||
"-f",
|
||||
"DEVICE,TYPE,STATE,CONNECTION",
|
||||
"device",
|
||||
"status",
|
||||
],
|
||||
)?;
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
let mut devices = parse_network_manager_devices(&text);
|
||||
for device in &mut devices {
|
||||
device.addresses = ipv4_addresses(&device.name);
|
||||
device.has_default_route = default_route(&device.name).is_some();
|
||||
}
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
fn parse_network_manager_devices(text: &str) -> Vec<NetworkManagerDevice> {
|
||||
let mut devices = Vec::new();
|
||||
for line in text.lines() {
|
||||
let fields = line.splitn(4, ':').collect::<Vec<_>>();
|
||||
if fields.len() != 4 || fields[0].is_empty() {
|
||||
continue;
|
||||
}
|
||||
devices.push(NetworkManagerDevice {
|
||||
name: fields[0].to_string(),
|
||||
interface_type: fields[1].to_string(),
|
||||
state: fields[2].to_string(),
|
||||
connection: fields[3].to_string(),
|
||||
addresses: Vec::new(),
|
||||
has_default_route: false,
|
||||
});
|
||||
}
|
||||
devices
|
||||
}
|
||||
|
||||
fn bridge_candidates(
|
||||
devices: Vec<NetworkManagerDevice>,
|
||||
is_physical: impl Fn(&str) -> bool,
|
||||
) -> Vec<NetworkInterfaceInfo> {
|
||||
devices
|
||||
.into_iter()
|
||||
.filter(|device| {
|
||||
device.interface_type == "ethernet"
|
||||
&& device.state == "connected"
|
||||
&& !device.connection.is_empty()
|
||||
&& device.connection != "--"
|
||||
&& is_physical(&device.name)
|
||||
})
|
||||
.map(|device| NetworkInterfaceInfo {
|
||||
name: device.name,
|
||||
interface_type: device.interface_type,
|
||||
state: device.state,
|
||||
connection: device.connection,
|
||||
addresses: device.addresses,
|
||||
has_default_route: device.has_default_route,
|
||||
bridge_supported: true,
|
||||
reason: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_physical_network_interface(name: &str) -> bool {
|
||||
Path::new("/sys/class/net")
|
||||
.join(name)
|
||||
.join("device")
|
||||
.exists()
|
||||
}
|
||||
|
||||
fn select_bridge_candidate<'a>(
|
||||
interfaces: &'a [NetworkInterfaceInfo],
|
||||
requested: &str,
|
||||
) -> Result<&'a NetworkInterfaceInfo> {
|
||||
if requested.trim().is_empty() {
|
||||
return interfaces
|
||||
.iter()
|
||||
.max_by_key(|item| item.has_default_route)
|
||||
.ok_or_else(|| {
|
||||
AppError::Config(
|
||||
"No connected physical NetworkManager Ethernet interface is available for OTG bridging"
|
||||
.to_string(),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
interfaces
|
||||
.iter()
|
||||
.find(|item| item.name == requested)
|
||||
.ok_or_else(|| {
|
||||
AppError::Config(format!(
|
||||
"Network interface {requested} is not a connected physical NetworkManager Ethernet interface"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn restore_from_journal(journal: &BridgeJournal) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
for (kind, profile_uuid) in [
|
||||
("USB", Some(journal.usb_profile_uuid.as_str())),
|
||||
("uplink", journal.uplink_profile_uuid.as_deref()),
|
||||
("bridge", journal.bridge_profile_uuid.as_deref()),
|
||||
] {
|
||||
let Some(profile_uuid) = profile_uuid else {
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = delete_connection(profile_uuid) {
|
||||
errors.push(format!(
|
||||
"failed to remove owned {kind} profile {profile_uuid}: {error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !journal.existing_bridge {
|
||||
if let Err(error) = delete_bridge_interface() {
|
||||
errors.push(format!(
|
||||
"failed to remove owned bridge interface {BRIDGE_IF}: {error}"
|
||||
));
|
||||
}
|
||||
if let Some(ref original_uuid) = journal.original_connection_uuid {
|
||||
if let Err(error) = run_nmcli(&[
|
||||
"connection",
|
||||
"up",
|
||||
"uuid",
|
||||
original_uuid,
|
||||
"ifname",
|
||||
&journal.uplink,
|
||||
]) {
|
||||
errors.push(format!(
|
||||
"failed to restore original profile {original_uuid}: {error}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(AppError::Config(errors.join("; ")));
|
||||
}
|
||||
|
||||
match fs::remove_file(JOURNAL_PATH) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(AppError::Internal(format!(
|
||||
"Failed to remove OTG network recovery journal: {error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_or_combine(journal: &BridgeJournal, primary: AppError) -> AppError {
|
||||
match restore_from_journal(journal) {
|
||||
Ok(()) => primary,
|
||||
Err(rollback) => AppError::Config(format!("{primary}; bridge rollback failed: {rollback}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_bridge_interface() -> Result<()> {
|
||||
for profile_uuid in connection_uuids()? {
|
||||
if connection_value(&profile_uuid, "connection.interface-name")? == BRIDGE_IF {
|
||||
tracing::warn!(
|
||||
"Removing NetworkManager profile {} bound to reserved interface {}",
|
||||
profile_uuid,
|
||||
BRIDGE_IF
|
||||
);
|
||||
run_nmcli(&["connection", "delete", "uuid", &profile_uuid])?;
|
||||
}
|
||||
}
|
||||
delete_bridge_interface()
|
||||
}
|
||||
|
||||
fn create_bridge_interface(mac_address: &str) -> Result<()> {
|
||||
run_command("ip", &["link", "add", "name", BRIDGE_IF, "type", "bridge"])?;
|
||||
run_command(
|
||||
"ip",
|
||||
&["link", "set", "dev", BRIDGE_IF, "address", mac_address],
|
||||
)?;
|
||||
prepare_device_for_network_manager(BRIDGE_IF, "bridge")
|
||||
}
|
||||
|
||||
fn delete_bridge_interface() -> Result<()> {
|
||||
if !Path::new("/sys/class/net").join(BRIDGE_IF).exists() {
|
||||
return Ok(());
|
||||
}
|
||||
run_command("ip", &["link", "delete", BRIDGE_IF, "type", "bridge"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_device_for_network_manager(interface: &str, expected_type: &str) -> Result<()> {
|
||||
run_command("ip", &["link", "set", interface, "up"])?;
|
||||
|
||||
let deadline = Instant::now() + NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT;
|
||||
let mut requested_managed = false;
|
||||
while Instant::now() < deadline {
|
||||
match enumerate_network_manager_devices() {
|
||||
Ok(devices) => {
|
||||
if let Some(device) = devices.iter().find(|device| device.name == interface) {
|
||||
if device.interface_type != expected_type {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"One-KVM interface {interface} has NetworkManager type {}, expected {expected_type}",
|
||||
device.interface_type,
|
||||
)));
|
||||
}
|
||||
if device.state != "unmanaged" {
|
||||
return Ok(());
|
||||
}
|
||||
if !requested_managed {
|
||||
tracing::info!(
|
||||
"Marking One-KVM interface {} as managed by NetworkManager",
|
||||
interface
|
||||
);
|
||||
run_nmcli(&["device", "set", interface, "managed", "yes"])?;
|
||||
requested_managed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(
|
||||
"Waiting for NetworkManager to discover One-KVM interface {}: {}",
|
||||
interface,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
Err(AppError::Internal(format!(
|
||||
"NetworkManager did not discover One-KVM {expected_type} interface {interface} within {} seconds",
|
||||
NETWORK_MANAGER_DEVICE_WAIT_TIMEOUT.as_secs()
|
||||
)))
|
||||
}
|
||||
|
||||
fn activate_connection(kind: &str, name: &str, uuid: &str, interface: Option<&str>) -> Result<()> {
|
||||
let result = match interface {
|
||||
Some(interface) => run_nmcli(&["connection", "up", name, "ifname", interface]),
|
||||
None => run_nmcli(&["connection", "up", name]),
|
||||
};
|
||||
result.map_err(|error| {
|
||||
let target = interface
|
||||
.map(|value| format!(" on {value}"))
|
||||
.unwrap_or_default();
|
||||
AppError::Internal(format!(
|
||||
"Failed to activate One-KVM {kind} profile {name} ({uuid}){target}: {error}"
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_connection_uuid(interface: &str) -> Result<String> {
|
||||
let output = run_nmcli(&[
|
||||
"--escape",
|
||||
"no",
|
||||
"-g",
|
||||
"GENERAL.CON-UUID",
|
||||
"device",
|
||||
"show",
|
||||
interface,
|
||||
])?;
|
||||
let uuid = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if uuid.is_empty() || uuid == "--" {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Ethernet interface {interface} has no active NetworkManager profile UUID"
|
||||
)));
|
||||
}
|
||||
Ok(uuid)
|
||||
}
|
||||
|
||||
fn connection_uuids() -> Result<Vec<String>> {
|
||||
let output = run_nmcli(&["-t", "--escape", "no", "-f", "UUID", "connection", "show"])?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn delete_connection(profile_uuid: &str) -> Result<()> {
|
||||
if !connection_uuids()?.iter().any(|uuid| uuid == profile_uuid) {
|
||||
return Ok(());
|
||||
}
|
||||
run_nmcli(&["connection", "delete", "uuid", profile_uuid])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_connection_properties(source: &str, target: &str, properties: &[&str]) -> Result<()> {
|
||||
for property in properties {
|
||||
let Ok(value) = connection_value(source, property) else {
|
||||
tracing::debug!(
|
||||
"Skipping unsupported NetworkManager property {} while configuring OTG bridge",
|
||||
property
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if value.is_empty() || value == "--" {
|
||||
continue;
|
||||
}
|
||||
run_nmcli(&["connection", "modify", target, property, &value])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_ipv4_profile(source: &str, target: &str, method: &str) -> Result<()> {
|
||||
match method {
|
||||
"auto" => {
|
||||
run_nmcli(&["connection", "modify", target, "ipv4.method", "auto"])?;
|
||||
copy_connection_properties(source, target, DHCP_IDENTITY_PROPERTIES)
|
||||
}
|
||||
"manual" => {
|
||||
let addresses = connection_value(source, "ipv4.addresses")?;
|
||||
if addresses.is_empty() || addresses == "--" {
|
||||
return Err(AppError::BadRequest(
|
||||
"Static IPv4 profile has no ipv4.addresses value".to_string(),
|
||||
));
|
||||
}
|
||||
let gateway = connection_value(source, "ipv4.gateway")?;
|
||||
if gateway.is_empty() || gateway == "--" {
|
||||
run_nmcli(&[
|
||||
"connection",
|
||||
"modify",
|
||||
target,
|
||||
"ipv4.method",
|
||||
"manual",
|
||||
"ipv4.addresses",
|
||||
&addresses,
|
||||
])?;
|
||||
} else {
|
||||
run_nmcli(&[
|
||||
"connection",
|
||||
"modify",
|
||||
target,
|
||||
"ipv4.method",
|
||||
"manual",
|
||||
"ipv4.addresses",
|
||||
&addresses,
|
||||
"ipv4.gateway",
|
||||
&gateway,
|
||||
])?;
|
||||
}
|
||||
copy_connection_properties(source, target, STATIC_IPV4_PROPERTIES)
|
||||
}
|
||||
_ => Err(AppError::BadRequest(format!(
|
||||
"Unsupported IPv4 method {method}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_journal(journal: &BridgeJournal) -> Result<()> {
|
||||
let path = Path::new(JOURNAL_PATH);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| {
|
||||
AppError::Internal(format!("Failed to create {}: {}", parent.display(), e))
|
||||
})?;
|
||||
}
|
||||
let value = serde_json::to_vec(journal)
|
||||
.map_err(|e| AppError::Internal(format!("Failed to serialize bridge journal: {e}")))?;
|
||||
let temporary = path.with_extension("json.tmp");
|
||||
fs::write(&temporary, value)
|
||||
.map_err(|e| AppError::Internal(format!("Failed to write bridge recovery journal: {e}")))?;
|
||||
fs::rename(&temporary, path)
|
||||
.map_err(|e| AppError::Internal(format!("Failed to commit bridge recovery journal: {e}")))
|
||||
}
|
||||
|
||||
fn connection_value(connection: &str, property: &str) -> Result<String> {
|
||||
let output = run_nmcli(&[
|
||||
"--escape",
|
||||
"no",
|
||||
"-g",
|
||||
property,
|
||||
"connection",
|
||||
"show",
|
||||
connection,
|
||||
])?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn first_ipv4_address(interface: &str) -> Option<String> {
|
||||
ipv4_addresses(interface).into_iter().next()
|
||||
}
|
||||
|
||||
fn ipv4_addresses(interface: &str) -> Vec<String> {
|
||||
let Ok(output) = Command::new("ip")
|
||||
.args(["-4", "-o", "address", "show", "dev", interface])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let fields = line.split_whitespace().collect::<Vec<_>>();
|
||||
fields
|
||||
.iter()
|
||||
.position(|field| *field == "inet")
|
||||
.and_then(|index| fields.get(index + 1))
|
||||
.map(|value| (*value).to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_route(interface: &str) -> Option<String> {
|
||||
let output = Command::new("ip")
|
||||
.args(["-4", "route", "show", "default", "dev", interface])
|
||||
.output()
|
||||
.ok()?;
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn gateway_from_route(route: &str) -> Option<&str> {
|
||||
let fields = route.split_whitespace().collect::<Vec<_>>();
|
||||
fields
|
||||
.windows(2)
|
||||
.find_map(|part| (part[0] == "via").then_some(part[1]))
|
||||
}
|
||||
|
||||
fn ensure_command(name: &str) -> Result<()> {
|
||||
let status = Command::new(name).arg("--version").output();
|
||||
if status.is_err() {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"OTG bridge requires the {name} command"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_nmcli(args: &[&str]) -> Result<Output> {
|
||||
run_command("nmcli", args)
|
||||
}
|
||||
|
||||
fn run_command(command: &str, args: &[&str]) -> Result<Output> {
|
||||
let output = Command::new(command)
|
||||
.env("LC_ALL", "C")
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|e| {
|
||||
AppError::Internal(format!(
|
||||
"Failed to execute {command} {}: {e}",
|
||||
args.join(" ")
|
||||
))
|
||||
})?;
|
||||
if output.status.success() {
|
||||
return Ok(output);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
Err(AppError::Internal(format!(
|
||||
"{command} {} failed: {}",
|
||||
args.join(" "),
|
||||
if stderr.is_empty() { stdout } else { stderr }
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device(
|
||||
name: &str,
|
||||
interface_type: &str,
|
||||
state: &str,
|
||||
connection: &str,
|
||||
has_default_route: bool,
|
||||
) -> NetworkManagerDevice {
|
||||
NetworkManagerDevice {
|
||||
name: name.to_string(),
|
||||
interface_type: interface_type.to_string(),
|
||||
state: state.to_string(),
|
||||
connection: connection.to_string(),
|
||||
addresses: Vec::new(),
|
||||
has_default_route,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_journal_round_trip() {
|
||||
let journal = BridgeJournal {
|
||||
version: JOURNAL_VERSION,
|
||||
uplink: "eth0".to_string(),
|
||||
existing_bridge: false,
|
||||
original_connection_uuid: Some("original-uuid".to_string()),
|
||||
bridge_profile_uuid: Some("bridge-uuid".to_string()),
|
||||
uplink_profile_uuid: Some("uplink-uuid".to_string()),
|
||||
usb_profile_uuid: "usb-uuid".to_string(),
|
||||
};
|
||||
let value = serde_json::to_string(&journal).unwrap();
|
||||
let decoded: BridgeJournal = serde_json::from_str(&value).unwrap();
|
||||
assert_eq!(decoded.uplink, "eth0");
|
||||
assert_eq!(decoded.bridge_profile_uuid.as_deref(), Some("bridge-uuid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_profiles_use_unique_names_and_uuids() {
|
||||
let first = TransactionProfiles::new();
|
||||
let second = TransactionProfiles::new();
|
||||
assert_ne!(first.bridge_name, second.bridge_name);
|
||||
assert_ne!(first.bridge_uuid, second.bridge_uuid);
|
||||
assert!(first.usb_name.starts_with(PROFILE_PREFIX));
|
||||
assert!(Uuid::parse_str(&first.usb_uuid).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_is_optional_diagnostic_data() {
|
||||
assert_eq!(
|
||||
gateway_from_route("default via 192.0.2.1 dev okvm-br0"),
|
||||
Some("192.0.2.1")
|
||||
);
|
||||
assert_eq!(gateway_from_route("default dev okvm-br0"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dhcp_identity_properties_include_client_id_and_hostname() {
|
||||
assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-client-id"));
|
||||
assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-iaid"));
|
||||
assert!(DHCP_IDENTITY_PROPERTIES.contains(&"ipv4.dhcp-hostname"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_ipv4_properties_cover_dns_routes_and_policy() {
|
||||
assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.dns"));
|
||||
assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.routes"));
|
||||
assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.route-table"));
|
||||
assert!(STATIC_IPV4_PROPERTIES.contains(&"ipv4.never-default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_network_manager_enumeration_keeps_runtime_devices() {
|
||||
let devices = parse_network_manager_devices(
|
||||
"eth0:ethernet:connected:Wired connection 1\n\
|
||||
usb0:ethernet:disconnected:--\n\
|
||||
okvm-br0:bridge:unmanaged:--\n",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
devices
|
||||
.iter()
|
||||
.map(|device| device.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["eth0", "usb0", "okvm-br0"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_candidates_only_keep_connected_physical_ethernet() {
|
||||
let devices = vec![
|
||||
device("eth0", "ethernet", "connected", "one-kvm-otg-uplink", false),
|
||||
device("wlx76012dc07213", "wifi", "connected", "Wi-Fi", true),
|
||||
device("okvm-br0", "bridge", "connected", "Bridge", true),
|
||||
device("usb0", "ethernet", "connected", "USB", false),
|
||||
device("lo", "loopback", "connected", "lo", false),
|
||||
device("bond0", "bond", "connected", "Bond", false),
|
||||
device("tun0", "tun", "connected", "Tunnel", false),
|
||||
device("veth0", "ethernet", "connected", "Virtual", false),
|
||||
device("eth1", "ethernet", "disconnected", "--", false),
|
||||
device("eth2", "ethernet", "connected", "--", false),
|
||||
device("eth3", "ethernet", "connected", "", false),
|
||||
];
|
||||
|
||||
let candidates = bridge_candidates(devices, |name| {
|
||||
matches!(name, "eth0" | "eth1" | "eth2" | "eth3")
|
||||
});
|
||||
|
||||
assert_eq!(candidates.len(), 1);
|
||||
assert_eq!(candidates[0].name, "eth0");
|
||||
assert!(candidates[0].bridge_supported);
|
||||
assert_eq!(candidates[0].reason, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_bridge_selection_prefers_default_route() {
|
||||
let candidates = bridge_candidates(
|
||||
vec![
|
||||
device("eth0", "ethernet", "connected", "Wired 1", false),
|
||||
device("eth1", "ethernet", "connected", "Wired 2", true),
|
||||
],
|
||||
|_| true,
|
||||
);
|
||||
|
||||
let selected = select_bridge_candidate(&candidates, "").unwrap();
|
||||
|
||||
assert_eq!(selected.name, "eth1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_selection_reports_when_no_candidate_exists() {
|
||||
let error = select_bridge_candidate(&[], "").unwrap_err();
|
||||
|
||||
assert!(matches!(error, AppError::Config(_)));
|
||||
assert!(error.to_string().contains("connected physical"));
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,11 @@ pub const DEFAULT_USB_BCD_DEVICE: u16 = 0x0100;
|
||||
pub const USB_BCD_USB: u16 = 0x0200;
|
||||
|
||||
pub fn is_configfs_available() -> bool {
|
||||
Path::new(CONFIGFS_PATH).exists()
|
||||
configfs_path().exists()
|
||||
}
|
||||
|
||||
pub fn configfs_path() -> &'static Path {
|
||||
Path::new(CONFIGFS_PATH)
|
||||
}
|
||||
|
||||
/// Loads `libcomposite` if needed; does not mount configfs.
|
||||
@@ -71,11 +75,6 @@ fn collect_dir_names(path: &Path, devices: &mut Vec<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_low_endpoint_udc(name: &str) -> bool {
|
||||
let name = name.to_ascii_lowercase();
|
||||
name.contains("musb") || name.contains("musb-hdrc")
|
||||
}
|
||||
|
||||
/// Sysfs/configfs: one write syscall with final buffer (incl. newline when needed).
|
||||
pub fn write_file(path: &Path, content: &str) -> Result<()> {
|
||||
let mut file = OpenOptions::new()
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
pub const DEFAULT_MAX_ENDPOINTS: u8 = 16;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EndpointAllocator {
|
||||
max_endpoints: u8,
|
||||
used_endpoints: u8,
|
||||
}
|
||||
|
||||
impl EndpointAllocator {
|
||||
pub fn new(max_endpoints: u8) -> Self {
|
||||
Self {
|
||||
max_endpoints,
|
||||
used_endpoints: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allocate(&mut self, count: u8) -> Result<()> {
|
||||
if self.used_endpoints + count > self.max_endpoints {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Not enough endpoints: need {}, available {}",
|
||||
count,
|
||||
self.available()
|
||||
)));
|
||||
}
|
||||
self.used_endpoints += count;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn release(&mut self, count: u8) {
|
||||
self.used_endpoints = self.used_endpoints.saturating_sub(count);
|
||||
}
|
||||
|
||||
pub fn available(&self) -> u8 {
|
||||
self.max_endpoints.saturating_sub(self.used_endpoints)
|
||||
}
|
||||
|
||||
pub fn used(&self) -> u8 {
|
||||
self.used_endpoints
|
||||
}
|
||||
|
||||
pub fn max(&self) -> u8 {
|
||||
self.max_endpoints
|
||||
}
|
||||
|
||||
pub fn can_allocate(&self, count: u8) -> bool {
|
||||
self.available() >= count
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EndpointAllocator {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_MAX_ENDPOINTS)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_allocator() {
|
||||
let mut alloc = EndpointAllocator::new(8);
|
||||
assert_eq!(alloc.available(), 8);
|
||||
|
||||
alloc.allocate(2).unwrap();
|
||||
assert_eq!(alloc.available(), 6);
|
||||
assert_eq!(alloc.used(), 2);
|
||||
|
||||
alloc.allocate(4).unwrap();
|
||||
assert_eq!(alloc.available(), 2);
|
||||
|
||||
assert!(alloc.allocate(3).is_err());
|
||||
|
||||
alloc.release(2);
|
||||
assert_eq!(alloc.available(), 4);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ use crate::error::Result;
|
||||
pub trait GadgetFunction: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
|
||||
fn endpoints_required(&self) -> u8;
|
||||
|
||||
fn create(&self, gadget_path: &Path) -> Result<()>;
|
||||
|
||||
fn link(&self, config_path: &Path, gadget_path: &Path) -> Result<()>;
|
||||
|
||||
@@ -19,15 +19,6 @@ pub enum HidFunctionType {
|
||||
}
|
||||
|
||||
impl HidFunctionType {
|
||||
pub fn endpoints(&self) -> u8 {
|
||||
match self {
|
||||
HidFunctionType::Keyboard => 1,
|
||||
HidFunctionType::MouseRelative => 1,
|
||||
HidFunctionType::MouseAbsolute => 1,
|
||||
HidFunctionType::ConsumerControl => 1,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn protocol(&self) -> u8 {
|
||||
match self {
|
||||
HidFunctionType::Keyboard => 1,
|
||||
@@ -130,10 +121,6 @@ impl GadgetFunction for HidFunction {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn endpoints_required(&self) -> u8 {
|
||||
self.func_type.endpoints()
|
||||
}
|
||||
|
||||
fn create(&self, gadget_path: &Path) -> Result<()> {
|
||||
let func_path = self.function_path(gadget_path);
|
||||
create_dir(&func_path)?;
|
||||
@@ -197,10 +184,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_hid_function_types() {
|
||||
assert_eq!(HidFunctionType::Keyboard.endpoints(), 1);
|
||||
assert_eq!(HidFunctionType::MouseRelative.endpoints(), 1);
|
||||
assert_eq!(HidFunctionType::MouseAbsolute.endpoints(), 1);
|
||||
|
||||
assert_eq!(HidFunctionType::Keyboard.report_length(false), 8);
|
||||
assert_eq!(HidFunctionType::Keyboard.report_length(true), 8);
|
||||
assert_eq!(HidFunctionType::MouseRelative.report_length(false), 4);
|
||||
|
||||
@@ -3,14 +3,15 @@ use std::path::PathBuf;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
use super::configfs::{
|
||||
create_dir, create_symlink, find_udc, is_configfs_available, remove_dir, remove_file,
|
||||
write_file, CONFIGFS_PATH, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID,
|
||||
configfs_path, create_dir, create_symlink, find_udc, is_configfs_available, remove_dir,
|
||||
remove_file, write_file, DEFAULT_GADGET_NAME, DEFAULT_USB_BCD_DEVICE, DEFAULT_USB_PRODUCT_ID,
|
||||
DEFAULT_USB_VENDOR_ID, USB_BCD_USB,
|
||||
};
|
||||
use super::endpoint::{EndpointAllocator, DEFAULT_MAX_ENDPOINTS};
|
||||
use super::function::GadgetFunction;
|
||||
use super::hid::HidFunction;
|
||||
use super::msd::MsdFunction;
|
||||
use super::network::NetworkFunction;
|
||||
use crate::config::OtgNetworkConfig;
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
const REBIND_DELAY_MS: u64 = 300;
|
||||
@@ -43,9 +44,9 @@ pub struct OtgGadgetManager {
|
||||
gadget_path: PathBuf,
|
||||
config_path: PathBuf,
|
||||
descriptor: GadgetDescriptor,
|
||||
endpoint_allocator: EndpointAllocator,
|
||||
hid_instance: u8,
|
||||
msd_instance: u8,
|
||||
network_instance: u8,
|
||||
functions: Vec<Box<dyn GadgetFunction>>,
|
||||
bound_udc: Option<String>,
|
||||
created_by_us: bool,
|
||||
@@ -53,19 +54,15 @@ pub struct OtgGadgetManager {
|
||||
|
||||
impl OtgGadgetManager {
|
||||
pub fn new() -> Self {
|
||||
Self::with_config(DEFAULT_GADGET_NAME, DEFAULT_MAX_ENDPOINTS)
|
||||
Self::with_config(DEFAULT_GADGET_NAME)
|
||||
}
|
||||
|
||||
pub fn with_config(gadget_name: &str, max_endpoints: u8) -> Self {
|
||||
Self::with_descriptor(gadget_name, max_endpoints, GadgetDescriptor::default())
|
||||
pub fn with_config(gadget_name: &str) -> Self {
|
||||
Self::with_descriptor(gadget_name, GadgetDescriptor::default())
|
||||
}
|
||||
|
||||
pub fn with_descriptor(
|
||||
gadget_name: &str,
|
||||
max_endpoints: u8,
|
||||
descriptor: GadgetDescriptor,
|
||||
) -> Self {
|
||||
let gadget_path = PathBuf::from(CONFIGFS_PATH).join(gadget_name);
|
||||
pub fn with_descriptor(gadget_name: &str, descriptor: GadgetDescriptor) -> Self {
|
||||
let gadget_path = configfs_path().join(gadget_name);
|
||||
let config_path = gadget_path.join("configs/c.1");
|
||||
|
||||
Self {
|
||||
@@ -73,9 +70,9 @@ impl OtgGadgetManager {
|
||||
gadget_path,
|
||||
config_path,
|
||||
descriptor,
|
||||
endpoint_allocator: EndpointAllocator::new(max_endpoints),
|
||||
hid_instance: 0,
|
||||
msd_instance: 0,
|
||||
network_instance: 0,
|
||||
functions: Vec::with_capacity(4),
|
||||
bound_udc: None,
|
||||
created_by_us: false,
|
||||
@@ -135,30 +132,24 @@ impl OtgGadgetManager {
|
||||
Ok(device_path)
|
||||
}
|
||||
|
||||
pub fn add_msd(&mut self) -> Result<MsdFunction> {
|
||||
let func = MsdFunction::new(self.msd_instance);
|
||||
pub fn add_msd(&mut self, lun_capacity: u8) -> Result<MsdFunction> {
|
||||
let func = MsdFunction::new(self.msd_instance, lun_capacity)?;
|
||||
let func_clone = func.clone();
|
||||
self.add_function(Box::new(func))?;
|
||||
self.msd_instance += 1;
|
||||
Ok(func_clone)
|
||||
}
|
||||
|
||||
pub fn add_network(&mut self, config: &OtgNetworkConfig) -> Result<NetworkFunction> {
|
||||
let func = NetworkFunction::new(self.network_instance, config)?;
|
||||
let func_clone = func.clone();
|
||||
self.add_function(Box::new(func))?;
|
||||
self.network_instance += 1;
|
||||
Ok(func_clone)
|
||||
}
|
||||
|
||||
fn add_function(&mut self, func: Box<dyn GadgetFunction>) -> Result<()> {
|
||||
let endpoints = func.endpoints_required();
|
||||
|
||||
if !self.endpoint_allocator.can_allocate(endpoints) {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Not enough endpoints for function {}: need {}, available {}",
|
||||
func.name(),
|
||||
endpoints,
|
||||
self.endpoint_allocator.available()
|
||||
)));
|
||||
}
|
||||
|
||||
self.endpoint_allocator.allocate(endpoints)?;
|
||||
|
||||
self.functions.push(func);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -166,9 +157,10 @@ impl OtgGadgetManager {
|
||||
debug!("Setting up OTG USB Gadget: {}", self.gadget_name);
|
||||
|
||||
if !Self::is_available() {
|
||||
return Err(AppError::Internal(
|
||||
"ConfigFS not available. Is it mounted at /sys/kernel/config?".to_string(),
|
||||
));
|
||||
return Err(AppError::Internal(format!(
|
||||
"ConfigFS not available at {}",
|
||||
configfs_path().display()
|
||||
)));
|
||||
}
|
||||
|
||||
if self.gadget_exists() {
|
||||
@@ -223,30 +215,51 @@ impl OtgGadgetManager {
|
||||
|
||||
pub fn cleanup(&mut self) -> Result<()> {
|
||||
if !self.gadget_exists() {
|
||||
self.created_by_us = false;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Cleaning up OTG USB Gadget: {}", self.gadget_name);
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let _ = self.unbind();
|
||||
if let Err(error) = self.unbind() {
|
||||
errors.push(format!("unbind failed: {error}"));
|
||||
}
|
||||
|
||||
for func in self.functions.iter().rev() {
|
||||
let _ = func.unlink(&self.config_path);
|
||||
if let Err(error) = func.unlink(&self.config_path) {
|
||||
errors.push(format!("unlink {} failed: {error}", func.name()));
|
||||
}
|
||||
}
|
||||
|
||||
let config_strings = self.config_path.join("strings/0x409");
|
||||
let _ = remove_dir(&config_strings);
|
||||
let _ = remove_dir(&self.config_path);
|
||||
if let Err(error) = remove_dir(&config_strings) {
|
||||
errors.push(error.to_string());
|
||||
}
|
||||
if let Err(error) = remove_dir(&self.config_path) {
|
||||
errors.push(error.to_string());
|
||||
}
|
||||
|
||||
for func in self.functions.iter().rev() {
|
||||
let _ = func.cleanup(&self.gadget_path);
|
||||
if let Err(error) = func.cleanup(&self.gadget_path) {
|
||||
errors.push(format!("cleanup {} failed: {error}", func.name()));
|
||||
}
|
||||
}
|
||||
|
||||
let gadget_strings = self.gadget_path.join("strings/0x409");
|
||||
let _ = remove_dir(&gadget_strings);
|
||||
if let Err(error) = remove_dir(&gadget_strings) {
|
||||
errors.push(error.to_string());
|
||||
}
|
||||
|
||||
if let Err(e) = remove_dir(&self.gadget_path) {
|
||||
warn!("Could not remove gadget directory: {}", e);
|
||||
if let Err(error) = remove_dir(&self.gadget_path) {
|
||||
errors.push(error.to_string());
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(AppError::Config(format!(
|
||||
"OTG gadget cleanup incomplete: {}",
|
||||
errors.join("; ")
|
||||
)));
|
||||
}
|
||||
|
||||
self.created_by_us = false;
|
||||
@@ -312,12 +325,21 @@ impl OtgGadgetManager {
|
||||
}
|
||||
|
||||
fn configuration_label(&self) -> &'static str {
|
||||
if self
|
||||
let has_msd = self
|
||||
.functions
|
||||
.iter()
|
||||
.any(|func| func.name().starts_with("mass_storage."))
|
||||
{
|
||||
.any(|func| func.name().starts_with("mass_storage."));
|
||||
let has_network = self.functions.iter().any(|func| {
|
||||
["ncm.", "ecm.", "rndis."]
|
||||
.iter()
|
||||
.any(|prefix| func.name().starts_with(prefix))
|
||||
});
|
||||
if has_msd && has_network {
|
||||
"Config 1: HID + MSD + NET"
|
||||
} else if has_msd {
|
||||
"Config 1: HID + MSD"
|
||||
} else if has_network {
|
||||
"Config 1: HID + NET"
|
||||
} else {
|
||||
"Config 1: HID"
|
||||
}
|
||||
@@ -427,14 +449,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_endpoint_tracking() {
|
||||
let mut manager = OtgGadgetManager::with_config("test", 8);
|
||||
fn test_function_selection_is_not_prevalidated() {
|
||||
let mut manager = OtgGadgetManager::with_config("test");
|
||||
|
||||
let _ = manager.add_keyboard(false);
|
||||
assert_eq!(manager.endpoint_allocator.used(), 1);
|
||||
|
||||
let _ = manager.add_mouse_relative();
|
||||
let _ = manager.add_mouse_absolute();
|
||||
assert_eq!(manager.endpoint_allocator.used(), 3);
|
||||
assert!(manager.add_keyboard(false).is_ok());
|
||||
assert!(manager.add_mouse_relative().is_ok());
|
||||
assert!(manager.add_mouse_absolute().is_ok());
|
||||
assert_eq!(manager.functions.len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! USB OTG composite gadget (HID + MSD).
|
||||
//! USB OTG composite gadget (HID + MSD + Ethernet).
|
||||
|
||||
#[cfg(unix)]
|
||||
pub mod bridge;
|
||||
#[cfg(unix)]
|
||||
pub mod configfs;
|
||||
pub mod endpoint;
|
||||
#[cfg(unix)]
|
||||
pub mod function;
|
||||
#[cfg(unix)]
|
||||
@@ -11,6 +12,8 @@ pub mod hid;
|
||||
pub mod manager;
|
||||
#[cfg(unix)]
|
||||
pub mod msd;
|
||||
#[cfg(unix)]
|
||||
pub mod network;
|
||||
pub mod report_desc;
|
||||
pub mod self_check;
|
||||
#[cfg(unix)]
|
||||
@@ -21,7 +24,9 @@ pub use manager::{wait_for_hid_devices, OtgGadgetManager};
|
||||
#[cfg(unix)]
|
||||
pub use msd::{MsdFunction, MsdLunConfig};
|
||||
#[cfg(unix)]
|
||||
pub use service::{HidDevicePaths, OtgService};
|
||||
pub use network::NetworkFunction;
|
||||
#[cfg(unix)]
|
||||
pub use service::{HidDevicePaths, OtgNetworkStatus, OtgRuntimeHealth, OtgService};
|
||||
|
||||
/// List USB Device Controller names exposed by sysfs.
|
||||
pub fn list_udc_devices() -> Vec<String> {
|
||||
|
||||
211
src/otg/msd.rs
211
src/otg/msd.rs
@@ -56,13 +56,21 @@ impl MsdLunConfig {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MsdFunction {
|
||||
name: String,
|
||||
lun_capacity: u8,
|
||||
}
|
||||
|
||||
impl MsdFunction {
|
||||
pub fn new(instance: u8) -> Self {
|
||||
Self {
|
||||
name: format!("mass_storage.usb{}", instance),
|
||||
pub fn new(instance: u8, lun_capacity: u8) -> Result<Self> {
|
||||
if lun_capacity != 1 && lun_capacity != 8 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"MSD LUN capacity must be 1 or 8, got {lun_capacity}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
name: format!("mass_storage.usb{}", instance),
|
||||
lun_capacity,
|
||||
})
|
||||
}
|
||||
|
||||
fn function_path(&self, gadget_path: &Path) -> PathBuf {
|
||||
@@ -73,6 +81,32 @@ impl MsdFunction {
|
||||
self.function_path(gadget_path).join(format!("lun.{}", lun))
|
||||
}
|
||||
|
||||
fn existing_lun_paths(&self, gadget_path: &Path) -> Result<Vec<(u16, PathBuf)>> {
|
||||
let func_path = self.function_path(gadget_path);
|
||||
if !func_path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let entries = fs::read_dir(&func_path).map_err(|e| {
|
||||
AppError::Internal(format!(
|
||||
"Failed to read MSD function directory {}: {}",
|
||||
func_path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let mut luns = entries
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
let name = entry.file_name();
|
||||
let name = name.to_str()?;
|
||||
let lun = name.strip_prefix("lun.")?.parse::<u16>().ok()?;
|
||||
Some((lun, entry.path()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
luns.sort_by_key(|(lun, _)| *lun);
|
||||
Ok(luns)
|
||||
}
|
||||
|
||||
pub async fn configure_lun_async(
|
||||
&self,
|
||||
gadget_path: &Path,
|
||||
@@ -88,11 +122,32 @@ impl MsdFunction {
|
||||
.map_err(|e| AppError::Internal(format!("Task join error: {}", e)))?
|
||||
}
|
||||
|
||||
fn clear_lun_unbound(&self, gadget_path: &Path, lun: u8) -> Result<()> {
|
||||
let lun_path = self.lun_path(gadget_path, lun);
|
||||
if !lun_path.exists() {
|
||||
create_dir(&lun_path)?;
|
||||
}
|
||||
write_file(&lun_path.join("file"), "")?;
|
||||
let _ = write_file(&lun_path.join("cdrom"), "0");
|
||||
let _ = write_file(&lun_path.join("ro"), "0");
|
||||
let _ = write_file(&lun_path.join("removable"), "1");
|
||||
let _ = write_file(&lun_path.join("nofua"), "1");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn configure_lun(&self, gadget_path: &Path, lun: u8, config: &MsdLunConfig) -> Result<()> {
|
||||
if lun >= self.lun_capacity {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"LUN {lun} is outside MSD capacity {}",
|
||||
self.lun_capacity
|
||||
)));
|
||||
}
|
||||
let lun_path = self.lun_path(gadget_path, lun);
|
||||
|
||||
if !lun_path.exists() {
|
||||
create_dir(&lun_path)?;
|
||||
return Err(AppError::Internal(format!(
|
||||
"Configured MSD LUN {lun} does not exist"
|
||||
)));
|
||||
}
|
||||
|
||||
let read_attr = |attr: &str| -> String {
|
||||
@@ -210,8 +265,18 @@ impl MsdFunction {
|
||||
}
|
||||
|
||||
pub fn disconnect_lun(&self, gadget_path: &Path, lun: u8) -> Result<()> {
|
||||
if lun >= self.lun_capacity {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"LUN {lun} is outside MSD capacity {}",
|
||||
self.lun_capacity
|
||||
)));
|
||||
}
|
||||
let lun_path = self.lun_path(gadget_path, lun);
|
||||
|
||||
self.disconnect_lun_path(&lun_path, lun as u16)
|
||||
}
|
||||
|
||||
fn disconnect_lun_path(&self, lun_path: &Path, lun: u16) -> Result<()> {
|
||||
if lun_path.exists() {
|
||||
let forced_eject_path = lun_path.join("forced_eject");
|
||||
if forced_eject_path.exists() {
|
||||
@@ -226,11 +291,17 @@ impl MsdFunction {
|
||||
"forced_eject write failed: {}, falling back to clearing file",
|
||||
e
|
||||
);
|
||||
write_file(&lun_path.join("file"), "")?;
|
||||
let file_path = lun_path.join("file");
|
||||
if file_path.exists() {
|
||||
write_file(&file_path, "")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
write_file(&lun_path.join("file"), "")?;
|
||||
let file_path = lun_path.join("file");
|
||||
if file_path.exists() {
|
||||
write_file(&file_path, "")?;
|
||||
}
|
||||
}
|
||||
info!("LUN {} disconnected", lun);
|
||||
}
|
||||
@@ -262,10 +333,6 @@ impl GadgetFunction for MsdFunction {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn endpoints_required(&self) -> u8 {
|
||||
2
|
||||
}
|
||||
|
||||
fn create(&self, gadget_path: &Path) -> Result<()> {
|
||||
let func_path = self.function_path(gadget_path);
|
||||
create_dir(&func_path)?;
|
||||
@@ -275,16 +342,10 @@ impl GadgetFunction for MsdFunction {
|
||||
let _ = write_file(&stall_path, "0");
|
||||
}
|
||||
|
||||
let lun0_path = func_path.join("lun.0");
|
||||
if !lun0_path.exists() {
|
||||
create_dir(&lun0_path)?;
|
||||
for lun in 0..self.lun_capacity {
|
||||
self.clear_lun_unbound(gadget_path, lun)?;
|
||||
}
|
||||
|
||||
let _ = write_file(&lun0_path.join("cdrom"), "0");
|
||||
let _ = write_file(&lun0_path.join("ro"), "0");
|
||||
let _ = write_file(&lun0_path.join("removable"), "1");
|
||||
let _ = write_file(&lun0_path.join("nofua"), "1");
|
||||
|
||||
debug!("Created MSD function: {}", self.name());
|
||||
Ok(())
|
||||
}
|
||||
@@ -310,13 +371,38 @@ impl GadgetFunction for MsdFunction {
|
||||
|
||||
fn cleanup(&self, gadget_path: &Path) -> Result<()> {
|
||||
let func_path = self.function_path(gadget_path);
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for lun in 0..8 {
|
||||
let _ = self.disconnect_lun(gadget_path, lun);
|
||||
let lun_paths = match self.existing_lun_paths(gadget_path) {
|
||||
Ok(luns) => luns,
|
||||
Err(e) => {
|
||||
errors.push(format!("could not enumerate MSD LUN directories: {e}"));
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
for (lun, lun_path) in lun_paths {
|
||||
if let Err(e) = self.disconnect_lun_path(&lun_path, lun) {
|
||||
errors.push(format!("could not disconnect LUN {lun}: {e}"));
|
||||
}
|
||||
// lun.0 is the mass-storage function's configfs default group. It
|
||||
// cannot be removed directly and is released with the function.
|
||||
if lun == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = remove_dir(&lun_path) {
|
||||
errors.push(format!("could not remove LUN {lun} directory: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = remove_dir(&func_path) {
|
||||
warn!("Could not remove MSD function directory: {}", e);
|
||||
errors.push(format!("could not remove MSD function directory: {e}"));
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(AppError::Config(format!(
|
||||
"MSD cleanup incomplete: {}",
|
||||
errors.join("; ")
|
||||
)));
|
||||
}
|
||||
|
||||
debug!("Cleaned up MSD function {}", self.name());
|
||||
@@ -327,6 +413,7 @@ impl GadgetFunction for MsdFunction {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_lun_config_cdrom() {
|
||||
@@ -346,8 +433,86 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_msd_function_name() {
|
||||
let msd = MsdFunction::new(0);
|
||||
let msd = MsdFunction::new(0, 1).unwrap();
|
||||
assert_eq!(msd.name(), "mass_storage.usb0");
|
||||
assert_eq!(msd.endpoints_required(), 2);
|
||||
assert_eq!(msd.lun_capacity, 1);
|
||||
|
||||
let multi = MsdFunction::new(0, 8).unwrap();
|
||||
assert_eq!(multi.lun_capacity, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_msd_function_rejects_invalid_capacity() {
|
||||
assert!(MsdFunction::new(0, 0).is_err());
|
||||
assert!(MsdFunction::new(0, 2).is_err());
|
||||
assert!(MsdFunction::new(0, 9).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_uses_configured_lun_capacity() {
|
||||
for capacity in [1, 8] {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(temp_dir.path().join("functions")).unwrap();
|
||||
let msd = MsdFunction::new(0, capacity).unwrap();
|
||||
|
||||
msd.create(temp_dir.path()).unwrap();
|
||||
|
||||
for lun in 0..capacity {
|
||||
assert!(msd.lun_path(temp_dir.path(), lun).exists());
|
||||
}
|
||||
assert!(!msd.lun_path(temp_dir.path(), capacity).exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configure_lun_does_not_rebind_udc() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let lun_path = temp_dir.path().join("functions/mass_storage.usb0/lun.0");
|
||||
std::fs::create_dir_all(&lun_path).unwrap();
|
||||
for attr in ["file", "cdrom", "ro", "removable", "nofua"] {
|
||||
std::fs::write(lun_path.join(attr), b"0\n").unwrap();
|
||||
}
|
||||
std::fs::write(temp_dir.path().join("UDC"), b"test.udc\n").unwrap();
|
||||
let image_path = temp_dir.path().join("test.img");
|
||||
std::fs::write(&image_path, b"image").unwrap();
|
||||
let msd = MsdFunction::new(0, 1).unwrap();
|
||||
|
||||
msd.configure_lun(temp_dir.path(), 0, &MsdLunConfig::disk(image_path, false))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(temp_dir.path().join("UDC")).unwrap(),
|
||||
"test.udc\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_removes_all_dynamic_luns_including_stale_capacity() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let func_path = temp_dir.path().join("functions/mass_storage.usb0");
|
||||
for lun in 1..8 {
|
||||
std::fs::create_dir_all(func_path.join(format!("lun.{lun}"))).unwrap();
|
||||
}
|
||||
let msd = MsdFunction::new(0, 1).unwrap();
|
||||
|
||||
msd.cleanup(temp_dir.path()).unwrap();
|
||||
|
||||
assert!(!func_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_reports_when_non_configfs_cannot_release_default_lun() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let func_path = temp_dir.path().join("functions/mass_storage.usb0");
|
||||
for lun in 0..2 {
|
||||
std::fs::create_dir_all(func_path.join(format!("lun.{lun}"))).unwrap();
|
||||
}
|
||||
let msd = MsdFunction::new(0, 1).unwrap();
|
||||
|
||||
let error = msd.cleanup(temp_dir.path()).unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("MSD cleanup incomplete"));
|
||||
assert!(func_path.join("lun.0").exists());
|
||||
assert!(!func_path.join("lun.1").exists());
|
||||
}
|
||||
}
|
||||
|
||||
196
src/otg/network.rs
Normal file
196
src/otg/network.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tracing::debug;
|
||||
|
||||
use super::configfs::{
|
||||
create_dir, create_symlink, remove_dir, remove_file, write_bytes, write_file,
|
||||
};
|
||||
use super::function::GadgetFunction;
|
||||
use crate::config::{OtgNetworkConfig, OtgNetworkDriverMode};
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkFunction {
|
||||
name: String,
|
||||
mode: OtgNetworkDriverMode,
|
||||
host_mac: String,
|
||||
device_mac: String,
|
||||
}
|
||||
|
||||
impl NetworkFunction {
|
||||
pub fn new(instance: u8, config: &OtgNetworkConfig) -> Result<Self> {
|
||||
config.validate()?;
|
||||
let (device_mac, host_mac) = resolved_mac_pair(config);
|
||||
Ok(Self {
|
||||
name: format!("{}.usb{}", config.driver_mode.function_name(), instance),
|
||||
mode: config.driver_mode,
|
||||
host_mac,
|
||||
device_mac,
|
||||
})
|
||||
}
|
||||
|
||||
fn function_path(&self, gadget_path: &Path) -> PathBuf {
|
||||
gadget_path.join("functions").join(&self.name)
|
||||
}
|
||||
|
||||
pub fn interface_name(&self, gadget_path: &Path) -> Result<String> {
|
||||
let path = self.function_path(gadget_path).join("ifname");
|
||||
let value = fs::read_to_string(&path).map_err(|e| {
|
||||
AppError::Internal(format!(
|
||||
"Failed to read OTG network interface from {}: {}",
|
||||
path.display(),
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.contains('%') {
|
||||
return Err(AppError::Internal(format!(
|
||||
"Kernel did not allocate an OTG network interface for {}",
|
||||
self.name
|
||||
)));
|
||||
}
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> OtgNetworkDriverMode {
|
||||
self.mode
|
||||
}
|
||||
}
|
||||
|
||||
impl GadgetFunction for NetworkFunction {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn create(&self, gadget_path: &Path) -> Result<()> {
|
||||
let function_path = self.function_path(gadget_path);
|
||||
create_dir(&function_path)?;
|
||||
write_file(&function_path.join("dev_addr"), &self.device_mac)?;
|
||||
write_file(&function_path.join("host_addr"), &self.host_mac)?;
|
||||
|
||||
// New kernels accept an unbound interface-name pattern; old kernels expose it read-only.
|
||||
let _ = write_file(
|
||||
&function_path.join("ifname"),
|
||||
&format!("okvm-{}%d", self.mode.function_name()),
|
||||
);
|
||||
|
||||
if self.mode == OtgNetworkDriverMode::Rndis {
|
||||
write_file(&gadget_path.join("bDeviceClass"), "0xEF")?;
|
||||
write_file(&gadget_path.join("bDeviceSubClass"), "0x02")?;
|
||||
write_file(&gadget_path.join("bDeviceProtocol"), "0x01")?;
|
||||
write_file(&gadget_path.join("os_desc/use"), "1")?;
|
||||
write_file(&gadget_path.join("os_desc/b_vendor_code"), "0xcd")?;
|
||||
write_bytes(&gadget_path.join("os_desc/qw_sign"), b"MSFT100")?;
|
||||
write_file(
|
||||
&function_path.join("os_desc/interface.rndis/compatible_id"),
|
||||
"RNDIS",
|
||||
)?;
|
||||
write_file(
|
||||
&function_path.join("os_desc/interface.rndis/sub_compatible_id"),
|
||||
"5162001",
|
||||
)?;
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Created {} OTG network function {} (device {}, host {})",
|
||||
self.mode.function_name(),
|
||||
self.name,
|
||||
self.device_mac,
|
||||
self.host_mac
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn link(&self, config_path: &Path, gadget_path: &Path) -> Result<()> {
|
||||
let function_path = self.function_path(gadget_path);
|
||||
let config_link = config_path.join(&self.name);
|
||||
if !config_link.exists() {
|
||||
create_symlink(&function_path, &config_link)?;
|
||||
}
|
||||
if self.mode == OtgNetworkDriverMode::Rndis {
|
||||
let os_desc_link = gadget_path.join("os_desc/c.1");
|
||||
if !os_desc_link.exists() {
|
||||
create_symlink(config_path, &os_desc_link)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unlink(&self, config_path: &Path) -> Result<()> {
|
||||
let mut errors = Vec::new();
|
||||
if self.mode == OtgNetworkDriverMode::Rndis {
|
||||
if let Some(gadget_path) = config_path.parent().and_then(Path::parent) {
|
||||
if let Err(error) = remove_file(&gadget_path.join("os_desc/c.1")) {
|
||||
errors.push(error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(error) = remove_file(&config_path.join(&self.name)) {
|
||||
errors.push(error.to_string());
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Config(format!(
|
||||
"Failed to unlink OTG network function {}: {}",
|
||||
self.name,
|
||||
errors.join("; ")
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup(&self, gadget_path: &Path) -> Result<()> {
|
||||
remove_dir(&self.function_path(gadget_path))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolved_mac_pair(config: &OtgNetworkConfig) -> (String, String) {
|
||||
if !config.device_mac.is_empty() && !config.host_mac.is_empty() {
|
||||
return (config.device_mac.clone(), config.host_mac.clone());
|
||||
}
|
||||
|
||||
let identity = fs::read_to_string("/etc/machine-id")
|
||||
.or_else(|_| fs::read_to_string("/etc/hostname"))
|
||||
.unwrap_or_else(|_| "one-kvm".to_string());
|
||||
let mut hasher = DefaultHasher::new();
|
||||
identity.trim().hash(&mut hasher);
|
||||
let value = hasher.finish().to_be_bytes();
|
||||
let device = format!(
|
||||
"02:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
|
||||
value[1], value[2], value[3], value[4], value[5]
|
||||
);
|
||||
let host = format!(
|
||||
"02:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
|
||||
value[1],
|
||||
value[2],
|
||||
value[3],
|
||||
value[4],
|
||||
value[5] ^ 0x01
|
||||
);
|
||||
(
|
||||
if config.device_mac.is_empty() {
|
||||
device
|
||||
} else {
|
||||
config.device_mac.clone()
|
||||
},
|
||||
if config.host_mac.is_empty() {
|
||||
host
|
||||
} else {
|
||||
config.host_mac.clone()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn network_function_uses_selected_driver_name() {
|
||||
let function = NetworkFunction::new(0, &OtgNetworkConfig::default()).unwrap();
|
||||
assert_eq!(function.name(), "ncm.usb0");
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,7 @@ fn detect_libcomposite_available(gadget_root: &std::path::Path) -> bool {
|
||||
/// OTG self-check status for troubleshooting USB gadget issues
|
||||
pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
let hid_backend_is_otg = matches!(config.hid.backend, crate::config::HidBackend::Otg);
|
||||
let gadget_expected = hid_backend_is_otg || config.msd.enabled || config.otg_network.enabled;
|
||||
let mut checks = Vec::new();
|
||||
|
||||
let build_response = |checks: Vec<OtgSelfCheckItem>,
|
||||
@@ -286,7 +287,10 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
);
|
||||
}
|
||||
|
||||
let gadget_root = std::path::Path::new("/sys/kernel/config/usb_gadget");
|
||||
let gadget_root = crate::otg::configfs::configfs_path();
|
||||
let configfs_mount = gadget_root
|
||||
.parent()
|
||||
.unwrap_or_else(|| std::path::Path::new("/sys/kernel/config"));
|
||||
let configfs_mounted = std::fs::read_to_string("/proc/mounts")
|
||||
.ok()
|
||||
.map(|mounts| {
|
||||
@@ -295,7 +299,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
let _src = parts.next();
|
||||
let mount_point = parts.next();
|
||||
let fs_type = parts.next();
|
||||
mount_point == Some("/sys/kernel/config") && fs_type == Some("configfs")
|
||||
mount_point == configfs_mount.to_str() && fs_type == Some("configfs")
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
@@ -310,7 +314,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
OtgSelfCheckLevel::Info,
|
||||
"Check configfs mount status",
|
||||
None::<String>,
|
||||
Some("/sys/kernel/config"),
|
||||
Some(configfs_mount.display().to_string()),
|
||||
);
|
||||
} else {
|
||||
gadget_config_ok = false;
|
||||
@@ -320,8 +324,11 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
false,
|
||||
OtgSelfCheckLevel::Error,
|
||||
"Check configfs mount status",
|
||||
Some("Try: mount -t configfs none /sys/kernel/config"),
|
||||
Some("/sys/kernel/config"),
|
||||
Some(format!(
|
||||
"Try: mount -t configfs none {}",
|
||||
configfs_mount.display()
|
||||
)),
|
||||
Some(configfs_mount.display().to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -331,9 +338,9 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
"usb_gadget_dir_exists",
|
||||
true,
|
||||
OtgSelfCheckLevel::Info,
|
||||
"Check /sys/kernel/config/usb_gadget access",
|
||||
format!("Check {} access", gadget_root.display()),
|
||||
None::<String>,
|
||||
Some("/sys/kernel/config/usb_gadget"),
|
||||
Some(gadget_root.display().to_string()),
|
||||
);
|
||||
} else {
|
||||
gadget_config_ok = false;
|
||||
@@ -342,9 +349,9 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
"usb_gadget_dir_exists",
|
||||
false,
|
||||
OtgSelfCheckLevel::Error,
|
||||
"Check /sys/kernel/config/usb_gadget access",
|
||||
format!("Check {} access", gadget_root.display()),
|
||||
Some("Ensure configfs and USB gadget support are enabled"),
|
||||
Some("/sys/kernel/config/usb_gadget"),
|
||||
Some(gadget_root.display().to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -402,13 +409,13 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
&mut checks,
|
||||
"one_kvm_gadget_exists",
|
||||
false,
|
||||
if hid_backend_is_otg {
|
||||
if gadget_expected {
|
||||
OtgSelfCheckLevel::Error
|
||||
} else {
|
||||
OtgSelfCheckLevel::Warn
|
||||
},
|
||||
"Check one-kvm gadget presence",
|
||||
Some("Enable OTG HID or MSD to let one-kvm gadget be created automatically"),
|
||||
Some("Enable OTG HID, MSD, or USB Ethernet to create the one-kvm gadget"),
|
||||
Some(one_kvm_path.display().to_string()),
|
||||
);
|
||||
}
|
||||
@@ -426,7 +433,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
OtgSelfCheckLevel::Info,
|
||||
"Check for other gadget services",
|
||||
None::<String>,
|
||||
Some("/sys/kernel/config/usb_gadget"),
|
||||
Some(gadget_root.display().to_string()),
|
||||
);
|
||||
} else {
|
||||
push_otg_check(
|
||||
@@ -436,7 +443,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
OtgSelfCheckLevel::Warn,
|
||||
"Check for other gadget services",
|
||||
Some("Potential UDC contention with one-kvm; check other OTG services"),
|
||||
Some("/sys/kernel/config/usb_gadget"),
|
||||
Some(gadget_root.display().to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -478,6 +485,15 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
.filter(|name| name.starts_with("hid.usb"))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let network_functions = function_names
|
||||
.iter()
|
||||
.filter(|name| {
|
||||
name.starts_with("ncm.usb")
|
||||
|| name.starts_with("ecm.usb")
|
||||
|| name.starts_with("rndis.usb")
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if hid_functions.is_empty() {
|
||||
push_otg_check(
|
||||
&mut checks,
|
||||
@@ -504,6 +520,103 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
);
|
||||
}
|
||||
|
||||
if config.otg_network.enabled {
|
||||
let network_function_ok = network_functions.len() == 1;
|
||||
push_otg_check(
|
||||
&mut checks,
|
||||
"network_function_present",
|
||||
network_function_ok,
|
||||
if network_function_ok {
|
||||
OtgSelfCheckLevel::Info
|
||||
} else {
|
||||
OtgSelfCheckLevel::Error
|
||||
},
|
||||
"Check USB Ethernet function creation",
|
||||
Some("The configured NCM/ECM/RNDIS function must exist exactly once"),
|
||||
Some(functions_path.display().to_string()),
|
||||
);
|
||||
|
||||
if let Some(function_name) = network_functions.first() {
|
||||
let ifname_path = functions_path.join(function_name).join("ifname");
|
||||
let ifname = read_trimmed(&ifname_path).unwrap_or_default();
|
||||
let netdev_ok = !ifname.is_empty()
|
||||
&& !ifname.contains('%')
|
||||
&& std::path::Path::new("/sys/class/net")
|
||||
.join(&ifname)
|
||||
.exists();
|
||||
push_otg_check(
|
||||
&mut checks,
|
||||
"network_netdev_present",
|
||||
netdev_ok,
|
||||
if netdev_ok {
|
||||
OtgSelfCheckLevel::Info
|
||||
} else {
|
||||
OtgSelfCheckLevel::Error
|
||||
},
|
||||
"Check USB Ethernet network device",
|
||||
Some("Read the function ifname and verify the matching /sys/class/net entry"),
|
||||
Some(ifname_path.display().to_string()),
|
||||
);
|
||||
|
||||
if netdev_ok {
|
||||
let master_path = std::path::Path::new("/sys/class/net")
|
||||
.join(&ifname)
|
||||
.join("master");
|
||||
let bridge_ok = std::fs::canonicalize(&master_path)
|
||||
.ok()
|
||||
.and_then(|path| {
|
||||
path.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
})
|
||||
.is_some_and(|name| {
|
||||
name == "okvm-br0" || name == config.otg_network.bridge_interface
|
||||
});
|
||||
push_otg_check(
|
||||
&mut checks,
|
||||
"network_bridge_port",
|
||||
bridge_ok,
|
||||
if bridge_ok {
|
||||
OtgSelfCheckLevel::Info
|
||||
} else {
|
||||
OtgSelfCheckLevel::Error
|
||||
},
|
||||
"Check USB Ethernet bridge membership",
|
||||
Some("The USB network interface must be a port of the selected bridge"),
|
||||
Some(master_path.display().to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
if function_name.starts_with("rndis.") {
|
||||
let os_desc = one_kvm_path.join("os_desc/c.1");
|
||||
let os_desc_ok = os_desc.exists()
|
||||
&& read_trimmed(&one_kvm_path.join("os_desc/use")).as_deref() == Some("1")
|
||||
&& read_trimmed(&one_kvm_path.join("os_desc/qw_sign")).as_deref()
|
||||
== Some("MSFT100")
|
||||
&& read_trimmed(
|
||||
&functions_path
|
||||
.join(function_name)
|
||||
.join("os_desc/interface.rndis/compatible_id"),
|
||||
)
|
||||
.is_some_and(|value| value.starts_with("RNDIS"));
|
||||
push_otg_check(
|
||||
&mut checks,
|
||||
"rndis_os_descriptor",
|
||||
os_desc_ok,
|
||||
if os_desc_ok {
|
||||
OtgSelfCheckLevel::Info
|
||||
} else {
|
||||
OtgSelfCheckLevel::Error
|
||||
},
|
||||
"Check RNDIS Microsoft OS descriptor",
|
||||
Some(
|
||||
"RNDIS requires the OS descriptor configuration link and compatible ID",
|
||||
),
|
||||
Some(os_desc.display().to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let config_path = one_kvm_path.join("configs/c.1");
|
||||
if !config_path.exists() {
|
||||
push_otg_check(
|
||||
@@ -621,7 +734,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
OtgSelfCheckLevel::Info,
|
||||
"Check UDC binding conflicts",
|
||||
None::<String>,
|
||||
Some("/sys/kernel/config/usb_gadget/*/UDC"),
|
||||
Some(format!("{}/*/UDC", gadget_root.display())),
|
||||
);
|
||||
} else {
|
||||
push_otg_check(
|
||||
@@ -631,7 +744,7 @@ pub fn run(config: &crate::config::AppConfig) -> OtgSelfCheckResponse {
|
||||
OtgSelfCheckLevel::Error,
|
||||
"Check UDC binding conflicts",
|
||||
Some("Stop other OTG services or switch one-kvm to an idle UDC"),
|
||||
Some("/sys/kernel/config/usb_gadget/*/UDC"),
|
||||
Some(format!("{}/*/UDC", gadget_root.display())),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tracing::{debug, info, warn};
|
||||
use typeshare::typeshare;
|
||||
|
||||
use super::bridge::NetworkBridgeRuntime;
|
||||
use super::manager::{wait_for_hid_devices, GadgetDescriptor, OtgGadgetManager};
|
||||
use super::msd::MsdFunction;
|
||||
use crate::config::{HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions};
|
||||
use crate::config::{
|
||||
HidBackend, HidConfig, MsdConfig, OtgDescriptorConfig, OtgHidFunctions, OtgNetworkConfig,
|
||||
};
|
||||
use crate::error::{AppError, Result};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -17,6 +22,23 @@ pub struct HidDevicePaths {
|
||||
pub keyboard_leds_enabled: bool,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum OtgRuntimeHealth {
|
||||
#[default]
|
||||
Healthy,
|
||||
Applying,
|
||||
Degraded,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct OtgNetworkStatus {
|
||||
pub health: OtgRuntimeHealth,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl HidDevicePaths {
|
||||
pub fn existing_paths(&self) -> Vec<PathBuf> {
|
||||
[
|
||||
@@ -38,7 +60,8 @@ pub(crate) struct OtgDesiredState {
|
||||
pub hid_functions: Option<OtgHidFunctions>,
|
||||
pub keyboard_leds: bool,
|
||||
pub msd_enabled: bool,
|
||||
pub max_endpoints: u8,
|
||||
pub msd_lun_capacity: u8,
|
||||
pub network: OtgNetworkConfig,
|
||||
}
|
||||
|
||||
impl Default for OtgDesiredState {
|
||||
@@ -49,13 +72,19 @@ impl Default for OtgDesiredState {
|
||||
hid_functions: None,
|
||||
keyboard_leds: false,
|
||||
msd_enabled: false,
|
||||
max_endpoints: super::endpoint::DEFAULT_MAX_ENDPOINTS,
|
||||
msd_lun_capacity: 1,
|
||||
network: OtgNetworkConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OtgDesiredState {
|
||||
pub(crate) fn from_config(hid: &HidConfig, msd: &MsdConfig) -> Result<Self> {
|
||||
pub(crate) fn from_config(
|
||||
hid: &HidConfig,
|
||||
msd: &MsdConfig,
|
||||
network: &OtgNetworkConfig,
|
||||
) -> Result<Self> {
|
||||
network.validate()?;
|
||||
let hid_functions = if hid.backend == HidBackend::Otg {
|
||||
let functions = hid.constrained_otg_functions();
|
||||
Some(functions)
|
||||
@@ -63,17 +92,25 @@ impl OtgDesiredState {
|
||||
None
|
||||
};
|
||||
|
||||
hid.validate_otg_endpoint_budget(msd.enabled)?;
|
||||
|
||||
hid.validate_otg_functions()?;
|
||||
let needs_udc = hid_functions.is_some() || msd.enabled || network.enabled;
|
||||
let udc = if needs_udc {
|
||||
hid.otg_udc
|
||||
.as_ref()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.or_else(OtgGadgetManager::find_udc)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Self {
|
||||
udc: hid.resolved_otg_udc(),
|
||||
udc,
|
||||
descriptor: GadgetDescriptor::from(&hid.otg_descriptor),
|
||||
hid_functions,
|
||||
keyboard_leds: hid.effective_otg_keyboard_leds(),
|
||||
msd_enabled: msd.enabled,
|
||||
max_endpoints: hid
|
||||
.resolved_otg_endpoint_limit()
|
||||
.unwrap_or(super::endpoint::DEFAULT_MAX_ENDPOINTS),
|
||||
msd_lun_capacity: 1,
|
||||
network: network.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,27 +118,55 @@ impl OtgDesiredState {
|
||||
pub fn hid_enabled(&self) -> bool {
|
||||
self.hid_functions.is_some()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn network_enabled(&self) -> bool {
|
||||
self.network.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone)]
|
||||
struct OtgServiceState {
|
||||
pub health: OtgRuntimeHealth,
|
||||
pub gadget_active: bool,
|
||||
pub hid_enabled: bool,
|
||||
pub msd_enabled: bool,
|
||||
pub msd_lun_capacity: u8,
|
||||
pub network: OtgNetworkConfig,
|
||||
pub configured_udc: Option<String>,
|
||||
pub hid_paths: Option<HidDevicePaths>,
|
||||
pub hid_functions: Option<OtgHidFunctions>,
|
||||
pub keyboard_leds_enabled: bool,
|
||||
pub max_endpoints: u8,
|
||||
pub descriptor: Option<GadgetDescriptor>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for OtgServiceState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
health: OtgRuntimeHealth::Healthy,
|
||||
gadget_active: false,
|
||||
hid_enabled: false,
|
||||
msd_enabled: false,
|
||||
msd_lun_capacity: 1,
|
||||
network: OtgNetworkConfig::default(),
|
||||
configured_udc: None,
|
||||
hid_paths: None,
|
||||
hid_functions: None,
|
||||
keyboard_leds_enabled: false,
|
||||
descriptor: None,
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OtgService {
|
||||
manager: Mutex<Option<OtgGadgetManager>>,
|
||||
state: RwLock<OtgServiceState>,
|
||||
msd_function: RwLock<Option<MsdFunction>>,
|
||||
network_bridge: Mutex<Option<NetworkBridgeRuntime>>,
|
||||
desired: RwLock<OtgDesiredState>,
|
||||
recovery_checked: AtomicBool,
|
||||
}
|
||||
|
||||
impl OtgService {
|
||||
@@ -110,7 +175,9 @@ impl OtgService {
|
||||
manager: Mutex::new(None),
|
||||
state: RwLock::new(OtgServiceState::default()),
|
||||
msd_function: RwLock::new(None),
|
||||
network_bridge: Mutex::new(None),
|
||||
desired: RwLock::new(OtgDesiredState::default()),
|
||||
recovery_checked: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,8 +198,97 @@ impl OtgService {
|
||||
self.msd_function.read().await.clone()
|
||||
}
|
||||
|
||||
pub async fn apply_config(&self, hid: &HidConfig, msd: &MsdConfig) -> Result<()> {
|
||||
let desired = OtgDesiredState::from_config(hid, msd)?;
|
||||
pub async fn msd_lun_capacity(&self) -> u8 {
|
||||
self.desired.read().await.msd_lun_capacity
|
||||
}
|
||||
|
||||
pub async fn network_status(&self) -> OtgNetworkStatus {
|
||||
let state = self.state.read().await;
|
||||
OtgNetworkStatus {
|
||||
health: state.health,
|
||||
error: state.error.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_config(
|
||||
&self,
|
||||
hid: &HidConfig,
|
||||
msd: &MsdConfig,
|
||||
network: &OtgNetworkConfig,
|
||||
) -> Result<()> {
|
||||
if !self.recovery_checked.load(Ordering::SeqCst) {
|
||||
if let Err(error) = NetworkBridgeRuntime::recover_stale_transaction() {
|
||||
let message = format!("Failed to recover stale OTG network transaction: {error}");
|
||||
self.mark_degraded(message.clone()).await;
|
||||
return Err(AppError::Config(message));
|
||||
}
|
||||
self.recovery_checked.store(true, Ordering::SeqCst);
|
||||
}
|
||||
let previous = self.desired.read().await.clone();
|
||||
let desired = self
|
||||
.desired_from_config_preserving_runtime(hid, msd, network)
|
||||
.await?;
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
state.health = OtgRuntimeHealth::Applying;
|
||||
state.error = None;
|
||||
}
|
||||
if let Err(error) = self.apply_desired_state(desired).await {
|
||||
warn!("OTG apply failed, restoring previous desired state: {error}");
|
||||
self.mark_degraded(error.to_string()).await;
|
||||
return match self.apply_desired_state(previous).await {
|
||||
Ok(()) => {
|
||||
self.mark_healthy().await;
|
||||
Err(error)
|
||||
}
|
||||
Err(rollback_error) => {
|
||||
let message = format!("{error}; OTG runtime rollback failed: {rollback_error}");
|
||||
self.mark_degraded(message.clone()).await;
|
||||
Err(AppError::Config(message))
|
||||
}
|
||||
};
|
||||
}
|
||||
self.mark_healthy().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn mark_degraded(&self, error: String) {
|
||||
let mut state = self.state.write().await;
|
||||
state.health = OtgRuntimeHealth::Degraded;
|
||||
state.error = Some(error);
|
||||
}
|
||||
|
||||
async fn mark_healthy(&self) {
|
||||
let mut state = self.state.write().await;
|
||||
state.health = OtgRuntimeHealth::Healthy;
|
||||
state.error = None;
|
||||
}
|
||||
|
||||
async fn desired_from_config_preserving_runtime(
|
||||
&self,
|
||||
hid: &HidConfig,
|
||||
msd: &MsdConfig,
|
||||
network: &OtgNetworkConfig,
|
||||
) -> Result<OtgDesiredState> {
|
||||
let mut desired = OtgDesiredState::from_config(hid, msd, network)?;
|
||||
desired.msd_lun_capacity = self.desired.read().await.msd_lun_capacity;
|
||||
Ok(desired)
|
||||
}
|
||||
|
||||
pub async fn set_msd_lun_capacity(&self, capacity: u8) -> Result<()> {
|
||||
if capacity != 1 && capacity != 8 {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"MSD LUN capacity must be 1 or 8, got {capacity}"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut desired = self.desired.read().await.clone();
|
||||
if !desired.msd_enabled {
|
||||
return Err(AppError::Internal(
|
||||
"MSD is not enabled in the OTG gadget".to_string(),
|
||||
));
|
||||
}
|
||||
desired.msd_lun_capacity = capacity;
|
||||
self.apply_desired_state(desired).await
|
||||
}
|
||||
|
||||
@@ -149,21 +305,24 @@ impl OtgService {
|
||||
let desired = self.desired.read().await.clone();
|
||||
|
||||
debug!(
|
||||
"Reconciling OTG gadget: HID={}, MSD={}, UDC={:?}",
|
||||
"Reconciling OTG gadget: HID={}, MSD={}, NET={}, UDC={:?}",
|
||||
desired.hid_enabled(),
|
||||
desired.msd_enabled,
|
||||
desired.network_enabled(),
|
||||
desired.udc
|
||||
);
|
||||
|
||||
{
|
||||
let state = self.state.read().await;
|
||||
if state.gadget_active
|
||||
if state.health != OtgRuntimeHealth::Degraded
|
||||
&& state.gadget_active
|
||||
&& state.hid_enabled == desired.hid_enabled()
|
||||
&& state.msd_enabled == desired.msd_enabled
|
||||
&& state.msd_lun_capacity == desired.msd_lun_capacity
|
||||
&& state.network == desired.network
|
||||
&& state.configured_udc == desired.udc
|
||||
&& state.hid_functions == desired.hid_functions
|
||||
&& state.keyboard_leds_enabled == desired.keyboard_leds
|
||||
&& state.max_endpoints == desired.max_endpoints
|
||||
&& state.descriptor.as_ref() == Some(&desired.descriptor)
|
||||
{
|
||||
debug!("OTG gadget already matches desired state");
|
||||
@@ -171,14 +330,24 @@ impl OtgService {
|
||||
}
|
||||
}
|
||||
|
||||
let network_runtime = { self.network_bridge.lock().await.as_ref().cloned() };
|
||||
if let Some(runtime) = network_runtime {
|
||||
debug!("Restoring network before OTG gadget reconcile");
|
||||
tokio::task::spawn_blocking(move || runtime.deactivate())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Bridge cleanup task failed: {e}")))??;
|
||||
self.network_bridge.lock().await.take();
|
||||
}
|
||||
|
||||
{
|
||||
let mut manager = self.manager.lock().await;
|
||||
if let Some(mut m) = manager.take() {
|
||||
if let Some(m) = manager.as_mut() {
|
||||
debug!("Cleaning up existing gadget before OTG reconcile");
|
||||
if let Err(e) = m.cleanup() {
|
||||
warn!("Error cleaning up existing gadget: {}", e);
|
||||
}
|
||||
m.cleanup().map_err(|e| {
|
||||
AppError::Internal(format!("Failed to clean up existing OTG gadget: {e}"))
|
||||
})?;
|
||||
}
|
||||
manager.take();
|
||||
}
|
||||
|
||||
*self.msd_function.write().await = None;
|
||||
@@ -188,16 +357,17 @@ impl OtgService {
|
||||
state.gadget_active = false;
|
||||
state.hid_enabled = false;
|
||||
state.msd_enabled = false;
|
||||
state.msd_lun_capacity = 1;
|
||||
state.network = OtgNetworkConfig::default();
|
||||
state.configured_udc = None;
|
||||
state.hid_paths = None;
|
||||
state.hid_functions = None;
|
||||
state.keyboard_leds_enabled = false;
|
||||
state.max_endpoints = super::endpoint::DEFAULT_MAX_ENDPOINTS;
|
||||
state.descriptor = None;
|
||||
state.error = None;
|
||||
}
|
||||
|
||||
if !desired.hid_enabled() && !desired.msd_enabled {
|
||||
if !desired.hid_enabled() && !desired.msd_enabled && !desired.network_enabled() {
|
||||
info!("OTG desired state is empty, gadget removed");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -219,7 +389,6 @@ impl OtgService {
|
||||
|
||||
let mut manager = OtgGadgetManager::with_descriptor(
|
||||
super::configfs::DEFAULT_GADGET_NAME,
|
||||
desired.max_endpoints,
|
||||
desired.descriptor.clone(),
|
||||
);
|
||||
|
||||
@@ -280,7 +449,7 @@ impl OtgService {
|
||||
}
|
||||
|
||||
let msd_func = if desired.msd_enabled {
|
||||
match manager.add_msd() {
|
||||
match manager.add_msd(desired.msd_lun_capacity) {
|
||||
Ok(func) => {
|
||||
debug!("MSD function added to gadget");
|
||||
Some(func)
|
||||
@@ -295,19 +464,61 @@ impl OtgService {
|
||||
None
|
||||
};
|
||||
|
||||
let network_func = if desired.network_enabled() {
|
||||
Some(manager.add_network(&desired.network).map_err(|e| {
|
||||
AppError::Internal(format!("Failed to add OTG network function: {e}"))
|
||||
})?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Err(e) = manager.setup() {
|
||||
let error = format!("Failed to setup gadget: {}", e);
|
||||
self.state.write().await.error = Some(error.clone());
|
||||
return Err(AppError::Internal(error));
|
||||
return Err(cleanup_manager_or_combine(&mut manager, error));
|
||||
}
|
||||
|
||||
if let Err(e) = manager.bind(&udc) {
|
||||
let error = format!("Failed to bind gadget to UDC {}: {}", udc, e);
|
||||
self.state.write().await.error = Some(error.clone());
|
||||
let _ = manager.cleanup();
|
||||
return Err(AppError::Internal(error));
|
||||
return Err(cleanup_manager_or_combine(&mut manager, error));
|
||||
}
|
||||
|
||||
let network_usb_interface = match network_func.as_ref() {
|
||||
Some(function) => match function.interface_name(manager.gadget_path()) {
|
||||
Ok(interface) => Some(interface),
|
||||
Err(error) => {
|
||||
let message = format!("Failed to resolve OTG network interface: {error}");
|
||||
self.state.write().await.error = Some(message.clone());
|
||||
return Err(cleanup_manager_or_combine(&mut manager, message));
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
let network_runtime = if let Some(ref usb_interface) = network_usb_interface {
|
||||
let requested = desired.network.bridge_interface.clone();
|
||||
let usb_interface = usb_interface.clone();
|
||||
let activation = tokio::task::spawn_blocking(move || {
|
||||
NetworkBridgeRuntime::activate(&requested, &usb_interface)
|
||||
})
|
||||
.await;
|
||||
match activation {
|
||||
Err(error) => {
|
||||
let message = format!("Bridge activation task failed: {error}");
|
||||
self.state.write().await.error = Some(message.clone());
|
||||
return Err(cleanup_manager_or_combine(&mut manager, message));
|
||||
}
|
||||
Ok(Ok(runtime)) => Some(runtime),
|
||||
Ok(Err(error)) => {
|
||||
let message = format!("Failed to activate OTG network bridge: {error}");
|
||||
self.state.write().await.error = Some(message.clone());
|
||||
return Err(cleanup_manager_or_combine(&mut manager, message));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(ref paths) = hid_paths {
|
||||
let device_paths = paths.existing_paths();
|
||||
if !device_paths.is_empty() && !wait_for_hid_devices(&device_paths, 2000).await {
|
||||
@@ -317,17 +528,19 @@ impl OtgService {
|
||||
|
||||
*self.manager.lock().await = Some(manager);
|
||||
*self.msd_function.write().await = msd_func;
|
||||
*self.network_bridge.lock().await = network_runtime.clone();
|
||||
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
state.gadget_active = true;
|
||||
state.hid_enabled = desired.hid_enabled();
|
||||
state.msd_enabled = desired.msd_enabled;
|
||||
state.msd_lun_capacity = desired.msd_lun_capacity;
|
||||
state.network = desired.network.clone();
|
||||
state.configured_udc = Some(udc);
|
||||
state.hid_paths = hid_paths;
|
||||
state.hid_functions = desired.hid_functions;
|
||||
state.keyboard_leds_enabled = desired.keyboard_leds;
|
||||
state.max_endpoints = desired.max_endpoints;
|
||||
state.descriptor = Some(desired.descriptor);
|
||||
state.error = None;
|
||||
}
|
||||
@@ -344,13 +557,22 @@ impl OtgService {
|
||||
*desired = OtgDesiredState::default();
|
||||
}
|
||||
|
||||
let mut manager = self.manager.lock().await;
|
||||
if let Some(mut m) = manager.take() {
|
||||
if let Err(e) = m.cleanup() {
|
||||
warn!("Error cleaning up gadget during shutdown: {}", e);
|
||||
}
|
||||
let network_runtime = { self.network_bridge.lock().await.as_ref().cloned() };
|
||||
if let Some(runtime) = network_runtime {
|
||||
tokio::task::spawn_blocking(move || runtime.deactivate())
|
||||
.await
|
||||
.map_err(|e| AppError::Internal(format!("Bridge cleanup task failed: {e}")))??;
|
||||
self.network_bridge.lock().await.take();
|
||||
}
|
||||
|
||||
let mut manager = self.manager.lock().await;
|
||||
if let Some(m) = manager.as_mut() {
|
||||
m.cleanup().map_err(|e| {
|
||||
AppError::Internal(format!("Failed to clean up gadget during shutdown: {e}"))
|
||||
})?;
|
||||
}
|
||||
manager.take();
|
||||
|
||||
*self.msd_function.write().await = None;
|
||||
{
|
||||
let mut state = self.state.write().await;
|
||||
@@ -362,6 +584,15 @@ impl OtgService {
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_manager_or_combine(manager: &mut OtgGadgetManager, primary: String) -> AppError {
|
||||
match manager.cleanup() {
|
||||
Ok(()) => AppError::Internal(primary),
|
||||
Err(cleanup_error) => AppError::Config(format!(
|
||||
"{primary}; gadget rollback failed: {cleanup_error}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OtgService {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
@@ -393,4 +624,60 @@ mod tests {
|
||||
let _service = OtgService::new();
|
||||
let _ = OtgService::is_available();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_starts_with_single_lun_capacity() {
|
||||
let service = OtgService::new();
|
||||
assert_eq!(service.desired.read().await.msd_lun_capacity, 1);
|
||||
assert_eq!(service.state.read().await.msd_lun_capacity, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_updates_preserve_runtime_lun_capacity() {
|
||||
let service = OtgService::new();
|
||||
service.desired.write().await.msd_lun_capacity = 8;
|
||||
|
||||
let desired = service
|
||||
.desired_from_config_preserving_runtime(
|
||||
&HidConfig::default(),
|
||||
&MsdConfig::default(),
|
||||
&OtgNetworkConfig::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(desired.msd_lun_capacity, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lun_capacity_participates_in_desired_state_equality() {
|
||||
let single = OtgDesiredState::default();
|
||||
let mut multi = single.clone();
|
||||
multi.msd_lun_capacity = 8;
|
||||
assert_ne!(single, multi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onecloud_full_composite_is_not_rejected_before_configfs() {
|
||||
let hid = HidConfig {
|
||||
backend: HidBackend::Otg,
|
||||
otg_udc: Some("c9040000.usb".to_string()),
|
||||
..HidConfig::default()
|
||||
};
|
||||
let msd = MsdConfig {
|
||||
enabled: true,
|
||||
..MsdConfig::default()
|
||||
};
|
||||
let network = OtgNetworkConfig {
|
||||
enabled: true,
|
||||
..OtgNetworkConfig::default()
|
||||
};
|
||||
|
||||
let desired = OtgDesiredState::from_config(&hid, &msd, &network).unwrap();
|
||||
|
||||
assert_eq!(desired.udc.as_deref(), Some("c9040000.usb"));
|
||||
assert_eq!(desired.hid_functions, Some(OtgHidFunctions::full()));
|
||||
assert!(desired.msd_enabled);
|
||||
assert!(desired.network_enabled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
//! Android Amlogic platform capabilities.
|
||||
|
||||
use super::{FeatureCapability, PlatformCapabilities, PlatformMode};
|
||||
|
||||
#[cfg(feature = "android")]
|
||||
#[allow(dead_code)]
|
||||
fn _keep_android_bionic_ifaddrs_shim_linked() {
|
||||
let _ = crate::platform::android_bionic::freeifaddrs
|
||||
as unsafe extern "C" fn(*mut crate::platform::android_bionic::ifaddrs);
|
||||
let _ = crate::platform::android_bionic::getifaddrs
|
||||
as unsafe extern "C" fn(*mut *mut crate::platform::android_bionic::ifaddrs) -> i32;
|
||||
}
|
||||
|
||||
pub fn capabilities() -> PlatformCapabilities {
|
||||
#[cfg(feature = "android")]
|
||||
_keep_android_bionic_ifaddrs_shim_linked();
|
||||
|
||||
PlatformCapabilities {
|
||||
mode: PlatformMode::AndroidAmlogic,
|
||||
mode_label: PlatformMode::AndroidAmlogic.label(),
|
||||
video_capture: FeatureCapability::available(["v4l2_uvc"])
|
||||
.with_selected_backend(Some("v4l2_uvc".to_string())),
|
||||
encoder: FeatureCapability::available(["ffmpeg_mediacodec_h264", "mjpeg"])
|
||||
.with_selected_backend(Some(
|
||||
if cfg!(feature = "android-mediacodec") {
|
||||
"ffmpeg_mediacodec_h264"
|
||||
} else {
|
||||
"mjpeg"
|
||||
}
|
||||
.to_string(),
|
||||
)),
|
||||
hid: FeatureCapability::available(["otg_configfs", "ch9329", "none"]),
|
||||
atx: FeatureCapability::available(["gpio", "usb_relay", "serial", "wol", "none"]),
|
||||
msd: FeatureCapability::available(["otg_configfs"]),
|
||||
otg: FeatureCapability::available(["configfs"]),
|
||||
audio: FeatureCapability::available(["alsa", "opus"])
|
||||
.with_selected_backend(Some("alsa".to_string())),
|
||||
rustdesk: FeatureCapability::available(["builtin"]),
|
||||
vnc: FeatureCapability::available(["builtin", "tight_jpeg", "h264"]),
|
||||
diagnostics: FeatureCapability::available(["android_linux"]),
|
||||
extensions: FeatureCapability::unsupported("unsupported on Android Amlogic v1"),
|
||||
service_installation: FeatureCapability::available(["android_foreground_service"]),
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
#![allow(clippy::missing_safety_doc)]
|
||||
|
||||
use std::ffi::CString;
|
||||
use std::mem::{size_of, zeroed};
|
||||
use std::os::raw::{c_char, c_int, c_uint, c_void};
|
||||
|
||||
#[repr(C)]
|
||||
pub struct ifaddrs {
|
||||
pub ifa_next: *mut ifaddrs,
|
||||
pub ifa_name: *mut c_char,
|
||||
pub ifa_flags: c_uint,
|
||||
pub ifa_addr: *mut libc::sockaddr,
|
||||
pub ifa_netmask: *mut libc::sockaddr,
|
||||
pub ifa_ifu: *mut libc::sockaddr,
|
||||
pub ifa_data: *mut c_void,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct AddrNode {
|
||||
ifa: ifaddrs,
|
||||
name: CString,
|
||||
addr: libc::sockaddr_in,
|
||||
next: *mut AddrNode,
|
||||
}
|
||||
|
||||
fn sockaddr_to_ipv4(addr: libc::sockaddr) -> Option<std::net::Ipv4Addr> {
|
||||
if addr.sa_family as c_int != libc::AF_INET {
|
||||
return None;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let sin = &*(&addr as *const libc::sockaddr as *const libc::sockaddr_in);
|
||||
Some(std::net::Ipv4Addr::from(u32::from_be(sin.sin_addr.s_addr)))
|
||||
}
|
||||
}
|
||||
|
||||
fn query_ipv4(iface_name: &str) -> Option<libc::sockaddr_in> {
|
||||
let name = CString::new(iface_name).ok()?;
|
||||
if name.as_bytes().len() >= libc::IFNAMSIZ {
|
||||
return None;
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let fd = libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0);
|
||||
if fd < 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut request: libc::ifreq = zeroed();
|
||||
std::ptr::copy_nonoverlapping(
|
||||
name.as_ptr(),
|
||||
request.ifr_name.as_mut_ptr(),
|
||||
name.as_bytes_with_nul().len(),
|
||||
);
|
||||
|
||||
let request_code = libc::SIOCGIFADDR.try_into().ok()?;
|
||||
let rc = libc::ioctl(fd, request_code, &mut request);
|
||||
libc::close(fd);
|
||||
if rc < 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let addr = request.ifr_ifru.ifru_addr;
|
||||
if addr.sa_family as c_int != libc::AF_INET {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut sin: libc::sockaddr_in = zeroed();
|
||||
std::ptr::copy_nonoverlapping(
|
||||
&addr as *const libc::sockaddr as *const u8,
|
||||
&mut sin as *mut libc::sockaddr_in as *mut u8,
|
||||
size_of::<libc::sockaddr_in>(),
|
||||
);
|
||||
Some(sin)
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn getifaddrs(addrs: *mut *mut ifaddrs) -> c_int {
|
||||
if addrs.is_null() {
|
||||
return -1;
|
||||
}
|
||||
*addrs = std::ptr::null_mut();
|
||||
|
||||
let net_dir = match std::fs::read_dir("/sys/class/net") {
|
||||
Ok(dir) => dir,
|
||||
Err(_) => return -1,
|
||||
};
|
||||
|
||||
let mut head: *mut AddrNode = std::ptr::null_mut();
|
||||
let mut tail: *mut AddrNode = std::ptr::null_mut();
|
||||
|
||||
for entry in net_dir.flatten() {
|
||||
let iface_name = match entry.file_name().into_string() {
|
||||
Ok(name) => name,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if iface_name == "lo" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let operstate_path = entry.path().join("operstate");
|
||||
let is_up = std::fs::read_to_string(&operstate_path)
|
||||
.map(|s| s.trim() == "up")
|
||||
.unwrap_or(false);
|
||||
if !is_up {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(addr) = query_ipv4(&iface_name) else {
|
||||
continue;
|
||||
};
|
||||
let ip = sockaddr_to_ipv4(unsafe {
|
||||
std::mem::transmute::<libc::sockaddr_in, libc::sockaddr>(addr)
|
||||
});
|
||||
if ip
|
||||
.map(|ip| ip.is_loopback() || ip.is_unspecified())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = match CString::new(iface_name) {
|
||||
Ok(name) => name,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let mut node = Box::new(AddrNode {
|
||||
ifa: ifaddrs {
|
||||
ifa_next: std::ptr::null_mut(),
|
||||
ifa_name: std::ptr::null_mut(),
|
||||
ifa_flags: 0,
|
||||
ifa_addr: std::ptr::null_mut(),
|
||||
ifa_netmask: std::ptr::null_mut(),
|
||||
ifa_ifu: std::ptr::null_mut(),
|
||||
ifa_data: std::ptr::null_mut(),
|
||||
},
|
||||
name,
|
||||
addr,
|
||||
next: std::ptr::null_mut(),
|
||||
});
|
||||
|
||||
node.ifa.ifa_name = node.name.as_ptr() as *mut c_char;
|
||||
node.ifa.ifa_addr = &mut node.addr as *mut libc::sockaddr_in as *mut libc::sockaddr;
|
||||
node.ifa.ifa_ifu = std::ptr::null_mut();
|
||||
node.ifa.ifa_netmask = std::ptr::null_mut();
|
||||
node.ifa.ifa_flags = (libc::IFF_UP | libc::IFF_RUNNING) as c_uint;
|
||||
|
||||
let raw = Box::into_raw(node);
|
||||
if head.is_null() {
|
||||
head = raw;
|
||||
} else {
|
||||
(*tail).next = raw;
|
||||
(*tail).ifa.ifa_next = raw as *mut ifaddrs;
|
||||
}
|
||||
tail = raw;
|
||||
}
|
||||
|
||||
*addrs = if head.is_null() {
|
||||
std::ptr::null_mut()
|
||||
} else {
|
||||
head as *mut ifaddrs
|
||||
};
|
||||
0
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn freeifaddrs(addrs: *mut ifaddrs) {
|
||||
let mut current = addrs as *mut AddrNode;
|
||||
while !current.is_null() {
|
||||
let next = (*current).next;
|
||||
drop(Box::from_raw(current));
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,13 @@ use serde::{Deserialize, Serialize};
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlatformMode {
|
||||
AndroidAmlogic,
|
||||
Linux,
|
||||
Windows,
|
||||
}
|
||||
|
||||
impl PlatformMode {
|
||||
pub const fn current() -> Self {
|
||||
if cfg!(feature = "android") {
|
||||
Self::AndroidAmlogic
|
||||
} else if cfg!(windows) {
|
||||
if cfg!(windows) {
|
||||
Self::Windows
|
||||
} else {
|
||||
Self::Linux
|
||||
@@ -23,7 +20,6 @@ impl PlatformMode {
|
||||
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::AndroidAmlogic => "Android Amlogic",
|
||||
Self::Linux => "Linux",
|
||||
Self::Windows => "Windows",
|
||||
}
|
||||
@@ -86,15 +82,11 @@ pub struct PlatformCapabilities {
|
||||
|
||||
impl PlatformCapabilities {
|
||||
pub fn current() -> Self {
|
||||
#[cfg(feature = "android")]
|
||||
{
|
||||
return crate::platform::android::capabilities();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return crate::platform::windows::capabilities();
|
||||
}
|
||||
#[cfg(all(unix, not(feature = "android")))]
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return crate::platform::linux::capabilities();
|
||||
}
|
||||
|
||||
@@ -1,72 +1,21 @@
|
||||
use crate::config::AppConfig;
|
||||
#[cfg(windows)]
|
||||
use crate::config::AtxDriverType;
|
||||
#[cfg(any(windows, all(unix, feature = "android")))]
|
||||
#[cfg(windows)]
|
||||
use crate::config::HidBackend;
|
||||
|
||||
pub fn apply(config: &mut AppConfig) {
|
||||
#[cfg(not(any(windows, all(unix, feature = "android"))))]
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let _ = config;
|
||||
}
|
||||
|
||||
#[cfg(all(unix, feature = "android"))]
|
||||
{
|
||||
apply_android(config);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
apply_windows(config);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, feature = "android"))]
|
||||
fn apply_android(config: &mut AppConfig) {
|
||||
let detected_udc = crate::otg::configfs::find_udc();
|
||||
if config
|
||||
.hid
|
||||
.otg_udc
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
{
|
||||
config.hid.otg_udc = detected_udc;
|
||||
}
|
||||
|
||||
let otg_available = config.hid.otg_udc.is_some();
|
||||
if !config.initialized && otg_available {
|
||||
config.hid.backend = HidBackend::Otg;
|
||||
} else if config.hid.backend == HidBackend::Ch9329
|
||||
&& config.hid.ch9329_port == "/dev/ttyUSB0"
|
||||
&& !std::path::Path::new(&config.hid.ch9329_port).exists()
|
||||
&& otg_available
|
||||
{
|
||||
config.hid.backend = HidBackend::Otg;
|
||||
}
|
||||
|
||||
if !config.initialized {
|
||||
config.audio.enabled = false;
|
||||
config.audio.device.clear();
|
||||
config.atx.enabled = false;
|
||||
config.rustdesk.enabled = false;
|
||||
config.rtsp.enabled = false;
|
||||
config.redfish.enabled = false;
|
||||
}
|
||||
|
||||
config
|
||||
.video
|
||||
.device
|
||||
.get_or_insert_with(|| "auto".to_string());
|
||||
config
|
||||
.video
|
||||
.format
|
||||
.get_or_insert_with(|| "MJPEG".to_string());
|
||||
config.web.bind_address = "0.0.0.0".to_string();
|
||||
config.web.bind_addresses = vec!["0.0.0.0".to_string()];
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn apply_windows(config: &mut AppConfig) {
|
||||
config.msd.enabled = false;
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
//! Platform selection and capability reporting.
|
||||
|
||||
#[cfg(feature = "android")]
|
||||
pub mod android;
|
||||
#[cfg(feature = "android")]
|
||||
pub mod android_bionic;
|
||||
pub mod capabilities;
|
||||
pub mod defaults;
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -80,3 +80,30 @@ fn decode_basic_auth(encoded: &str) -> Option<(String, String)> {
|
||||
}
|
||||
Some((username, password))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_service_discovery_and_session_creation_are_public() {
|
||||
assert!(is_redfish_public_endpoint("/v1/", &Method::GET));
|
||||
assert!(is_redfish_public_endpoint(
|
||||
"/v1/$metadata",
|
||||
&Method::GET
|
||||
));
|
||||
assert!(is_redfish_public_endpoint(
|
||||
"/v1/SessionService/Sessions",
|
||||
&Method::POST
|
||||
));
|
||||
|
||||
assert!(!is_redfish_public_endpoint(
|
||||
"/v1/Managers/1/VirtualMedia",
|
||||
&Method::GET
|
||||
));
|
||||
assert!(!is_redfish_public_endpoint(
|
||||
"/v1/Managers/1/VirtualMedia/1/Actions/VirtualMedia.EjectMedia",
|
||||
&Method::POST
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ async fn event_service() -> Json<EventService> {
|
||||
}
|
||||
|
||||
async fn event_service_sse(State(state): State<Arc<AppState>>) -> Response {
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::sse::{Event, Sse};
|
||||
|
||||
let mut device_info_rx = state.subscribe_device_info();
|
||||
|
||||
@@ -87,15 +87,25 @@ async fn event_service_sse(State(state): State<Arc<AppState>>) -> Response {
|
||||
};
|
||||
|
||||
Sse::new(Box::pin(stream))
|
||||
.keep_alive(
|
||||
KeepAlive::new()
|
||||
.interval(Duration::from_secs(30))
|
||||
.text(":\n"),
|
||||
)
|
||||
.keep_alive(redfish_keep_alive())
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn redfish_keep_alive() -> axum::response::sse::KeepAlive {
|
||||
axum::response::sse::KeepAlive::new().interval(Duration::from_secs(30))
|
||||
}
|
||||
|
||||
async fn event_submit_test() -> StatusCode {
|
||||
info!("Redfish: SubmitTestEvent received (no-op)");
|
||||
StatusCode::NO_CONTENT
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keep_alive_configuration_does_not_panic() {
|
||||
let _ = redfish_keep_alive();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ mod event;
|
||||
mod managers;
|
||||
mod session;
|
||||
mod systems;
|
||||
#[cfg(all(unix, not(feature = "android")))]
|
||||
#[cfg(unix)]
|
||||
mod virtual_media;
|
||||
|
||||
use axum::{
|
||||
@@ -194,15 +194,16 @@ pub fn create_redfish_router(state: Arc<AppState>) -> Router {
|
||||
.merge(managers::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,
|
||||
));
|
||||
.merge(event::router(state.clone()));
|
||||
|
||||
#[cfg(all(unix, not(feature = "android")))]
|
||||
#[cfg(unix)]
|
||||
let redfish_routes = redfish_routes.merge(virtual_media::router(state.clone()));
|
||||
|
||||
let redfish_routes = redfish_routes.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
redfish_auth_middleware,
|
||||
));
|
||||
|
||||
Router::new()
|
||||
.route("/redfish", get(service_root_redirect))
|
||||
.nest("/redfish/", redfish_routes)
|
||||
|
||||
@@ -5,12 +5,13 @@ use axum::{
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::super::schema::*;
|
||||
use super::{empty_collection, resource_not_found, service_unavailable, validate_id, RESOURCE_ID};
|
||||
use super::{empty_collection, resource_not_found, service_unavailable, validate_id};
|
||||
use crate::error::AppError;
|
||||
use crate::msd::{ImageInfo, ImageManager, MountedMedia, MountedMediaKind};
|
||||
use crate::state::AppState;
|
||||
|
||||
pub(crate) fn router(state: Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
@@ -34,21 +35,37 @@ pub(crate) fn router(state: Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn virtual_media_collection(Path(manager_id): Path<String>) -> Response {
|
||||
async fn virtual_media_collection(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(manager_id): Path<String>,
|
||||
) -> Response {
|
||||
if let Some(resp) = validate_id(&manager_id) {
|
||||
return resp;
|
||||
}
|
||||
|
||||
let capacity = {
|
||||
let guard = state.msd.read().await;
|
||||
let Some(msd) = guard.as_ref() else {
|
||||
return service_unavailable("MSD not available");
|
||||
};
|
||||
msd.state().await.disk_mode.capacity()
|
||||
};
|
||||
let members = (1..=capacity)
|
||||
.map(|slot| {
|
||||
odata_ref(&format!(
|
||||
"/redfish/v1/Managers/{}/VirtualMedia/{}",
|
||||
manager_id, slot
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
|
||||
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
|
||||
))],
|
||||
members,
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
@@ -60,56 +77,58 @@ async fn virtual_media_detail(
|
||||
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 (msd_state, lun) = {
|
||||
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),
|
||||
}
|
||||
let Some(msd) = guard.as_ref() else {
|
||||
return service_unavailable("MSD not available");
|
||||
};
|
||||
let msd_state = msd.state().await;
|
||||
let Some(lun) = parse_slot_id(&media_id, msd_state.disk_mode.capacity()) else {
|
||||
return resource_not_found();
|
||||
};
|
||||
(msd_state, lun)
|
||||
};
|
||||
let media = msd_state
|
||||
.mounted_media
|
||||
.iter()
|
||||
.find(|media| media.lun == lun);
|
||||
|
||||
Json(virtual_media_resource(&manager_id, &media_id, media)).into_response()
|
||||
}
|
||||
|
||||
fn virtual_media_resource(
|
||||
manager_id: &str,
|
||||
media_id: &str,
|
||||
media: Option<&MountedMedia>,
|
||||
) -> VirtualMedia {
|
||||
let inserted = media.is_some();
|
||||
let is_image = media.is_some_and(|media| media.kind == MountedMediaKind::Image);
|
||||
let media_types = match media {
|
||||
Some(media) if media.cdrom => vec!["CD".to_string(), "DVD".to_string()],
|
||||
Some(_) => vec!["USBStick".to_string()],
|
||||
None => vec!["CD".to_string(), "DVD".to_string(), "USBStick".to_string()],
|
||||
};
|
||||
|
||||
Json(VirtualMedia {
|
||||
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,
|
||||
id: media_id.to_string(),
|
||||
name: format!("Virtual Media Slot {}", media_id),
|
||||
description: "Virtual Media Slot".to_string(),
|
||||
media_types,
|
||||
connected_via: media.map(|_| if is_image { "URI" } else { "Applet" }.to_string()),
|
||||
inserted,
|
||||
image: media
|
||||
.filter(|_| is_image)
|
||||
.map(|media| format!("/api/msd/images/{}", media.id)),
|
||||
image_name: media.map(|media| media.name.clone()),
|
||||
write_protected: media.is_none_or(|media| media.read_only),
|
||||
transfer_method: is_image.then(|| "Upload".to_string()),
|
||||
transfer_protocol_type: None,
|
||||
status: if inserted {
|
||||
Status::enabled_ok()
|
||||
@@ -130,8 +149,7 @@ async fn virtual_media_detail(
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn virtual_media_insert(
|
||||
@@ -142,43 +160,53 @@ async fn virtual_media_insert(
|
||||
if let Some(resp) = validate_id(&manager_id) {
|
||||
return resp;
|
||||
}
|
||||
if media_id != RESOURCE_ID {
|
||||
return resource_not_found();
|
||||
|
||||
let lun = {
|
||||
let guard = state.msd.read().await;
|
||||
let Some(msd) = guard.as_ref() else {
|
||||
return service_unavailable("MSD not available");
|
||||
};
|
||||
let msd_state = msd.state().await;
|
||||
let Some(lun) = parse_slot_id(&media_id, msd_state.disk_mode.capacity()) else {
|
||||
return resource_not_found();
|
||||
};
|
||||
if msd_state.mounted_media.iter().any(|media| media.lun == lun) {
|
||||
return redfish_error(
|
||||
StatusCode::CONFLICT,
|
||||
"Virtual media slot is already occupied",
|
||||
);
|
||||
}
|
||||
lun
|
||||
};
|
||||
|
||||
if let Err(error) = validate_insert_request(&req) {
|
||||
return app_error_response(error);
|
||||
}
|
||||
let image = match resolve_image(&state, &req).await {
|
||||
Ok(image) => image,
|
||||
Err(error) => return app_error_response(error),
|
||||
};
|
||||
let (cdrom, read_only) = match mount_options(&req, &image.name) {
|
||||
Ok(options) => options,
|
||||
Err(error) => return app_error_response(error),
|
||||
};
|
||||
|
||||
let result = {
|
||||
let guard = state.msd.read().await;
|
||||
let msd = match guard.as_ref() {
|
||||
Some(msd) => msd,
|
||||
None => return service_unavailable("MSD not available"),
|
||||
let Some(msd) = guard.as_ref() else {
|
||||
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
|
||||
msd.mount_image_at_lun(&image, cdrom, read_only, lun).await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
info!("Redfish: VirtualMedia.InsertMedia executed");
|
||||
info!(slot = %media_id, image = %image.name, "Redfish virtual media inserted");
|
||||
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()
|
||||
Err(error) => {
|
||||
warn!(slot = %media_id, %error, "Redfish virtual media insert failed");
|
||||
app_error_response(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,40 +218,207 @@ async fn virtual_media_eject(
|
||||
if let Some(resp) = validate_id(&manager_id) {
|
||||
return resp;
|
||||
}
|
||||
if media_id != RESOURCE_ID {
|
||||
return resource_not_found();
|
||||
}
|
||||
|
||||
let lun = {
|
||||
let guard = state.msd.read().await;
|
||||
let Some(msd) = guard.as_ref() else {
|
||||
return service_unavailable("MSD not available");
|
||||
};
|
||||
let capacity = msd.state().await.disk_mode.capacity();
|
||||
let Some(lun) = parse_slot_id(&media_id, capacity) else {
|
||||
return resource_not_found();
|
||||
};
|
||||
lun
|
||||
};
|
||||
|
||||
let result = {
|
||||
let guard = state.msd.read().await;
|
||||
let msd = match guard.as_ref() {
|
||||
Some(msd) => msd,
|
||||
None => return service_unavailable("MSD not available"),
|
||||
let Some(msd) = guard.as_ref() else {
|
||||
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
|
||||
msd.unmount_lun(lun).await
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
info!("Redfish: VirtualMedia.EjectMedia executed");
|
||||
Ok(true) => {
|
||||
info!(slot = %media_id, "Redfish virtual media ejected");
|
||||
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()
|
||||
Ok(false) => redfish_error(
|
||||
StatusCode::CONFLICT,
|
||||
"No virtual media inserted in this slot",
|
||||
),
|
||||
Err(error) => {
|
||||
warn!(slot = %media_id, %error, "Redfish virtual media eject failed");
|
||||
app_error_response(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_slot_id(media_id: &str, capacity: u8) -> Option<u8> {
|
||||
media_id
|
||||
.parse::<u8>()
|
||||
.ok()?
|
||||
.checked_sub(1)
|
||||
.filter(|lun| *lun < capacity)
|
||||
}
|
||||
|
||||
fn validate_insert_request(req: &InsertMediaRequest) -> Result<(), AppError> {
|
||||
if req.inserted == Some(false) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Inserted=false is not supported".to_string(),
|
||||
));
|
||||
}
|
||||
if req
|
||||
.transfer_method
|
||||
.as_deref()
|
||||
.is_some_and(|method| !method.eq_ignore_ascii_case("Upload"))
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Only TransferMethod=Upload is supported".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount_options(req: &InsertMediaRequest, image_name: &str) -> Result<(bool, bool), AppError> {
|
||||
let requested_type = req
|
||||
.media_types
|
||||
.as_ref()
|
||||
.and_then(|types| types.first())
|
||||
.map(|value| value.as_str());
|
||||
let cdrom = match requested_type {
|
||||
Some(value) if value.eq_ignore_ascii_case("CD") || value.eq_ignore_ascii_case("DVD") => {
|
||||
true
|
||||
}
|
||||
Some(value) if value.eq_ignore_ascii_case("USBStick") => false,
|
||||
Some(value) => {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Unsupported virtual media type: {value}"
|
||||
)))
|
||||
}
|
||||
None => image_name.to_ascii_lowercase().ends_with(".iso"),
|
||||
};
|
||||
|
||||
Ok((cdrom, cdrom || req.write_protected.unwrap_or(true)))
|
||||
}
|
||||
|
||||
async fn resolve_image(
|
||||
state: &Arc<AppState>,
|
||||
req: &InsertMediaRequest,
|
||||
) -> Result<ImageInfo, AppError> {
|
||||
if req.user_name.is_some() || req.password.is_some() {
|
||||
return Err(AppError::BadRequest(
|
||||
"Authenticated virtual media URIs are not supported".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let config = state.config.get();
|
||||
let manager = ImageManager::new(config.msd.images_dir());
|
||||
if req.image.starts_with("http://") || req.image.starts_with("https://") {
|
||||
if let Some(protocol) = req.transfer_protocol_type.as_deref() {
|
||||
let expected = if req.image.starts_with("https://") {
|
||||
"HTTPS"
|
||||
} else {
|
||||
"HTTP"
|
||||
};
|
||||
if !protocol.eq_ignore_ascii_case(expected) {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"TransferProtocolType must be {expected} for this Image URI"
|
||||
)));
|
||||
}
|
||||
}
|
||||
return manager.download_from_url(&req.image, None, |_, _| {}).await;
|
||||
}
|
||||
if req.transfer_protocol_type.is_some() {
|
||||
return Err(AppError::BadRequest(
|
||||
"TransferProtocolType is only valid for remote Image URIs".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let image_id = req
|
||||
.image
|
||||
.strip_prefix("/api/msd/images/")
|
||||
.unwrap_or(&req.image)
|
||||
.split(['?', '#'])
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
if image_id.is_empty() || image_id.contains('/') {
|
||||
return Err(AppError::BadRequest(
|
||||
"Image must be an HTTP(S) URI, image ID, or /api/msd/images/{id}".to_string(),
|
||||
));
|
||||
}
|
||||
manager.get(image_id)
|
||||
}
|
||||
|
||||
fn app_error_response(error: AppError) -> Response {
|
||||
let status = match &error {
|
||||
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
redfish_error(status, &error.to_string())
|
||||
}
|
||||
|
||||
fn redfish_error(status: StatusCode, message: &str) -> Response {
|
||||
(status, Json(RedfishError::general_error(message))).into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request(image: &str) -> InsertMediaRequest {
|
||||
InsertMediaRequest {
|
||||
image: image.to_string(),
|
||||
write_protected: None,
|
||||
transfer_method: None,
|
||||
transfer_protocol_type: None,
|
||||
media_types: None,
|
||||
inserted: None,
|
||||
user_name: None,
|
||||
password: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_ids_map_to_zero_based_luns() {
|
||||
assert_eq!(parse_slot_id("1", 1), Some(0));
|
||||
assert_eq!(parse_slot_id("8", 8), Some(7));
|
||||
assert_eq!(parse_slot_id("0", 8), None);
|
||||
assert_eq!(parse_slot_id("2", 1), None);
|
||||
assert_eq!(parse_slot_id("invalid", 8), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mount_options_follow_media_type_and_redfish_write_protect_default() {
|
||||
assert_eq!(
|
||||
mount_options(&request("opaque-id"), "image.iso").unwrap(),
|
||||
(true, true)
|
||||
);
|
||||
assert_eq!(
|
||||
mount_options(&request("opaque-id"), "image.img").unwrap(),
|
||||
(false, true)
|
||||
);
|
||||
|
||||
let mut writable = request("image.img");
|
||||
writable.write_protected = Some(false);
|
||||
writable.media_types = Some(vec!["USBStick".to_string()]);
|
||||
assert_eq!(
|
||||
mount_options(&writable, "image.img").unwrap(),
|
||||
(false, false)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_transfer_and_non_inserted_media_are_rejected() {
|
||||
let mut stream = request("image.iso");
|
||||
stream.transfer_method = Some("Stream".to_string());
|
||||
assert!(validate_insert_request(&stream).is_err());
|
||||
|
||||
let mut not_inserted = request("image.iso");
|
||||
not_inserted.inserted = Some(false);
|
||||
assert!(validate_insert_request(¬_inserted).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ use rtsp_types as rtsp;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{broadcast, Mutex, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::config::RtspConfig;
|
||||
use crate::error::{AppError, Result};
|
||||
@@ -25,6 +27,8 @@ use super::types::RtspConnectionState;
|
||||
|
||||
pub use super::types::RtspServiceStatus;
|
||||
|
||||
const RTSP_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
pub struct RtspService {
|
||||
config: Arc<RwLock<RtspConfig>>,
|
||||
status: Arc<RwLock<RtspServiceStatus>>,
|
||||
@@ -74,18 +78,37 @@ impl RtspService {
|
||||
tracing::debug!("Failed to request keyframe on RTSP start: {}", err);
|
||||
}
|
||||
|
||||
let bind_addr = bind_socket_addr(&config.bind, config.port)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid RTSP bind address: {}", e)))?;
|
||||
let bind_addr = match bind_socket_addr(&config.bind, config.port) {
|
||||
Ok(addr) => addr,
|
||||
Err(err) => {
|
||||
let error = AppError::BadRequest(format!("Invalid RTSP bind address: {}", err));
|
||||
*self.status.write().await = RtspServiceStatus::Error(error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
let listener = bind_tcp_listener(bind_addr).map_err(|e| {
|
||||
AppError::Io(io::Error::new(e.kind(), format!("RTSP bind failed: {}", e)))
|
||||
})?;
|
||||
let listener = TcpListener::from_std(listener).map_err(|e| {
|
||||
AppError::Io(io::Error::new(
|
||||
e.kind(),
|
||||
format!("RTSP listener setup failed: {}", e),
|
||||
))
|
||||
})?;
|
||||
let listener = match bind_tcp_listener(bind_addr) {
|
||||
Ok(listener) => listener,
|
||||
Err(err) => {
|
||||
let error = AppError::Io(io::Error::new(
|
||||
err.kind(),
|
||||
format!("RTSP bind failed: {}", err),
|
||||
));
|
||||
*self.status.write().await = RtspServiceStatus::Error(error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let listener = match TcpListener::from_std(listener) {
|
||||
Ok(listener) => listener,
|
||||
Err(err) => {
|
||||
let error = AppError::Io(io::Error::new(
|
||||
err.kind(),
|
||||
format!("RTSP listener setup failed: {}", err),
|
||||
));
|
||||
*self.status.write().await = RtspServiceStatus::Error(error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
let service_config = self.config.clone();
|
||||
let video_manager = self.video_manager.clone();
|
||||
@@ -138,7 +161,7 @@ impl RtspService {
|
||||
pub async fn stop(&self) -> Result<()> {
|
||||
let _ = self.shutdown_tx.send(());
|
||||
if let Some(handle) = self.server_handle.lock().await.take() {
|
||||
handle.abort();
|
||||
wait_for_server_stop(handle).await;
|
||||
}
|
||||
|
||||
let mut client_handles = self.client_handles.lock().await;
|
||||
@@ -170,6 +193,42 @@ impl RtspService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_server_stop(mut handle: JoinHandle<()>) {
|
||||
match tokio::time::timeout(RTSP_SHUTDOWN_TIMEOUT, &mut handle).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) if err.is_cancelled() => {}
|
||||
Ok(Err(err)) => tracing::warn!("RTSP server task ended with error: {}", err),
|
||||
Err(_) => {
|
||||
tracing::warn!("Timed out waiting for RTSP server task to stop");
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn waiting_for_server_stop_releases_listener() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test listener");
|
||||
let bind_addr = listener.local_addr().expect("read test listener address");
|
||||
let (shutdown_tx, mut shutdown_rx) = broadcast::channel(1);
|
||||
let handle = tokio::spawn(async move {
|
||||
let _listener = listener;
|
||||
let _ = shutdown_rx.recv().await;
|
||||
});
|
||||
|
||||
shutdown_tx.send(()).expect("send shutdown signal");
|
||||
wait_for_server_stop(handle).await;
|
||||
|
||||
std::net::TcpListener::bind(bind_addr).expect("rebind released listener address");
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client(
|
||||
mut stream: TcpStream,
|
||||
peer: SocketAddr,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use bytes::Bytes;
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
use rtp::packet::Packet;
|
||||
use rtp::packetizer::Payloader;
|
||||
use rtsp_types as rtsp;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
use rtsp_types as rtsp;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
|
||||
@@ -1,789 +0,0 @@
|
||||
//! Android service runtime.
|
||||
//!
|
||||
//! Android is treated as a packaged Linux distribution: the APK/Java layer only
|
||||
//! starts and stops this runtime, while the Rust side builds the same AppState
|
||||
//! and Axum router used by the desktop service.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rustls::crypto::{ring, CryptoProvider};
|
||||
use tokio::runtime::Runtime;
|
||||
use tokio::sync::{broadcast, mpsc, oneshot};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use crate::atx::AtxController;
|
||||
use crate::audio::{AudioController, AudioControllerConfig, AudioQuality};
|
||||
use crate::auth::{SessionStore, UserStore};
|
||||
use crate::computer_use::ComputerUseManager;
|
||||
use crate::config::{self, AppConfig, ConfigStore};
|
||||
use crate::db::DatabasePool;
|
||||
use crate::events::EventBus;
|
||||
use crate::extensions::ExtensionManager;
|
||||
use crate::hid::{HidBackendType, HidController};
|
||||
use crate::msd::MsdController;
|
||||
use crate::otg::OtgService;
|
||||
use crate::rtsp::RtspService;
|
||||
use crate::rustdesk::RustDeskService;
|
||||
use crate::state::{AppState, ShutdownAction};
|
||||
use crate::stream_encoder::encoder_type_to_backend;
|
||||
use crate::update::UpdateService;
|
||||
use crate::utils::bind_tcp_listener;
|
||||
use crate::video::codec_constraints::{
|
||||
enforce_constraints_with_stream_manager, validate_third_party_codec_compatibility,
|
||||
StreamCodecConstraints,
|
||||
};
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
use crate::video::{Streamer, VideoStreamManager};
|
||||
use crate::vnc::VncService;
|
||||
use crate::web;
|
||||
use crate::webrtc::{config::WebRtcConfig, WebRtcStreamer, WebRtcStreamerConfig};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AndroidRuntimeConfig {
|
||||
pub data_dir: String,
|
||||
pub bind_address: String,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
struct RuntimeHandle {
|
||||
stop_tx: oneshot::Sender<()>,
|
||||
join: JoinHandle<()>,
|
||||
}
|
||||
|
||||
static HANDLE: OnceLock<Mutex<Option<RuntimeHandle>>> = OnceLock::new();
|
||||
|
||||
fn handle_slot() -> &'static Mutex<Option<RuntimeHandle>> {
|
||||
HANDLE.get_or_init(|| Mutex::new(None))
|
||||
}
|
||||
|
||||
pub fn start(config: AndroidRuntimeConfig) -> Result<String, String> {
|
||||
init_logging();
|
||||
|
||||
let mut slot = handle_slot()
|
||||
.lock()
|
||||
.map_err(|_| "runtime lock poisoned".to_string())?;
|
||||
if slot.is_some() {
|
||||
return Ok(status());
|
||||
}
|
||||
|
||||
let (stop_tx, stop_rx) = oneshot::channel();
|
||||
let config_for_thread = config.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("one-kvm-android-runtime".to_string())
|
||||
.spawn(move || {
|
||||
if let Err(err) = run_runtime(config_for_thread, stop_rx) {
|
||||
tracing::error!("One-KVM Android runtime exited: {}", err);
|
||||
}
|
||||
})
|
||||
.map_err(|err| format!("failed to spawn runtime: {err}"))?;
|
||||
|
||||
*slot = Some(RuntimeHandle { stop_tx, join });
|
||||
Ok(format!(
|
||||
"One-KVM Android runtime starting on http://{}:{}",
|
||||
config.bind_address, config.port
|
||||
))
|
||||
}
|
||||
|
||||
pub fn run_foreground(config: AndroidRuntimeConfig) -> Result<(), String> {
|
||||
init_logging();
|
||||
let (_stop_tx, stop_rx) = oneshot::channel();
|
||||
run_runtime(config, stop_rx)
|
||||
}
|
||||
|
||||
pub fn init_rustls_provider() {
|
||||
ensure_rustls_provider();
|
||||
}
|
||||
|
||||
pub fn stop() -> String {
|
||||
let handle = match handle_slot().lock() {
|
||||
Ok(mut slot) => slot.take(),
|
||||
Err(_) => return "runtime lock poisoned".to_string(),
|
||||
};
|
||||
|
||||
let Some(handle) = handle else {
|
||||
return "One-KVM Android runtime is not running".to_string();
|
||||
};
|
||||
|
||||
let _ = handle.stop_tx.send(());
|
||||
match handle.join.join() {
|
||||
Ok(()) => "One-KVM Android runtime stopped".to_string(),
|
||||
Err(_) => "One-KVM Android runtime stopped after panic".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status() -> String {
|
||||
match handle_slot().lock() {
|
||||
Ok(slot) if slot.is_some() => "One-KVM Android runtime running".to_string(),
|
||||
Ok(_) => "One-KVM Android runtime stopped".to_string(),
|
||||
Err(_) => "runtime lock poisoned".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_runtime(config: AndroidRuntimeConfig, stop_rx: oneshot::Receiver<()>) -> Result<(), String> {
|
||||
ensure_rustls_provider();
|
||||
let runtime = Runtime::new().map_err(|err| format!("failed to create tokio runtime: {err}"))?;
|
||||
runtime.block_on(async move { run_async(config, stop_rx).await })
|
||||
}
|
||||
|
||||
async fn run_async(
|
||||
config: AndroidRuntimeConfig,
|
||||
stop_rx: oneshot::Receiver<()>,
|
||||
) -> Result<(), String> {
|
||||
let (db, config_store, app_config) =
|
||||
load_runtime_config(&PathBuf::from(&config.data_dir), &config).await?;
|
||||
let (shutdown_tx, _) = broadcast::channel::<ShutdownAction>(1);
|
||||
let state = build_app_state(
|
||||
PathBuf::from(&config.data_dir),
|
||||
db,
|
||||
config_store,
|
||||
app_config,
|
||||
shutdown_tx.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let app = web::create_router(state.clone());
|
||||
let listener = bind_android_listener(&config.bind_address, config.port)?;
|
||||
let local_addr = listener
|
||||
.local_addr()
|
||||
.map_err(|err| format!("failed to get listener address: {err}"))?;
|
||||
tracing::info!(
|
||||
"Starting One-KVM desktop router on Android at http://{}",
|
||||
local_addr
|
||||
);
|
||||
|
||||
let listener = tokio::net::TcpListener::from_std(listener)
|
||||
.map_err(|err| format!("failed to create tokio listener: {err}"))?;
|
||||
let server = axum::serve(listener, app);
|
||||
|
||||
let shutdown_signal = {
|
||||
let mut shutdown_rx = shutdown_tx.subscribe();
|
||||
async move {
|
||||
tokio::select! {
|
||||
_ = stop_rx => {
|
||||
tracing::info!("Android stop request received");
|
||||
let _ = shutdown_tx.send(ShutdownAction::Exit);
|
||||
}
|
||||
request = shutdown_rx.recv() => {
|
||||
match request {
|
||||
Ok(action) => {
|
||||
tracing::info!("Android shutdown request received: {:?}", action);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("Android shutdown request channel closed: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
result = server => {
|
||||
if let Err(err) = result {
|
||||
tracing::error!("Android HTTP server error: {}", err);
|
||||
}
|
||||
}
|
||||
_ = shutdown_signal => {}
|
||||
}
|
||||
|
||||
cleanup(&state).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_runtime_config(
|
||||
data_dir: &Path,
|
||||
runtime_config: &AndroidRuntimeConfig,
|
||||
) -> Result<(DatabasePool, ConfigStore, AppConfig), String> {
|
||||
tokio::fs::create_dir_all(data_dir)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create data dir {}: {err}", data_dir.display()))?;
|
||||
|
||||
let db_path = data_dir.join("one-kvm.db");
|
||||
let db = DatabasePool::new(&db_path)
|
||||
.await
|
||||
.map_err(|err| format!("failed to open database {}: {err}", db_path.display()))?;
|
||||
db.init_schema()
|
||||
.await
|
||||
.map_err(|err| format!("failed to initialize database schema: {err}"))?;
|
||||
|
||||
let config_store = ConfigStore::new(db.clone_pool())
|
||||
.map_err(|err| format!("failed to create config store: {err}"))?;
|
||||
config_store
|
||||
.load()
|
||||
.await
|
||||
.map_err(|err| format!("failed to load config: {err}"))?;
|
||||
|
||||
let mut config = (*config_store.get()).clone();
|
||||
config.apply_platform_defaults();
|
||||
config.web.bind_address = runtime_config.bind_address.clone();
|
||||
config.web.bind_addresses = vec![runtime_config.bind_address.clone()];
|
||||
config.web.http_port = runtime_config.port;
|
||||
config.web.https_enabled = false;
|
||||
prepare_android_runtime_dirs(data_dir, &config_store, &mut config).await?;
|
||||
|
||||
if let Some(device) = config.video.device.as_deref() {
|
||||
if device == "auto" {
|
||||
config.video.device = None;
|
||||
}
|
||||
}
|
||||
|
||||
config_store
|
||||
.set(config.clone())
|
||||
.await
|
||||
.map_err(|err| format!("failed to persist Android runtime config: {err}"))?;
|
||||
|
||||
Ok((db, config_store, config))
|
||||
}
|
||||
|
||||
async fn prepare_android_runtime_dirs(
|
||||
data_dir: &Path,
|
||||
config_store: &ConfigStore,
|
||||
config: &mut AppConfig,
|
||||
) -> Result<(), String> {
|
||||
let mut updated = false;
|
||||
if config.msd.msd_dir.trim().is_empty() {
|
||||
config.msd.msd_dir = data_dir.join("msd").to_string_lossy().to_string();
|
||||
updated = true;
|
||||
} else if !PathBuf::from(&config.msd.msd_dir).is_absolute() {
|
||||
config.msd.msd_dir = data_dir
|
||||
.join(&config.msd.msd_dir)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
updated = true;
|
||||
}
|
||||
|
||||
let msd_dir = config.msd.msd_dir_path();
|
||||
tokio::fs::create_dir_all(msd_dir.join("images"))
|
||||
.await
|
||||
.map_err(|err| format!("failed to create Android MSD images dir: {err}"))?;
|
||||
tokio::fs::create_dir_all(msd_dir.join("ventoy"))
|
||||
.await
|
||||
.map_err(|err| format!("failed to create Android MSD ventoy dir: {err}"))?;
|
||||
|
||||
if updated {
|
||||
config_store
|
||||
.set(config.clone())
|
||||
.await
|
||||
.map_err(|err| format!("failed to persist Android MSD dir: {err}"))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn build_app_state(
|
||||
data_dir: PathBuf,
|
||||
db: DatabasePool,
|
||||
config_store: ConfigStore,
|
||||
config: AppConfig,
|
||||
shutdown_tx: broadcast::Sender<ShutdownAction>,
|
||||
) -> Result<Arc<AppState>, String> {
|
||||
let session_store = SessionStore::new(config.auth.session_timeout_secs as i64);
|
||||
let user_store = UserStore::new(db.clone_pool());
|
||||
let events = Arc::new(EventBus::new());
|
||||
|
||||
let (video_format, video_resolution) = parse_video_config(&config);
|
||||
let streamer = Streamer::new();
|
||||
streamer.set_event_bus(events.clone()).await;
|
||||
if let Some(ref device_path) = config.video.device {
|
||||
if let Err(err) = streamer
|
||||
.apply_video_config(
|
||||
device_path,
|
||||
video_format,
|
||||
video_resolution,
|
||||
config.video.fps,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Android video config failed, falling back to auto: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
let webrtc_streamer = WebRtcStreamer::with_config(WebRtcStreamerConfig {
|
||||
resolution: video_resolution,
|
||||
input_format: video_format,
|
||||
fps: config.video.fps,
|
||||
bitrate_preset: config.stream.bitrate_preset,
|
||||
encoder_backend: encoder_type_to_backend(config.stream.encoder.clone()),
|
||||
webrtc: build_webrtc_config(&config),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let hid_backend = match config.hid.backend {
|
||||
config::HidBackend::Otg => HidBackendType::Otg,
|
||||
config::HidBackend::Ch9329 => HidBackendType::Ch9329 {
|
||||
port: config.hid.ch9329_port.clone(),
|
||||
baud_rate: config.hid.ch9329_baudrate,
|
||||
hybrid_mouse: config.hid.ch9329_hybrid_mouse,
|
||||
},
|
||||
config::HidBackend::None => HidBackendType::None,
|
||||
};
|
||||
let otg_service = Arc::new(OtgService::new());
|
||||
if let Err(err) = otg_service.apply_config(&config.hid, &config.msd).await {
|
||||
tracing::warn!("Failed to apply Android OTG config: {}", err);
|
||||
}
|
||||
|
||||
let hid = Arc::new(HidController::new(hid_backend, Some(otg_service.clone())));
|
||||
hid.set_event_bus(events.clone()).await;
|
||||
if let Err(err) = hid.init().await {
|
||||
tracing::warn!("Failed to initialize Android HID backend: {}", err);
|
||||
}
|
||||
|
||||
let msd = if config.msd.enabled {
|
||||
let ventoy_resource_dir = data_dir.join("ventoy");
|
||||
if ventoy_resource_dir.exists() {
|
||||
if let Err(err) = ventoy_img::init_resources(&ventoy_resource_dir) {
|
||||
tracing::warn!("Failed to initialize Android Ventoy resources: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
let controller = MsdController::new(otg_service.clone(), config.msd.msd_dir_path());
|
||||
if let Err(err) = controller.init().await {
|
||||
tracing::warn!("Failed to initialize Android MSD controller: {}", err);
|
||||
None
|
||||
} else {
|
||||
controller.set_event_bus(events.clone()).await;
|
||||
Some(controller)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let atx = if config.atx.enabled {
|
||||
let controller = AtxController::new(config.atx.to_controller_config());
|
||||
if let Err(err) = controller.init().await {
|
||||
tracing::warn!("Failed to initialize Android ATX controller: {}", err);
|
||||
None
|
||||
} else {
|
||||
Some(controller)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let audio = {
|
||||
let audio_config = AudioControllerConfig {
|
||||
enabled: config.audio.enabled,
|
||||
device: config.audio.device.clone(),
|
||||
quality: config
|
||||
.audio
|
||||
.quality
|
||||
.parse::<AudioQuality>()
|
||||
.unwrap_or(AudioQuality::Balanced),
|
||||
};
|
||||
let controller = AudioController::new(audio_config);
|
||||
controller.set_event_bus(events.clone()).await;
|
||||
if config.audio.enabled {
|
||||
if let Err(err) = controller.start_streaming().await {
|
||||
tracing::warn!("Failed to start Android audio: {}", err);
|
||||
}
|
||||
}
|
||||
Arc::new(controller)
|
||||
};
|
||||
|
||||
let extensions = Arc::new(ExtensionManager::new());
|
||||
webrtc_streamer.set_hid_controller(hid.clone()).await;
|
||||
webrtc_streamer.set_audio_controller(audio.clone()).await;
|
||||
|
||||
let (device_path, actual_resolution, actual_format, actual_fps, jpeg_quality) =
|
||||
streamer.current_capture_config().await;
|
||||
webrtc_streamer
|
||||
.update_video_config(actual_resolution, actual_format, actual_fps)
|
||||
.await;
|
||||
if let Some(device_path) = device_path {
|
||||
let (subdev_path, bridge_kind, v4l2_driver) = streamer
|
||||
.current_device()
|
||||
.await
|
||||
.map(|device| {
|
||||
(
|
||||
device.subdev_path.clone(),
|
||||
device.bridge_kind.clone(),
|
||||
Some(device.driver.clone()),
|
||||
)
|
||||
})
|
||||
.unwrap_or((None, None, None));
|
||||
webrtc_streamer
|
||||
.set_capture_device(
|
||||
device_path,
|
||||
jpeg_quality,
|
||||
subdev_path,
|
||||
bridge_kind,
|
||||
v4l2_driver,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let stream_manager = VideoStreamManager::with_webrtc_streamer(
|
||||
streamer.clone(),
|
||||
webrtc_streamer.clone() as Arc<dyn crate::video::traits::VideoOutput>,
|
||||
);
|
||||
stream_manager.set_event_bus(events.clone()).await;
|
||||
stream_manager.set_config_store(config_store.clone()).await;
|
||||
{
|
||||
let stream_manager_weak = Arc::downgrade(&stream_manager);
|
||||
audio
|
||||
.set_recovered_callback(Arc::new(move || {
|
||||
if let Some(stream_manager) = stream_manager_weak.upgrade() {
|
||||
tokio::spawn(async move {
|
||||
stream_manager.reconnect_webrtc_audio_sources().await;
|
||||
});
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Err(err) = stream_manager
|
||||
.init_with_mode(config.stream.mode.clone())
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to initialize Android stream manager: {}", err);
|
||||
}
|
||||
|
||||
let third_party_codec_config_valid = match validate_third_party_codec_compatibility(&config) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Android third-party access codec configuration is invalid; RustDesk/VNC/RTSP will not start: {}",
|
||||
e
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let rustdesk = if third_party_codec_config_valid && config.rustdesk.is_valid() {
|
||||
Some(Arc::new(RustDeskService::new(
|
||||
config.rustdesk.clone(),
|
||||
stream_manager.clone(),
|
||||
hid.clone(),
|
||||
audio.clone(),
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let rtsp = if third_party_codec_config_valid && config.rtsp.enabled {
|
||||
Some(Arc::new(RtspService::new(
|
||||
config.rtsp.clone(),
|
||||
stream_manager.clone(),
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let vnc = if third_party_codec_config_valid && config.vnc.enabled {
|
||||
Some(Arc::new(VncService::new(
|
||||
config.vnc.clone(),
|
||||
stream_manager.clone(),
|
||||
hid.clone(),
|
||||
)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let update_service = Arc::new(UpdateService::new(data_dir.join("updates")));
|
||||
let computer_use = ComputerUseManager::new(config_store.clone(), hid.clone());
|
||||
let state = AppState::new(
|
||||
db,
|
||||
config_store.clone(),
|
||||
session_store,
|
||||
user_store,
|
||||
otg_service,
|
||||
stream_manager,
|
||||
webrtc_streamer,
|
||||
hid,
|
||||
computer_use,
|
||||
msd,
|
||||
atx,
|
||||
audio,
|
||||
rustdesk.clone(),
|
||||
vnc.clone(),
|
||||
rtsp.clone(),
|
||||
extensions.clone(),
|
||||
events.clone(),
|
||||
update_service,
|
||||
shutdown_tx,
|
||||
data_dir,
|
||||
);
|
||||
|
||||
extensions.set_event_bus(events.clone()).await;
|
||||
|
||||
if let Some(service) = rustdesk {
|
||||
if let Err(err) = service.start().await {
|
||||
tracing::warn!("Failed to start Android RustDesk service: {}", err);
|
||||
}
|
||||
}
|
||||
if let Some(service) = vnc {
|
||||
if let Err(err) = service.start().await {
|
||||
tracing::warn!("Failed to start Android VNC service: {}", err);
|
||||
}
|
||||
}
|
||||
if let Some(service) = rtsp {
|
||||
if let Err(err) = service.start().await {
|
||||
tracing::warn!("Failed to start Android RTSP service: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
let constraints = StreamCodecConstraints::from_config(&state.config.get());
|
||||
if let Err(err) =
|
||||
enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await
|
||||
{
|
||||
tracing::warn!("Failed to enforce Android stream constraints: {}", err);
|
||||
}
|
||||
|
||||
state.publish_device_info().await;
|
||||
spawn_device_info_broadcaster(state.clone(), events);
|
||||
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn build_webrtc_config(config: &AppConfig) -> WebRtcConfig {
|
||||
let mut webrtc = WebRtcConfig::default();
|
||||
if let Some(stun) = config
|
||||
.stream
|
||||
.stun_server
|
||||
.as_ref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
webrtc.stun_servers.push(stun.clone());
|
||||
}
|
||||
if let Some(turn) = config
|
||||
.stream
|
||||
.turn_server
|
||||
.as_ref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
webrtc
|
||||
.turn_servers
|
||||
.push(crate::webrtc::config::TurnServer::new(
|
||||
turn.clone(),
|
||||
config.stream.turn_username.clone().unwrap_or_default(),
|
||||
config.stream.turn_password.clone().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
webrtc
|
||||
}
|
||||
|
||||
fn parse_video_config(config: &AppConfig) -> (PixelFormat, Resolution) {
|
||||
let format = config
|
||||
.video
|
||||
.format
|
||||
.as_ref()
|
||||
.and_then(|value| value.parse::<PixelFormat>().ok())
|
||||
.unwrap_or(PixelFormat::Mjpeg);
|
||||
(
|
||||
format,
|
||||
Resolution::new(config.video.width, config.video.height),
|
||||
)
|
||||
}
|
||||
|
||||
fn bind_android_listener(bind_address: &str, port: u16) -> Result<std::net::TcpListener, String> {
|
||||
let ip = bind_address
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|err| format!("invalid Android bind address {bind_address}: {err}"))?;
|
||||
bind_tcp_listener(SocketAddr::new(ip, port))
|
||||
.map_err(|err| format!("failed to bind Android listener {bind_address}:{port}: {err}"))
|
||||
}
|
||||
|
||||
fn spawn_device_info_broadcaster(state: Arc<AppState>, events: Arc<EventBus>) {
|
||||
enum DeviceInfoTrigger {
|
||||
Event,
|
||||
Lagged { topic: &'static str, count: u64 },
|
||||
}
|
||||
|
||||
const DEVICE_INFO_TOPICS: &[&str] = &[
|
||||
"stream.state_changed",
|
||||
"stream.config_applied",
|
||||
"stream.mode_ready",
|
||||
];
|
||||
const DEBOUNCE_MS: u64 = 100;
|
||||
|
||||
let (trigger_tx, mut trigger_rx) = mpsc::unbounded_channel();
|
||||
for topic in DEVICE_INFO_TOPICS {
|
||||
let Some(mut rx) = events.subscribe_topic(topic) else {
|
||||
continue;
|
||||
};
|
||||
let trigger_tx = trigger_tx.clone();
|
||||
let topic_name = *topic;
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(_) => {
|
||||
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||
if trigger_tx
|
||||
.send(DeviceInfoTrigger::Lagged {
|
||||
topic: topic_name,
|
||||
count,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let mut dirty_rx = events.subscribe_device_info_dirty();
|
||||
let trigger_tx = trigger_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match dirty_rx.recv().await {
|
||||
Ok(()) => {
|
||||
if trigger_tx.send(DeviceInfoTrigger::Event).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
|
||||
if trigger_tx
|
||||
.send(DeviceInfoTrigger::Lagged {
|
||||
topic: "device_info_dirty",
|
||||
count,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut last_broadcast = Instant::now() - Duration::from_millis(DEBOUNCE_MS);
|
||||
let mut pending_broadcast = false;
|
||||
|
||||
loop {
|
||||
let recv_result = if pending_broadcast {
|
||||
let remaining =
|
||||
DEBOUNCE_MS.saturating_sub(last_broadcast.elapsed().as_millis() as u64);
|
||||
tokio::time::timeout(Duration::from_millis(remaining), trigger_rx.recv()).await
|
||||
} else {
|
||||
Ok(trigger_rx.recv().await)
|
||||
};
|
||||
|
||||
match recv_result {
|
||||
Ok(Some(DeviceInfoTrigger::Event)) => pending_broadcast = true,
|
||||
Ok(Some(DeviceInfoTrigger::Lagged { topic, count })) => {
|
||||
tracing::warn!(
|
||||
"Android device info broadcaster lagged by {} events on {}",
|
||||
count,
|
||||
topic
|
||||
);
|
||||
pending_broadcast = true;
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => {}
|
||||
}
|
||||
|
||||
if pending_broadcast && last_broadcast.elapsed() >= Duration::from_millis(DEBOUNCE_MS) {
|
||||
state.publish_device_info().await;
|
||||
last_broadcast = Instant::now();
|
||||
pending_broadcast = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn cleanup(state: &Arc<AppState>) {
|
||||
state.extensions.stop_all().await;
|
||||
|
||||
if let Some(service) = state.rustdesk.read().await.as_ref() {
|
||||
if let Err(err) = service.stop().await {
|
||||
tracing::warn!("Failed to stop Android RustDesk service: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(service) = state.vnc.read().await.as_ref() {
|
||||
if let Err(err) = service.stop().await {
|
||||
tracing::warn!("Failed to stop Android VNC service: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(service) = state.rtsp.read().await.as_ref() {
|
||||
if let Err(err) = service.stop().await {
|
||||
tracing::warn!("Failed to stop Android RTSP service: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = state.stream_manager.stop().await {
|
||||
tracing::warn!("Failed to stop Android stream manager: {}", err);
|
||||
}
|
||||
if let Err(err) = state.hid.shutdown().await {
|
||||
tracing::warn!("Failed to stop Android HID: {}", err);
|
||||
}
|
||||
if let Some(msd) = state.msd.write().await.as_mut() {
|
||||
if let Err(err) = msd.shutdown().await {
|
||||
tracing::warn!("Failed to stop Android MSD: {}", err);
|
||||
}
|
||||
}
|
||||
if let Err(err) = state.otg_service.shutdown().await {
|
||||
tracing::warn!("Failed to stop Android OTG: {}", err);
|
||||
}
|
||||
if let Some(atx) = state.atx.write().await.as_mut() {
|
||||
if let Err(err) = atx.shutdown().await {
|
||||
tracing::warn!("Failed to stop Android ATX: {}", err);
|
||||
}
|
||||
}
|
||||
if let Err(err) = state.audio.shutdown().await {
|
||||
tracing::warn!("Failed to stop Android audio: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
fn init_logging() {
|
||||
static INIT: OnceLock<()> = OnceLock::new();
|
||||
INIT.get_or_init(|| {
|
||||
let _ = tracing_log::LogTracer::init();
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "one_kvm=info,tower_http=info,webrtc_sctp=warn".into());
|
||||
let fmt_layer = tracing_subscriber::fmt::layer();
|
||||
if let Ok(path) = std::env::var("ONE_KVM_ANDROID_LOG_FILE") {
|
||||
match std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
{
|
||||
Ok(file) => {
|
||||
let file_layer = tracing_subscriber::fmt::layer()
|
||||
.with_ansi(false)
|
||||
.with_writer(Arc::new(file));
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt_layer)
|
||||
.with(file_layer)
|
||||
.try_init();
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("failed to open Android Rust log file {path}: {err}");
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt_layer)
|
||||
.try_init();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(fmt_layer)
|
||||
.try_init();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn ensure_rustls_provider() {
|
||||
static INIT: OnceLock<()> = OnceLock::new();
|
||||
INIT.get_or_init(|| {
|
||||
let _ = CryptoProvider::install_default(ring::default_provider());
|
||||
});
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
//! Runtime entry points for packaged service modes.
|
||||
|
||||
#[cfg(feature = "android")]
|
||||
pub mod android;
|
||||
@@ -130,14 +130,14 @@ impl RustDeskConfig {
|
||||
}
|
||||
|
||||
pub fn generate_device_id() -> String {
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
let mut rng = rand::rng();
|
||||
let id: u32 = rng.random_range(100_000_000..999_999_999);
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
pub fn generate_random_password() -> String {
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
let mut rng = rand::rng();
|
||||
(0..8)
|
||||
|
||||
@@ -32,6 +32,7 @@ use self::protocol::{make_local_addr, make_relay_response, make_request_relay};
|
||||
use self::rendezvous::{AddrMangle, RendezvousMediator, RendezvousStatus};
|
||||
|
||||
const RELAY_CONNECT_TIMEOUT_MS: u64 = 10_000;
|
||||
const SERVICE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ServiceStatus {
|
||||
@@ -121,6 +122,10 @@ impl RustDeskService {
|
||||
self.connection_manager.connection_count()
|
||||
}
|
||||
|
||||
pub fn is_listening(&self) -> bool {
|
||||
self.tcp_listener_handle.read().is_some()
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> anyhow::Result<()> {
|
||||
let config = self.config.read().clone();
|
||||
|
||||
@@ -169,7 +174,13 @@ impl RustDeskService {
|
||||
|
||||
*self.rendezvous.write() = Some(mediator.clone());
|
||||
|
||||
let (tcp_handles, listen_port) = self.start_tcp_listener_with_port().await?;
|
||||
let (tcp_handles, listen_port) = match self.start_tcp_listener_with_port().await {
|
||||
Ok(result) => result,
|
||||
Err(err) => {
|
||||
*self.status.write() = ServiceStatus::Error(err.to_string());
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
*self.tcp_listener_handle.write() = Some(tcp_handles);
|
||||
|
||||
mediator.set_listen_port(listen_port);
|
||||
@@ -383,13 +394,15 @@ impl RustDeskService {
|
||||
mediator.stop();
|
||||
}
|
||||
|
||||
if let Some(handle) = self.rendezvous_handle.write().take() {
|
||||
handle.abort();
|
||||
let rendezvous_handle = self.rendezvous_handle.write().take();
|
||||
if let Some(handle) = rendezvous_handle {
|
||||
wait_for_service_task(handle, "rendezvous").await;
|
||||
}
|
||||
|
||||
if let Some(handles) = self.tcp_listener_handle.write().take() {
|
||||
let tcp_listener_handles = self.tcp_listener_handle.write().take();
|
||||
if let Some(handles) = tcp_listener_handles {
|
||||
for handle in handles {
|
||||
handle.abort();
|
||||
wait_for_service_task(handle, "TCP listener").await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,6 +467,19 @@ impl RustDeskService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_service_task(mut handle: JoinHandle<()>, task_name: &str) {
|
||||
match tokio::time::timeout(SERVICE_SHUTDOWN_TIMEOUT, &mut handle).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) if err.is_cancelled() => {}
|
||||
Ok(Err(err)) => warn!("RustDesk {} task ended with error: {}", task_name, err),
|
||||
Err(_) => {
|
||||
warn!("Timed out waiting for RustDesk {} task to stop", task_name);
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rustdesk_relay_key(config: &Arc<RwLock<RustDeskConfig>>) -> String {
|
||||
config.read().relay_key.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
70
src/state.rs
70
src/state.rs
@@ -3,13 +3,13 @@ use tokio::sync::{broadcast, watch, Mutex, RwLock};
|
||||
|
||||
use crate::atx::AtxController;
|
||||
use crate::audio::AudioController;
|
||||
use crate::auth::{SessionStore, UserStore};
|
||||
use crate::auth::{SessionStore, TwoFactorService, UserStore};
|
||||
use crate::computer_use::ComputerUseManager;
|
||||
use crate::config::ConfigStore;
|
||||
use crate::db::DatabasePool;
|
||||
use crate::events::{
|
||||
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, LedState, MsdDeviceInfo, SystemEvent,
|
||||
TtydDeviceInfo, VideoDeviceInfo,
|
||||
AtxDeviceInfo, AudioDeviceInfo, EventBus, HidDeviceInfo, LedState, MsdDeviceInfo,
|
||||
MsdDeviceMediaInfo, SystemEvent, TtydDeviceInfo, VideoDeviceInfo,
|
||||
};
|
||||
use crate::extensions::{ExtensionId, ExtensionManager};
|
||||
use crate::hid::HidController;
|
||||
@@ -22,6 +22,7 @@ use crate::rustdesk::RustDeskService;
|
||||
use crate::update::UpdateService;
|
||||
use crate::video::VideoStreamManager;
|
||||
use crate::vnc::VncService;
|
||||
use crate::watchdog::WatchdogController;
|
||||
use crate::webrtc::WebRtcStreamer;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -34,6 +35,7 @@ pub struct ConfigApplyLocks {
|
||||
pub rustdesk: Arc<Mutex<()>>,
|
||||
pub vnc: Arc<Mutex<()>>,
|
||||
pub rtsp: Arc<Mutex<()>>,
|
||||
pub watchdog: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -53,6 +55,7 @@ impl ConfigApplyLocks {
|
||||
rustdesk: Arc::new(Mutex::new(())),
|
||||
vnc: Arc::new(Mutex::new(())),
|
||||
rtsp: Arc::new(Mutex::new(())),
|
||||
watchdog: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +66,7 @@ pub struct AppState {
|
||||
pub config: ConfigStore,
|
||||
pub sessions: SessionStore,
|
||||
pub users: UserStore,
|
||||
pub two_factor: TwoFactorService,
|
||||
#[cfg(unix)]
|
||||
pub otg_service: Arc<OtgService>,
|
||||
pub stream_manager: Arc<VideoStreamManager>,
|
||||
@@ -80,6 +84,7 @@ pub struct AppState {
|
||||
pub events: Arc<EventBus>,
|
||||
device_info_tx: watch::Sender<Option<SystemEvent>>,
|
||||
pub update: Arc<UpdateService>,
|
||||
pub watchdog: Arc<WatchdogController>,
|
||||
pub shutdown_tx: broadcast::Sender<ShutdownAction>,
|
||||
pub revoked_sessions: Arc<RwLock<VecDeque<String>>>,
|
||||
pub config_apply_locks: ConfigApplyLocks,
|
||||
@@ -93,6 +98,7 @@ impl AppState {
|
||||
config: ConfigStore,
|
||||
sessions: SessionStore,
|
||||
users: UserStore,
|
||||
two_factor: TwoFactorService,
|
||||
#[cfg(unix)] otg_service: Arc<OtgService>,
|
||||
stream_manager: Arc<VideoStreamManager>,
|
||||
webrtc: Arc<WebRtcStreamer>,
|
||||
@@ -117,6 +123,7 @@ impl AppState {
|
||||
config,
|
||||
sessions,
|
||||
users,
|
||||
two_factor,
|
||||
#[cfg(unix)]
|
||||
otg_service,
|
||||
stream_manager,
|
||||
@@ -134,6 +141,7 @@ impl AppState {
|
||||
events,
|
||||
device_info_tx,
|
||||
update,
|
||||
watchdog: Arc::new(WatchdogController::new()),
|
||||
shutdown_tx,
|
||||
revoked_sessions: Arc::new(RwLock::new(VecDeque::new())),
|
||||
config_apply_locks: ConfigApplyLocks::new(),
|
||||
@@ -145,6 +153,33 @@ impl AppState {
|
||||
&self.data_dir
|
||||
}
|
||||
|
||||
pub async fn runtime_third_party_config(&self) -> crate::config::AppConfig {
|
||||
let mut config = self.config.get().as_ref().clone();
|
||||
|
||||
config.rustdesk.enabled = self
|
||||
.rustdesk
|
||||
.read()
|
||||
.await
|
||||
.as_ref()
|
||||
.is_some_and(|service| service.is_listening());
|
||||
config.vnc.enabled = match self.vnc.read().await.as_ref() {
|
||||
Some(service) => matches!(
|
||||
service.status().await,
|
||||
crate::vnc::VncServiceStatus::Starting | crate::vnc::VncServiceStatus::Running
|
||||
),
|
||||
None => false,
|
||||
};
|
||||
config.rtsp.enabled = match self.rtsp.read().await.as_ref() {
|
||||
Some(service) => matches!(
|
||||
service.status().await,
|
||||
crate::rtsp::RtspServiceStatus::Starting | crate::rtsp::RtspServiceStatus::Running
|
||||
),
|
||||
None => false,
|
||||
};
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
pub fn subscribe_device_info(&self) -> watch::Receiver<Option<SystemEvent>> {
|
||||
self.device_info_tx.subscribe()
|
||||
}
|
||||
@@ -231,16 +266,33 @@ impl AppState {
|
||||
|
||||
let state = msd.state().await;
|
||||
let error = msd.monitor().error_message().await;
|
||||
let mounted_media = state
|
||||
.mounted_media
|
||||
.iter()
|
||||
.map(|media| MsdDeviceMediaInfo {
|
||||
id: media.id.clone(),
|
||||
kind: match media.kind {
|
||||
crate::msd::MountedMediaKind::Drive => "drive",
|
||||
crate::msd::MountedMediaKind::Image => "image",
|
||||
}
|
||||
.to_string(),
|
||||
name: media.name.clone(),
|
||||
cdrom: media.cdrom,
|
||||
read_only: media.read_only,
|
||||
size: media.size,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Some(MsdDeviceInfo {
|
||||
available: state.available,
|
||||
mode: match state.mode {
|
||||
crate::msd::MsdMode::None => "none",
|
||||
crate::msd::MsdMode::Image => "image",
|
||||
crate::msd::MsdMode::Drive => "drive",
|
||||
disk_mode: match state.disk_mode {
|
||||
crate::msd::DiskMode::Single => "single",
|
||||
crate::msd::DiskMode::Multi => "multi",
|
||||
}
|
||||
.to_string(),
|
||||
connected: state.connected,
|
||||
image_id: state.current_image.map(|img| img.id),
|
||||
slot_capacity: state.disk_mode.capacity(),
|
||||
mounted_count: state.mounted_media.len() as u8,
|
||||
mounted_media,
|
||||
usb_reenumerating: state.usb_reenumerating,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -175,9 +175,7 @@ impl MjpegStreamHandler {
|
||||
}
|
||||
#[cfg(not(feature = "desktop"))]
|
||||
{
|
||||
warn!(
|
||||
"Dropping non-JPEG frame for MJPEG stream on Android; native encoder is not wired yet"
|
||||
);
|
||||
warn!("Dropping non-JPEG frame because this build has no JPEG encoder");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -552,7 +553,12 @@ async fn compute_file_sha256(path: &Path) -> Result<String> {
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
let digest = hasher.finalize();
|
||||
let mut checksum = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
write!(&mut checksum, "{byte:02x}").expect("writing to a String cannot fail");
|
||||
}
|
||||
Ok(checksum)
|
||||
}
|
||||
|
||||
fn normalize_sha256(input: &str) -> Option<String> {
|
||||
@@ -578,3 +584,20 @@ fn current_target_triple() -> Result<String> {
|
||||
};
|
||||
Ok(triple.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::compute_file_sha256;
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_sha256_is_lowercase_hex() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("payload");
|
||||
tokio::fs::write(&path, b"one-kvm").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
compute_file_sha256(&path).await.unwrap(),
|
||||
"f62202e0a47f1ebb56427006019524c680f15d769d70479b9dbc35f550e86e5e"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
pub mod fs;
|
||||
pub mod host;
|
||||
#[cfg(all(unix, not(target_os = "android")))]
|
||||
#[cfg(unix)]
|
||||
pub mod net;
|
||||
#[cfg(any(not(unix), target_os = "android"))]
|
||||
#[cfg(not(unix))]
|
||||
#[path = "net_disabled.rs"]
|
||||
pub mod net;
|
||||
pub mod serial;
|
||||
|
||||
@@ -376,7 +376,7 @@ impl CaptureStream {
|
||||
pub fn next_into(&mut self, dst: &mut Vec<u8>) -> io::Result<CaptureMeta> {
|
||||
self.wait_ready()?;
|
||||
|
||||
let dqbuf: V4l2Buffer = ioctl::dqbuf(&self.fd, self.queue)
|
||||
let dqbuf: V4l2Buffer = ioctl::dqbuf(&self.fd, self.queue, MemoryType::Mmap)
|
||||
.map_err(|e| io::Error::other(format!("dqbuf failed: {}", e)))?;
|
||||
let index = dqbuf.as_v4l2_buffer().index as usize;
|
||||
let sequence = dqbuf.as_v4l2_buffer().sequence as u64;
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
//! Android FFmpeg/MediaCodec encoder glue.
|
||||
|
||||
use bytes::Bytes;
|
||||
use hwcodec::common::{Quality, RateControl};
|
||||
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
|
||||
pub struct AndroidMediaCodecH264Encoder {
|
||||
inner: HwEncoder,
|
||||
resolution: Resolution,
|
||||
input_format: PixelFormat,
|
||||
bitrate_kbps: u32,
|
||||
}
|
||||
|
||||
impl AndroidMediaCodecH264Encoder {
|
||||
pub fn new(
|
||||
resolution: Resolution,
|
||||
input_format: PixelFormat,
|
||||
fps: u32,
|
||||
bitrate_kbps: u32,
|
||||
) -> Result<Self> {
|
||||
let pixfmt = match input_format {
|
||||
PixelFormat::Nv12 => resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||
PixelFormat::Yuv420 => {
|
||||
resolve_pixel_format("yuv420p", AVPixelFormat::AV_PIX_FMT_YUV420P)
|
||||
}
|
||||
other => {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"FFmpeg h264_mediacodec accepts NV12/YUV420P memory frames; {other} requires conversion first"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
let ctx = EncodeContext {
|
||||
name: "h264_mediacodec".to_string(),
|
||||
mc_name: None,
|
||||
width: resolution.width as i32,
|
||||
height: resolution.height as i32,
|
||||
pixfmt,
|
||||
align: 1,
|
||||
fps: fps.max(1) as i32,
|
||||
gop: fps.max(1) as i32,
|
||||
rc: RateControl::RC_CBR,
|
||||
quality: Quality::Quality_Low,
|
||||
kbs: bitrate_kbps.max(1) as i32,
|
||||
q: 23,
|
||||
thread_count: 1,
|
||||
};
|
||||
|
||||
let inner = HwEncoder::new(ctx).map_err(|_| {
|
||||
AppError::VideoError("Failed to create FFmpeg h264_mediacodec encoder".to_string())
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
inner,
|
||||
resolution,
|
||||
input_format,
|
||||
bitrate_kbps: bitrate_kbps.max(1),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<AndroidH264Packet>> {
|
||||
let min_len = self
|
||||
.input_format
|
||||
.frame_size(self.resolution)
|
||||
.ok_or_else(|| AppError::VideoError("MediaCodec input must be raw YUV".to_string()))?;
|
||||
if data.len() < min_len {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"MediaCodec {} frame too small: {} < {}",
|
||||
self.input_format,
|
||||
data.len(),
|
||||
min_len
|
||||
)));
|
||||
}
|
||||
|
||||
let packets = self
|
||||
.inner
|
||||
.encode_bytes(data, pts_ms)
|
||||
.map_err(|err| AppError::VideoError(format!("h264_mediacodec encode failed: {err}")))?;
|
||||
|
||||
Ok(packets
|
||||
.into_iter()
|
||||
.map(|packet| AndroidH264Packet {
|
||||
data: packet.data,
|
||||
pts: packet.pts,
|
||||
key_frame: packet.key == 1,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||
self.inner
|
||||
.set_bitrate(bitrate_kbps.max(1) as i32)
|
||||
.map_err(|_| AppError::VideoError("Failed to set MediaCodec bitrate".to_string()))?;
|
||||
self.bitrate_kbps = bitrate_kbps.max(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn request_keyframe(&mut self) {
|
||||
self.inner.request_keyframe();
|
||||
}
|
||||
|
||||
pub fn codec_name(&self) -> &str {
|
||||
"h264_mediacodec"
|
||||
}
|
||||
|
||||
pub fn input_format(&self) -> PixelFormat {
|
||||
self.input_format
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for AndroidMediaCodecH264Encoder {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AndroidH264Packet {
|
||||
pub data: Bytes,
|
||||
pub pts: i64,
|
||||
pub key_frame: bool,
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
//! Android FFmpeg/MediaCodec MJPEG decoder glue.
|
||||
|
||||
use hwcodec::ffmpeg::AVPixelFormat;
|
||||
use hwcodec::ffmpeg_ram::decode::{DecodeContext, Decoder};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::video::codec::convert::Nv12Converter;
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
|
||||
pub struct AndroidMediaCodecMjpegDecoder {
|
||||
decoder: Decoder,
|
||||
resolution: Resolution,
|
||||
nv12_converter: Option<Nv12Converter>,
|
||||
last_output_format: Option<PixelFormat>,
|
||||
pending_frames: u32,
|
||||
}
|
||||
|
||||
impl AndroidMediaCodecMjpegDecoder {
|
||||
pub fn new(resolution: Resolution) -> Result<Self> {
|
||||
let ctx = DecodeContext {
|
||||
name: "mjpeg_mediacodec".to_string(),
|
||||
width: resolution.width as i32,
|
||||
height: resolution.height as i32,
|
||||
sw_pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
|
||||
thread_count: 1,
|
||||
};
|
||||
let decoder = Decoder::new(ctx).map_err(|_| {
|
||||
AppError::VideoError("Failed to create FFmpeg mjpeg_mediacodec decoder".to_string())
|
||||
})?;
|
||||
Ok(Self {
|
||||
decoder,
|
||||
resolution,
|
||||
nv12_converter: None,
|
||||
last_output_format: None,
|
||||
pending_frames: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_to_nv12(&mut self, mjpeg: &[u8]) -> Result<Vec<u8>> {
|
||||
let frames = match self.decoder.decode(mjpeg) {
|
||||
Ok(frames) => frames,
|
||||
Err(err) if err == -11 => {
|
||||
self.pending_frames += 1;
|
||||
if self.pending_frames <= 3 {
|
||||
return Err(AppError::VideoError(
|
||||
"mjpeg_mediacodec decode needs more input".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(AppError::VideoError(
|
||||
"mjpeg_mediacodec decoder did not output after 3 frames".to_string(),
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"mjpeg_mediacodec decode failed: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if frames.is_empty() {
|
||||
self.pending_frames += 1;
|
||||
if self.pending_frames <= 3 {
|
||||
return Err(AppError::VideoError(
|
||||
"mjpeg_mediacodec decode needs more input".to_string(),
|
||||
));
|
||||
}
|
||||
return Err(AppError::VideoError(
|
||||
"mjpeg_mediacodec decoder did not output after 3 frames".to_string(),
|
||||
));
|
||||
}
|
||||
self.pending_frames = 0;
|
||||
if frames.len() > 1 {
|
||||
warn!(
|
||||
"mjpeg_mediacodec decode returned {} frames, using last",
|
||||
frames.len()
|
||||
);
|
||||
}
|
||||
|
||||
let frame = frames.pop().ok_or_else(|| {
|
||||
AppError::VideoError("mjpeg_mediacodec decode returned empty".to_string())
|
||||
})?;
|
||||
|
||||
if frame.width as u32 != self.resolution.width
|
||||
|| frame.height as u32 != self.resolution.height
|
||||
{
|
||||
warn!(
|
||||
"mjpeg_mediacodec output size {}x{} differs from expected {}x{}",
|
||||
frame.width, frame.height, self.resolution.width, self.resolution.height
|
||||
);
|
||||
}
|
||||
|
||||
let output_format = pixel_format_from_av(frame.pixfmt).ok_or_else(|| {
|
||||
AppError::VideoError(format!(
|
||||
"mjpeg_mediacodec output pixfmt {:?} is not supported",
|
||||
frame.pixfmt
|
||||
))
|
||||
})?;
|
||||
|
||||
if self.last_output_format != Some(output_format) {
|
||||
info!("mjpeg_mediacodec output format: {}", output_format);
|
||||
self.last_output_format = Some(output_format);
|
||||
}
|
||||
|
||||
match output_format {
|
||||
PixelFormat::Nv12 => Ok(frame.data),
|
||||
PixelFormat::Nv21 => {
|
||||
let converter = self
|
||||
.nv12_converter
|
||||
.get_or_insert_with(|| Nv12Converter::nv21_to_nv12(self.resolution));
|
||||
Ok(converter.convert(&frame.data)?.to_vec())
|
||||
}
|
||||
PixelFormat::Yuv420 => {
|
||||
let converter = self
|
||||
.nv12_converter
|
||||
.get_or_insert_with(|| Nv12Converter::yuv420_to_nv12(self.resolution));
|
||||
Ok(converter.convert(&frame.data)?.to_vec())
|
||||
}
|
||||
other => Err(AppError::VideoError(format!(
|
||||
"mjpeg_mediacodec output {} cannot be converted to NV12",
|
||||
other
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel_format_from_av(format: AVPixelFormat) -> Option<PixelFormat> {
|
||||
match format {
|
||||
AVPixelFormat::AV_PIX_FMT_NV12 => Some(PixelFormat::Nv12),
|
||||
AVPixelFormat::AV_PIX_FMT_NV21 => Some(PixelFormat::Nv21),
|
||||
AVPixelFormat::AV_PIX_FMT_YUV420P | AVPixelFormat::AV_PIX_FMT_YUVJ420P => {
|
||||
Some(PixelFormat::Yuv420)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
unsafe impl Send for AndroidMediaCodecMjpegDecoder {}
|
||||
@@ -48,8 +48,6 @@ pub enum H264EncoderType {
|
||||
Rkmpp,
|
||||
/// V4L2 M2M (ARM generic) - requires hwcodec extension
|
||||
V4l2M2m,
|
||||
/// Android MediaCodec via FFmpeg
|
||||
MediaCodec,
|
||||
/// Software encoding (libx264/openh264)
|
||||
Software,
|
||||
/// No encoder available
|
||||
@@ -66,7 +64,6 @@ impl std::fmt::Display for H264EncoderType {
|
||||
H264EncoderType::Vaapi => write!(f, "VAAPI"),
|
||||
H264EncoderType::Rkmpp => write!(f, "RKMPP"),
|
||||
H264EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
||||
H264EncoderType::MediaCodec => write!(f, "MediaCodec"),
|
||||
H264EncoderType::Software => write!(f, "Software"),
|
||||
H264EncoderType::None => write!(f, "None"),
|
||||
}
|
||||
@@ -83,7 +80,6 @@ impl From<EncoderBackend> for H264EncoderType {
|
||||
EncoderBackend::Vaapi => H264EncoderType::Vaapi,
|
||||
EncoderBackend::Rkmpp => H264EncoderType::Rkmpp,
|
||||
EncoderBackend::V4l2m2m => H264EncoderType::V4l2M2m,
|
||||
EncoderBackend::MediaCodec => H264EncoderType::MediaCodec,
|
||||
EncoderBackend::Software => H264EncoderType::Software,
|
||||
}
|
||||
}
|
||||
@@ -196,7 +192,6 @@ pub fn get_available_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
||||
|
||||
let ctx = EncodeContext {
|
||||
name: String::new(),
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt: resolve_pixel_format("yuv420p", AVPixelFormat::AV_PIX_FMT_YUV420P),
|
||||
@@ -296,7 +291,6 @@ impl H264Encoder {
|
||||
|
||||
let ctx = EncodeContext {
|
||||
name: codec_name.to_string(),
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt,
|
||||
|
||||
@@ -45,8 +45,6 @@ pub enum H265EncoderType {
|
||||
Rkmpp,
|
||||
/// V4L2 M2M (ARM generic)
|
||||
V4l2M2m,
|
||||
/// Android MediaCodec via FFmpeg
|
||||
MediaCodec,
|
||||
/// Software encoder (libx265)
|
||||
Software,
|
||||
/// No encoder available
|
||||
@@ -63,7 +61,6 @@ impl std::fmt::Display for H265EncoderType {
|
||||
H265EncoderType::Vaapi => write!(f, "VAAPI"),
|
||||
H265EncoderType::Rkmpp => write!(f, "RKMPP"),
|
||||
H265EncoderType::V4l2M2m => write!(f, "V4L2 M2M"),
|
||||
H265EncoderType::MediaCodec => write!(f, "MediaCodec"),
|
||||
H265EncoderType::Software => write!(f, "Software"),
|
||||
H265EncoderType::None => write!(f, "None"),
|
||||
}
|
||||
@@ -79,7 +76,6 @@ impl From<EncoderBackend> for H265EncoderType {
|
||||
EncoderBackend::Vaapi => H265EncoderType::Vaapi,
|
||||
EncoderBackend::Rkmpp => H265EncoderType::Rkmpp,
|
||||
EncoderBackend::V4l2m2m => H265EncoderType::V4l2M2m,
|
||||
EncoderBackend::MediaCodec => H265EncoderType::MediaCodec,
|
||||
EncoderBackend::Software => H265EncoderType::Software,
|
||||
}
|
||||
}
|
||||
@@ -199,7 +195,6 @@ pub fn get_available_h265_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
||||
|
||||
let ctx = EncodeContext {
|
||||
name: String::new(),
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||
@@ -373,7 +368,6 @@ impl H265Encoder {
|
||||
|
||||
let ctx = EncodeContext {
|
||||
name: codec_name.to_string(),
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt,
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
use hwcodec::common::DataFormat;
|
||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
pub mod android_mediacodec;
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
pub mod android_mjpeg;
|
||||
pub mod convert;
|
||||
|
||||
pub mod h264;
|
||||
@@ -23,10 +19,6 @@ pub mod vp9;
|
||||
#[cfg(all(feature = "desktop", any(target_arch = "aarch64", target_arch = "arm")))]
|
||||
pub mod mjpeg_rkmpp;
|
||||
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
pub use android_mediacodec::{AndroidH264Packet, AndroidMediaCodecH264Encoder};
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
pub use android_mjpeg::AndroidMediaCodecMjpegDecoder;
|
||||
pub use convert::{MjpegToNv12Decoder, PixelConverter, Yuv420pBuffer};
|
||||
pub use h264::{H264Config, H264Encoder, H264EncoderType, H264InputFormat};
|
||||
pub use h265::{H265Config, H265Encoder, H265EncoderType, H265InputFormat};
|
||||
|
||||
@@ -96,8 +96,6 @@ pub enum EncoderBackend {
|
||||
Rkmpp,
|
||||
/// V4L2 Memory-to-Memory (ARM)
|
||||
V4l2m2m,
|
||||
/// Android MediaCodec via FFmpeg
|
||||
MediaCodec,
|
||||
/// Software encoding (libx264, libx265, libvpx)
|
||||
Software,
|
||||
}
|
||||
@@ -117,8 +115,6 @@ impl EncoderBackend {
|
||||
EncoderBackend::Rkmpp
|
||||
} else if name.contains("v4l2m2m") {
|
||||
EncoderBackend::V4l2m2m
|
||||
} else if name.contains("mediacodec") {
|
||||
EncoderBackend::MediaCodec
|
||||
} else {
|
||||
EncoderBackend::Software
|
||||
}
|
||||
@@ -138,7 +134,6 @@ impl EncoderBackend {
|
||||
EncoderBackend::Amf => "AMF",
|
||||
EncoderBackend::Rkmpp => "RKMPP",
|
||||
EncoderBackend::V4l2m2m => "V4L2 M2M",
|
||||
EncoderBackend::MediaCodec => "MediaCodec",
|
||||
EncoderBackend::Software => "Software",
|
||||
}
|
||||
}
|
||||
@@ -153,7 +148,6 @@ impl EncoderBackend {
|
||||
"amf" => Some(EncoderBackend::Amf),
|
||||
"rkmpp" => Some(EncoderBackend::Rkmpp),
|
||||
"v4l2m2m" | "v4l2" => Some(EncoderBackend::V4l2m2m),
|
||||
"mediacodec" | "android-mediacodec" => Some(EncoderBackend::MediaCodec),
|
||||
"software" | "cpu" => Some(EncoderBackend::Software),
|
||||
_ => None,
|
||||
}
|
||||
@@ -261,8 +255,8 @@ impl EncoderRegistry {
|
||||
let codec_name = match format {
|
||||
VideoEncoderType::H264 => "libx264",
|
||||
VideoEncoderType::H265 => "libx265",
|
||||
VideoEncoderType::VP8 => "libvpx",
|
||||
VideoEncoderType::VP9 => "libvpx-vp9",
|
||||
VideoEncoderType::VP8 => "libvpx_vp8",
|
||||
VideoEncoderType::VP9 => "libvpx_vp9",
|
||||
};
|
||||
|
||||
encoders.push(AvailableEncoder {
|
||||
@@ -309,10 +303,9 @@ impl EncoderRegistry {
|
||||
self.encoders.clear();
|
||||
self.detection_resolution = (width, height);
|
||||
|
||||
// Create test context for encoder detection
|
||||
// Create test context for encoder detection.
|
||||
let ctx = EncodeContext {
|
||||
name: String::new(),
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||
@@ -332,7 +325,6 @@ impl EncoderRegistry {
|
||||
ctx.clone(),
|
||||
Duration::from_millis(DETECT_TIMEOUT_MS),
|
||||
);
|
||||
|
||||
info!("Found {} encoders from hwcodec", all_encoders.len());
|
||||
|
||||
for codec_info in &all_encoders {
|
||||
|
||||
@@ -2,8 +2,6 @@ use serde::Serialize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
use super::AndroidMediaCodecH264Encoder;
|
||||
use super::{
|
||||
EncoderRegistry, H264Config, H264Encoder, H265Config, H265Encoder, VP8Config, VP8Encoder,
|
||||
VP9Config, VP9Encoder, VideoEncoderType,
|
||||
@@ -237,32 +235,6 @@ fn run_smoke_test(
|
||||
}
|
||||
|
||||
fn run_h264_smoke_test(resolution: Resolution, codec_name_ffmpeg: &str) -> Result<()> {
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
if codec_name_ffmpeg == "h264_mediacodec" {
|
||||
let mut encoder = AndroidMediaCodecH264Encoder::new(
|
||||
resolution,
|
||||
PixelFormat::Nv12,
|
||||
30,
|
||||
bitrate_kbps_for_resolution(resolution),
|
||||
)?;
|
||||
encoder.request_keyframe();
|
||||
let frame = build_nv12_test_frame(
|
||||
resolution,
|
||||
PixelFormat::Nv12.frame_size(resolution).unwrap_or(0),
|
||||
);
|
||||
|
||||
for sequence in 0..SELF_CHECK_FRAME_ATTEMPTS {
|
||||
let frames = encoder.encode_raw(&frame, pts_ms(sequence))?;
|
||||
if frames.iter().any(|frame| !frame.data.is_empty()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
return Err(AppError::VideoError(
|
||||
"Encoder produced no output after multiple frames".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut encoder = H264Encoder::with_codec(
|
||||
H264Config::low_latency(resolution, bitrate_kbps_for_resolution(resolution)),
|
||||
codec_name_ffmpeg,
|
||||
|
||||
@@ -130,7 +130,6 @@ pub fn get_available_vp8_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
||||
|
||||
let ctx = EncodeContext {
|
||||
name: String::new(),
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||
@@ -271,7 +270,6 @@ impl VP8Encoder {
|
||||
|
||||
let ctx = EncodeContext {
|
||||
name: codec_name.to_string(),
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt,
|
||||
|
||||
@@ -130,7 +130,6 @@ pub fn get_available_vp9_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
||||
|
||||
let ctx = EncodeContext {
|
||||
name: String::new(),
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||
@@ -271,7 +270,6 @@ impl VP9Encoder {
|
||||
|
||||
let ctx = EncodeContext {
|
||||
name: codec_name.to_string(),
|
||||
mc_name: None,
|
||||
width: width as i32,
|
||||
height: height as i32,
|
||||
pixfmt,
|
||||
|
||||
@@ -963,19 +963,6 @@ pub fn enumerate_devices() -> Result<Vec<VideoDeviceInfo>> {
|
||||
// for a single MIPI CSI pipeline. Keep only the highest-priority node per
|
||||
// (driver, bus_info) group so users see one device instead of ~11.
|
||||
dedup_platform_subdevices(&mut devices);
|
||||
devices.retain(|device| {
|
||||
let hide = should_hide_android_platform_node(device);
|
||||
if hide {
|
||||
debug!(
|
||||
"Hiding Android platform video node: {} ({}) {}",
|
||||
device.name,
|
||||
device.driver,
|
||||
device.path.display()
|
||||
);
|
||||
}
|
||||
!hide
|
||||
});
|
||||
|
||||
info!("Found {} video capture devices", devices.len());
|
||||
Ok(devices)
|
||||
}
|
||||
@@ -1055,33 +1042,6 @@ fn dedup_platform_subdevices(devices: &mut Vec<VideoDeviceInfo>) {
|
||||
});
|
||||
}
|
||||
|
||||
fn should_hide_android_platform_node(device: &VideoDeviceInfo) -> bool {
|
||||
if !cfg!(feature = "android") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let driver = device.driver.to_ascii_lowercase();
|
||||
let name = device.name.to_ascii_lowercase();
|
||||
let card = device.card.to_ascii_lowercase();
|
||||
let usb_device = driver == "uvcvideo" || device.bus_info.starts_with("usb-");
|
||||
let known_bridge =
|
||||
driver.contains("rkcif") || driver.contains("rk_hdmirx") || driver.contains("tc358743");
|
||||
if usb_device || known_bridge {
|
||||
return false;
|
||||
}
|
||||
|
||||
matches!(
|
||||
driver.as_str(),
|
||||
"ionvideo" | "amlvideo" | "amlvideo2" | "videosync"
|
||||
) || matches!(
|
||||
name.as_str(),
|
||||
"ionvideo" | "amlvideo" | "amlvideo2" | "videosync"
|
||||
) || matches!(
|
||||
card.as_str(),
|
||||
"ionvideo" | "amlvideo" | "amlvideo2" | "videosync"
|
||||
)
|
||||
}
|
||||
|
||||
/// rkcif registers many `/dev/video*` queues; probing all in parallel can
|
||||
/// contend and time out. Keep one node per board (lowest `videoN`).
|
||||
fn collapse_rkcif_probe_candidates(candidates: &mut Vec<PathBuf>) {
|
||||
@@ -1185,20 +1145,6 @@ fn sysfs_maybe_capture(path: &Path) -> bool {
|
||||
.to_lowercase();
|
||||
let driver = extract_uevent_value(&uevent, "driver");
|
||||
|
||||
if cfg!(feature = "android") {
|
||||
let platform_skip = ["ionvideo", "amlvideo", "amlvideo2", "videosync"];
|
||||
let driver_skip = driver
|
||||
.as_ref()
|
||||
.is_some_and(|driver| platform_skip.iter().any(|hint| driver == hint));
|
||||
if driver_skip || platform_skip.iter().any(|hint| sysfs_name == *hint) {
|
||||
debug!(
|
||||
"Skipping Android platform video node {:?}: {}",
|
||||
path, sysfs_name
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let mut maybe_capture = false;
|
||||
let capture_hints = [
|
||||
"capture",
|
||||
|
||||
@@ -8,21 +8,19 @@ pub mod codec_constraints;
|
||||
pub mod device;
|
||||
pub mod format;
|
||||
pub mod frame;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod pipeline;
|
||||
pub mod signal;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod stream_manager;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod streamer;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod traits;
|
||||
#[cfg(any(feature = "android", feature = "desktop"))]
|
||||
#[cfg(feature = "desktop")]
|
||||
pub mod types;
|
||||
|
||||
pub use capture::{CaptureMeta, CaptureStream};
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
pub use codec::{AndroidH264Packet, AndroidMediaCodecH264Encoder};
|
||||
pub use codec::{H264Encoder, H264EncoderType, JpegEncoder, PixelConverter, Yuv420pBuffer};
|
||||
pub use device::{VideoDevice, VideoDeviceInfo};
|
||||
pub use format::PixelFormat;
|
||||
|
||||
@@ -6,16 +6,9 @@ use crate::video::codec::registry::{EncoderBackend, EncoderRegistry, VideoEncode
|
||||
use crate::video::codec::traits::EncoderConfig;
|
||||
use crate::video::codec::vp8::{VP8Config, VP8Encoder};
|
||||
use crate::video::codec::vp9::{VP9Config, VP9Encoder};
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
use crate::video::codec::AndroidMediaCodecH264Encoder;
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
use crate::video::codec::AndroidMediaCodecMjpegDecoder;
|
||||
use crate::video::format::{PixelFormat, Resolution};
|
||||
use bytes::Bytes;
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
use hwcodec::ffmpeg_hw::{
|
||||
last_error_message as ffmpeg_hw_last_error, HwMjpegH26xConfig, HwMjpegH26xPipeline,
|
||||
};
|
||||
@@ -29,15 +22,9 @@ pub(super) struct EncoderThreadState {
|
||||
pub(super) nv12_converter: Option<Nv12Converter>,
|
||||
pub(super) yuv420p_converter: Option<PixelConverter>,
|
||||
pub(super) encoder_needs_yuv420p: bool,
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
pub(super) ffmpeg_hw_pipeline: Option<HwMjpegH26xPipeline>,
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
pub(super) ffmpeg_hw_enabled: bool,
|
||||
pub(super) fps: u32,
|
||||
pub(super) codec: VideoEncoderType,
|
||||
@@ -129,35 +116,6 @@ impl VideoEncoderTrait for H265EncoderWrapper {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
struct AndroidMediaCodecH264EncoderWrapper(AndroidMediaCodecH264Encoder);
|
||||
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
impl VideoEncoderTrait for AndroidMediaCodecH264EncoderWrapper {
|
||||
fn encode_raw(&mut self, data: &[u8], pts_ms: i64) -> Result<Vec<EncodedFrame>> {
|
||||
let frames = self.0.encode_raw(data, pts_ms)?;
|
||||
Ok(frames
|
||||
.into_iter()
|
||||
.map(|f| EncodedFrame {
|
||||
data: f.data,
|
||||
key: if f.key_frame { 1 } else { 0 },
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn set_bitrate(&mut self, bitrate_kbps: u32) -> Result<()> {
|
||||
self.0.set_bitrate(bitrate_kbps)
|
||||
}
|
||||
|
||||
fn codec_name(&self) -> &str {
|
||||
self.0.codec_name()
|
||||
}
|
||||
|
||||
fn request_keyframe(&mut self) {
|
||||
self.0.request_keyframe()
|
||||
}
|
||||
}
|
||||
|
||||
struct VP8EncoderWrapper(VP8Encoder);
|
||||
|
||||
impl VideoEncoderTrait for VP8EncoderWrapper {
|
||||
@@ -209,50 +167,12 @@ impl VideoEncoderTrait for VP9EncoderWrapper {
|
||||
}
|
||||
|
||||
pub(super) enum MjpegDecoderKind {
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
AndroidMediaCodec {
|
||||
decoder: AndroidMediaCodecMjpegDecoder,
|
||||
fallback: Box<MjpegDecoderKind>,
|
||||
fallback_active: bool,
|
||||
output: Vec<u8>,
|
||||
},
|
||||
Libyuv {
|
||||
decoder: MjpegToNv12Decoder,
|
||||
},
|
||||
Libyuv { decoder: MjpegToNv12Decoder },
|
||||
}
|
||||
|
||||
impl MjpegDecoderKind {
|
||||
pub(super) fn decode(&mut self, data: &[u8]) -> Result<&[u8]> {
|
||||
match self {
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
MjpegDecoderKind::AndroidMediaCodec {
|
||||
decoder,
|
||||
fallback,
|
||||
fallback_active,
|
||||
output,
|
||||
} => {
|
||||
if !*fallback_active {
|
||||
match decoder.decode_to_nv12(data) {
|
||||
Ok(decoded) => {
|
||||
*output = decoded;
|
||||
return Ok(output.as_slice());
|
||||
}
|
||||
Err(AppError::VideoError(message))
|
||||
if message.contains("needs more input") =>
|
||||
{
|
||||
return Err(AppError::VideoError(message));
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Android MediaCodec MJPEG decode failed; falling back to libyuv MJPEG->NV12: {}",
|
||||
err
|
||||
);
|
||||
*fallback_active = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
fallback.decode(data)
|
||||
}
|
||||
MjpegDecoderKind::Libyuv { decoder } => decoder.decode(data),
|
||||
}
|
||||
}
|
||||
@@ -265,40 +185,6 @@ fn libyuv_mjpeg_decoder(resolution: Resolution) -> MjpegDecoderKind {
|
||||
}
|
||||
|
||||
fn create_mjpeg_decoder(resolution: Resolution) -> Result<(MjpegDecoderKind, PixelFormat)> {
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
{
|
||||
if std::env::var_os("ONE_KVM_ANDROID_MJPEG_MEDIACODEC").is_none() {
|
||||
info!("MJPEG input detected, using libyuv decoder (MJPEG -> NV12)");
|
||||
return Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12));
|
||||
}
|
||||
|
||||
info!("MJPEG input detected, trying Android MediaCodec decoder (MJPEG -> NV12)");
|
||||
match AndroidMediaCodecMjpegDecoder::new(resolution) {
|
||||
Ok(decoder) => {
|
||||
info!("Using Android MediaCodec MJPEG decoder");
|
||||
return Ok((
|
||||
MjpegDecoderKind::AndroidMediaCodec {
|
||||
decoder,
|
||||
fallback: Box::new(libyuv_mjpeg_decoder(resolution)),
|
||||
fallback_active: false,
|
||||
output: Vec::with_capacity(
|
||||
PixelFormat::Nv12
|
||||
.frame_size(resolution)
|
||||
.unwrap_or((resolution.width * resolution.height * 3 / 2) as usize),
|
||||
),
|
||||
},
|
||||
PixelFormat::Nv12,
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"Android MediaCodec MJPEG decoder unavailable; using libyuv MJPEG->NV12: {}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!("MJPEG input detected, using libyuv decoder (MJPEG -> NV12)");
|
||||
Ok((libyuv_mjpeg_decoder(resolution), PixelFormat::Nv12))
|
||||
}
|
||||
@@ -400,15 +286,9 @@ pub(super) fn build_encoder_state(
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
let is_rkmpp_encoder = selected_codec_name.contains("rkmpp");
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
if needs_mjpeg_decode
|
||||
&& is_rkmpp_encoder
|
||||
&& matches!(
|
||||
@@ -448,15 +328,9 @@ pub(super) fn build_encoder_state(
|
||||
nv12_converter: None,
|
||||
yuv420p_converter: None,
|
||||
encoder_needs_yuv420p: false,
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
ffmpeg_hw_pipeline: Some(pipeline),
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
ffmpeg_hw_enabled: true,
|
||||
fps: config.fps,
|
||||
codec: config.output_codec,
|
||||
@@ -495,40 +369,7 @@ pub(super) fn build_encoder_state(
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "android-mediacodec")]
|
||||
{
|
||||
if codec_name == "h264_mediacodec" {
|
||||
info!(
|
||||
"Creating Android MediaCodec H264 encoder for {:?} input",
|
||||
input_format
|
||||
);
|
||||
let pixel_format = match input_format {
|
||||
H264InputFormat::Nv12 => PixelFormat::Nv12,
|
||||
H264InputFormat::Yuv420p => PixelFormat::Yuv420,
|
||||
other => {
|
||||
return Err(AppError::VideoError(format!(
|
||||
"Android MediaCodec H264 does not support {:?} direct input",
|
||||
other
|
||||
)));
|
||||
}
|
||||
};
|
||||
let encoder = AndroidMediaCodecH264Encoder::new(
|
||||
config.resolution,
|
||||
pixel_format,
|
||||
config.fps,
|
||||
config.bitrate_kbps(),
|
||||
)?;
|
||||
info!("Created Android MediaCodec H264 encoder");
|
||||
Box::new(AndroidMediaCodecH264EncoderWrapper(encoder))
|
||||
} else {
|
||||
create_h264_encoder(config, input_format, &codec_name)?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "android-mediacodec"))]
|
||||
{
|
||||
create_h264_encoder(config, input_format, &codec_name)?
|
||||
}
|
||||
create_h264_encoder(config, input_format, &codec_name)?
|
||||
}
|
||||
VideoEncoderType::H265 => {
|
||||
let codec_name = selected_codec_name.clone();
|
||||
@@ -622,11 +463,6 @@ pub(super) fn build_encoder_state(
|
||||
pipeline_input_format,
|
||||
PixelFormat::Nv12 | PixelFormat::Nv16 | PixelFormat::Nv21 | PixelFormat::Yuv420
|
||||
)
|
||||
} else if codec_name.contains("mediacodec") {
|
||||
matches!(
|
||||
pipeline_input_format,
|
||||
PixelFormat::Nv12 | PixelFormat::Yuv420
|
||||
)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -676,15 +512,9 @@ pub(super) fn build_encoder_state(
|
||||
nv12_converter,
|
||||
yuv420p_converter,
|
||||
encoder_needs_yuv420p: needs_yuv420p,
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
ffmpeg_hw_pipeline: None,
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
ffmpeg_hw_enabled: false,
|
||||
fps: config.fps,
|
||||
codec: config.output_codec,
|
||||
@@ -708,12 +538,6 @@ fn h264_direct_input_format(
|
||||
PixelFormat::Nv24 => Some(H264InputFormat::Nv24),
|
||||
_ => None,
|
||||
}
|
||||
} else if codec_name.contains("mediacodec") {
|
||||
match input_format {
|
||||
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
|
||||
PixelFormat::Yuv420 => Some(H264InputFormat::Yuv420p),
|
||||
_ => None,
|
||||
}
|
||||
} else if codec_name.contains("libx264") {
|
||||
match input_format {
|
||||
PixelFormat::Nv12 => Some(H264InputFormat::Nv12),
|
||||
|
||||
@@ -61,10 +61,7 @@ use crate::video::signal::SignalStatus;
|
||||
|
||||
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
|
||||
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
use hwcodec::ffmpeg_hw::last_error_message as ffmpeg_hw_last_error;
|
||||
|
||||
/// Encoded video frame for distribution
|
||||
@@ -484,15 +481,9 @@ impl SharedVideoPipeline {
|
||||
fn apply_cmd(&self, state: &mut EncoderThreadState, cmd: PipelineCmd) -> Result<()> {
|
||||
match cmd {
|
||||
PipelineCmd::SetBitrate { bitrate_kbps, gop } => {
|
||||
#[cfg(any(
|
||||
not(any(target_arch = "aarch64", target_arch = "arm")),
|
||||
target_os = "android"
|
||||
))]
|
||||
#[cfg(not(any(target_arch = "aarch64", target_arch = "arm")))]
|
||||
let _ = gop;
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
if state.ffmpeg_hw_enabled {
|
||||
if let Some(ref mut pipeline) = state.ffmpeg_hw_pipeline {
|
||||
pipeline
|
||||
@@ -659,7 +650,7 @@ impl SharedVideoPipeline {
|
||||
*guard = Some(cmd_tx);
|
||||
}
|
||||
|
||||
// Encoder loop uses a dedicated OS thread because FFmpeg/MediaCodec work is synchronous.
|
||||
// Encoder loop uses a dedicated OS thread because FFmpeg work is synchronous.
|
||||
{
|
||||
let pipeline = pipeline.clone();
|
||||
let latest_frame = latest_frame.clone();
|
||||
@@ -1289,10 +1280,7 @@ impl SharedVideoPipeline {
|
||||
current_ts_us.saturating_sub(start_ts_us) / 1000
|
||||
};
|
||||
|
||||
#[cfg(all(
|
||||
any(target_arch = "aarch64", target_arch = "arm"),
|
||||
not(target_os = "android")
|
||||
))]
|
||||
#[cfg(any(target_arch = "aarch64", target_arch = "arm"))]
|
||||
if state.ffmpeg_hw_enabled {
|
||||
if input_format != PixelFormat::Mjpeg {
|
||||
return Err(AppError::VideoError(
|
||||
|
||||
@@ -100,6 +100,8 @@ pub struct VideoStreamManager {
|
||||
events: RwLock<Option<Arc<EventBus>>>,
|
||||
/// Configuration store
|
||||
config_store: RwLock<Option<ConfigStore>>,
|
||||
/// Codec constraints derived from services that are actually running.
|
||||
runtime_codec_constraints: RwLock<Option<StreamCodecConstraints>>,
|
||||
/// Mode switching lock to prevent concurrent switch requests
|
||||
switching: AtomicBool,
|
||||
/// Current mode switch transaction ID (set while switching=true)
|
||||
@@ -118,6 +120,7 @@ impl VideoStreamManager {
|
||||
webrtc_streamer,
|
||||
events: RwLock::new(None),
|
||||
config_store: RwLock::new(None),
|
||||
runtime_codec_constraints: RwLock::new(None),
|
||||
switching: AtomicBool::new(false),
|
||||
transition_id: RwLock::new(None),
|
||||
})
|
||||
@@ -144,8 +147,16 @@ impl VideoStreamManager {
|
||||
*self.config_store.write().await = Some(config);
|
||||
}
|
||||
|
||||
/// Get current stream codec constraints derived from global configuration.
|
||||
pub async fn set_runtime_codec_constraints(&self, constraints: StreamCodecConstraints) {
|
||||
*self.runtime_codec_constraints.write().await = Some(constraints);
|
||||
}
|
||||
|
||||
/// Get current stream codec constraints derived from running services.
|
||||
pub async fn codec_constraints(&self) -> StreamCodecConstraints {
|
||||
if let Some(constraints) = self.runtime_codec_constraints.read().await.as_ref() {
|
||||
return constraints.clone();
|
||||
}
|
||||
|
||||
if let Some(ref config_store) = *self.config_store.read().await {
|
||||
let config = config_store.get();
|
||||
StreamCodecConstraints::from_config(&config)
|
||||
|
||||
233
src/vnc/mod.rs
233
src/vnc/mod.rs
@@ -9,7 +9,7 @@ use std::time::Duration;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{broadcast, Mutex, RwLock};
|
||||
use tokio::sync::{broadcast, watch, Mutex, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -21,7 +21,19 @@ use crate::utils::{bind_socket_addr, bind_tcp_listener};
|
||||
use crate::video::codec::{BitratePreset, VideoCodecType};
|
||||
use crate::video::stream_manager::VideoStreamManager;
|
||||
|
||||
use self::rfb::{RfbClient, RfbFrame, RfbInputEvent};
|
||||
use self::rfb::{FrameSendOutcome, RfbClient, RfbFrame, RfbInputEvent};
|
||||
|
||||
struct ActiveClientGuard(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for ActiveClientGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self
|
||||
.0
|
||||
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| {
|
||||
Some(count.saturating_sub(1))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum VncServiceStatus {
|
||||
@@ -109,20 +121,36 @@ impl VncService {
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
let bind_addr = bind_socket_addr(&config.bind, config.port)
|
||||
.map_err(|e| AppError::BadRequest(format!("Invalid VNC bind address: {}", e)))?;
|
||||
let listener = bind_tcp_listener(bind_addr).map_err(|e| {
|
||||
AppError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("VNC bind failed: {}", e),
|
||||
))
|
||||
})?;
|
||||
let listener = TcpListener::from_std(listener).map_err(|e| {
|
||||
AppError::Io(std::io::Error::new(
|
||||
e.kind(),
|
||||
format!("VNC listener setup failed: {}", e),
|
||||
))
|
||||
})?;
|
||||
let bind_addr = match bind_socket_addr(&config.bind, config.port) {
|
||||
Ok(addr) => addr,
|
||||
Err(err) => {
|
||||
let error = AppError::BadRequest(format!("Invalid VNC bind address: {}", err));
|
||||
*self.status.write().await = VncServiceStatus::Error(error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let listener = match bind_tcp_listener(bind_addr) {
|
||||
Ok(listener) => listener,
|
||||
Err(err) => {
|
||||
let error = AppError::Io(std::io::Error::new(
|
||||
err.kind(),
|
||||
format!("VNC bind failed: {}", err),
|
||||
));
|
||||
*self.status.write().await = VncServiceStatus::Error(error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let listener = match TcpListener::from_std(listener) {
|
||||
Ok(listener) => listener,
|
||||
Err(err) => {
|
||||
let error = AppError::Io(std::io::Error::new(
|
||||
err.kind(),
|
||||
format!("VNC listener setup failed: {}", err),
|
||||
));
|
||||
*self.status.write().await = VncServiceStatus::Error(error.to_string());
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
let config_ref = self.config.clone();
|
||||
let video_manager = self.video_manager.clone();
|
||||
@@ -145,7 +173,15 @@ impl VncService {
|
||||
match result {
|
||||
Ok((stream, peer)) => {
|
||||
let cfg = config_ref.read().await.clone();
|
||||
if cfg.allow_one_client && active_clients.load(Ordering::Relaxed) > 0 {
|
||||
let reserved = if cfg.allow_one_client {
|
||||
active_clients
|
||||
.compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_ok()
|
||||
} else {
|
||||
active_clients.fetch_add(1, Ordering::AcqRel);
|
||||
true
|
||||
};
|
||||
if !reserved {
|
||||
warn!("Rejecting VNC client {} because another client is active", peer);
|
||||
drop(stream);
|
||||
continue;
|
||||
@@ -154,9 +190,8 @@ impl VncService {
|
||||
let hid = hid.clone();
|
||||
let active = active_clients.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
active.fetch_add(1, Ordering::Relaxed);
|
||||
let _active_guard = ActiveClientGuard(active);
|
||||
let result = handle_client(stream, peer, cfg, vm, hid).await;
|
||||
active.fetch_sub(1, Ordering::Relaxed);
|
||||
if let Err(err) = result {
|
||||
warn!("VNC client {} ended: {}", peer, err);
|
||||
}
|
||||
@@ -234,24 +269,81 @@ async fn handle_client(
|
||||
let (width, height) = initial_frame_size(&config, &video_manager).await;
|
||||
client.set_size(width, height);
|
||||
client.handshake().await?;
|
||||
tracing::debug!("VNC client {} ClientInit shared={}", peer, client.shared());
|
||||
let (_, _, mut frame_rx) = subscribe_frames(&config, &video_manager).await?;
|
||||
let mut latest_frame = frame_rx.borrow().clone();
|
||||
let mut latest_size = latest_frame.as_ref().map(RfbFrame::size);
|
||||
let mut shutdown = client.shutdown_receiver();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = client.read_input_event() => {
|
||||
match result? {
|
||||
RfbInputEvent::Ignored => {}
|
||||
RfbInputEvent::Disconnected => break,
|
||||
event => handle_input_event(event, &hid, width, height).await?,
|
||||
RfbInputEvent::Key(key) => {
|
||||
if let Some(event) = client.key_event_to_hid(key) {
|
||||
hid.send_keyboard(event).await?;
|
||||
}
|
||||
}
|
||||
RfbInputEvent::Pointer(pointer) => {
|
||||
let (width, height) = client.framebuffer_size();
|
||||
for event in rfb::pointer_event_to_hid(pointer, width, height) {
|
||||
hid.send_mouse(event).await?;
|
||||
}
|
||||
}
|
||||
RfbInputEvent::SetEncodings { encoding_enabled, resumed } => {
|
||||
if !encoding_enabled {
|
||||
tracing::debug!("VNC client {} paused the configured encoding", peer);
|
||||
}
|
||||
if resumed && config.encoding == VncEncoding::H264 {
|
||||
request_vnc_keyframe(&video_manager, "encoding resume").await;
|
||||
}
|
||||
}
|
||||
RfbInputEvent::FramebufferUpdateRequest(request) => {
|
||||
if !request.incremental && config.encoding == VncEncoding::H264 {
|
||||
request_vnc_keyframe(&video_manager, "non-incremental refresh").await;
|
||||
}
|
||||
}
|
||||
RfbInputEvent::SetPixelFormat(format) => {
|
||||
tracing::debug!(
|
||||
"VNC client {} selected {} bpp true-colour={}",
|
||||
peer,
|
||||
format.bits_per_pixel,
|
||||
format.true_colour
|
||||
);
|
||||
}
|
||||
RfbInputEvent::UnsupportedClientCutText => {
|
||||
tracing::debug!("Ignoring unsupported VNC ClientCutText from {}", peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
maybe_frame = frame_rx.recv() => {
|
||||
let Some(frame) = maybe_frame else { break };
|
||||
client.send_frame(frame).await?;
|
||||
changed = frame_rx.changed() => {
|
||||
if changed.is_err() {
|
||||
break;
|
||||
}
|
||||
latest_frame = frame_rx.borrow_and_update().clone();
|
||||
let new_size = latest_frame.as_ref().map(RfbFrame::size);
|
||||
if config.encoding == VncEncoding::H264
|
||||
&& latest_size.is_some()
|
||||
&& new_size != latest_size
|
||||
{
|
||||
request_vnc_keyframe(&video_manager, "source resolution change").await;
|
||||
}
|
||||
latest_size = new_size;
|
||||
}
|
||||
_ = shutdown.recv() => break,
|
||||
}
|
||||
|
||||
if client.has_pending_request()
|
||||
&& latest_frame.is_some()
|
||||
&& !client.has_complete_buffered_input()?
|
||||
&& send_latest_frame(&mut client, latest_frame.as_ref()).await?
|
||||
== FrameSendOutcome::DesktopSizeSent
|
||||
&& config.encoding == VncEncoding::H264
|
||||
{
|
||||
request_vnc_keyframe(&video_manager, "framebuffer resize").await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -276,8 +368,7 @@ async fn initial_frame_size(
|
||||
async fn subscribe_frames(
|
||||
config: &VncConfig,
|
||||
video_manager: &Arc<VideoStreamManager>,
|
||||
) -> Result<(u16, u16, tokio::sync::mpsc::Receiver<RfbFrame>)> {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
||||
) -> Result<(u16, u16, watch::Receiver<Option<RfbFrame>>)> {
|
||||
match config.encoding {
|
||||
VncEncoding::TightJpeg => {
|
||||
let handler = video_manager.mjpeg_handler();
|
||||
@@ -289,6 +380,15 @@ async fn subscribe_frames(
|
||||
.as_ref()
|
||||
.map(|f| (f.width() as u16, f.height() as u16))
|
||||
.unwrap_or((800, 600));
|
||||
let initial = current
|
||||
.filter(|frame| frame.online && frame.is_valid_jpeg())
|
||||
.map(|frame| RfbFrame::Jpeg {
|
||||
data: frame.data_bytes(),
|
||||
width: frame.width() as u16,
|
||||
height: frame.height() as u16,
|
||||
sequence: frame.sequence,
|
||||
});
|
||||
let (tx, rx) = watch::channel(initial);
|
||||
let mut notify = handler.subscribe();
|
||||
tokio::spawn(async move {
|
||||
let _guard = guard;
|
||||
@@ -302,19 +402,22 @@ async fn subscribe_frames(
|
||||
if !frame.online || !frame.is_valid_jpeg() {
|
||||
continue;
|
||||
}
|
||||
let _ = tx
|
||||
.send(RfbFrame::Jpeg {
|
||||
data: frame.data_bytes(),
|
||||
width: frame.width() as u16,
|
||||
height: frame.height() as u16,
|
||||
})
|
||||
.await;
|
||||
if tx.receiver_count() == 0 {
|
||||
break;
|
||||
}
|
||||
tx.send_replace(Some(RfbFrame::Jpeg {
|
||||
data: frame.data_bytes(),
|
||||
width: frame.width() as u16,
|
||||
height: frame.height() as u16,
|
||||
sequence: frame.sequence,
|
||||
}));
|
||||
handler.record_frame_sent(&client_id);
|
||||
}
|
||||
});
|
||||
Ok((width, height, rx))
|
||||
}
|
||||
VncEncoding::H264 => {
|
||||
let (tx, rx) = watch::channel(None);
|
||||
video_manager.set_video_codec(VideoCodecType::H264).await?;
|
||||
let mut frames = video_manager
|
||||
.subscribe_encoded_frames()
|
||||
@@ -329,22 +432,28 @@ async fn subscribe_frames(
|
||||
.unwrap_or(crate::video::format::Resolution::HD720);
|
||||
let width = geometry.width as u16;
|
||||
let height = geometry.height as u16;
|
||||
if let Err(err) = video_manager.request_keyframe().await {
|
||||
warn!("Failed to request VNC H264 keyframe: {}", err);
|
||||
}
|
||||
request_vnc_keyframe(video_manager, "initial frame").await;
|
||||
let geometry_manager = video_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(frame) = frames.recv().await {
|
||||
if frame.codec != crate::video::codec::registry::VideoEncoderType::H264 {
|
||||
continue;
|
||||
}
|
||||
let _ = tx
|
||||
.send(RfbFrame::H264 {
|
||||
data: Bytes::copy_from_slice(&frame.data),
|
||||
width,
|
||||
height,
|
||||
key: frame.is_keyframe,
|
||||
})
|
||||
.await;
|
||||
if tx.receiver_count() == 0 {
|
||||
break;
|
||||
}
|
||||
let geometry = geometry_manager
|
||||
.get_encoding_config()
|
||||
.await
|
||||
.map(|cfg| cfg.resolution)
|
||||
.unwrap_or(crate::video::format::Resolution::HD720);
|
||||
tx.send_replace(Some(RfbFrame::H264 {
|
||||
data: Bytes::copy_from_slice(&frame.data),
|
||||
width: geometry.width as u16,
|
||||
height: geometry.height as u16,
|
||||
key: frame.is_keyframe,
|
||||
sequence: frame.sequence,
|
||||
}));
|
||||
}
|
||||
});
|
||||
Ok((width, height, rx))
|
||||
@@ -352,25 +461,21 @@ async fn subscribe_frames(
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_input_event(
|
||||
event: RfbInputEvent,
|
||||
hid: &Arc<HidController>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
) -> Result<()> {
|
||||
match event {
|
||||
RfbInputEvent::Key(key) => {
|
||||
if let Some(event) = rfb::key_event_to_hid(key) {
|
||||
hid.send_keyboard(event).await?;
|
||||
}
|
||||
}
|
||||
RfbInputEvent::Pointer(pointer) => {
|
||||
for event in rfb::pointer_event_to_hid(pointer, width, height) {
|
||||
hid.send_mouse(event).await?;
|
||||
}
|
||||
}
|
||||
RfbInputEvent::Clipboard(_) => {}
|
||||
RfbInputEvent::Ignored | RfbInputEvent::Disconnected => {}
|
||||
async fn send_latest_frame(
|
||||
client: &mut RfbClient,
|
||||
frame: Option<&RfbFrame>,
|
||||
) -> Result<FrameSendOutcome> {
|
||||
match frame {
|
||||
Some(frame) => client.send_frame(frame).await,
|
||||
None => Ok(FrameSendOutcome::NotSent),
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_vnc_keyframe(video_manager: &VideoStreamManager, reason: &str) {
|
||||
if let Err(err) = video_manager.request_keyframe().await {
|
||||
warn!(
|
||||
"Failed to request VNC H264 keyframe for {}: {}",
|
||||
reason, err
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
1424
src/vnc/rfb.rs
1424
src/vnc/rfb.rs
File diff suppressed because it is too large
Load Diff
761
src/watchdog/mod.rs
Normal file
761
src/watchdog/mod.rs
Normal file
@@ -0,0 +1,761 @@
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::{mpsc, oneshot, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod platform {
|
||||
use super::{Backend, Device, DiscoveredWatchdog};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::os::unix::fs::{FileTypeExt, MetadataExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const WDIOC_GETSUPPORT: libc::c_ulong = 0x8028_5700;
|
||||
const WDIOC_SETOPTIONS: libc::c_ulong = 0x8004_5704;
|
||||
const WDIOC_KEEPALIVE: libc::c_ulong = 0x8004_5705;
|
||||
const WDIOC_GETTIMEOUT: libc::c_ulong = 0x8004_5707;
|
||||
const WDIOS_DISABLECARD: libc::c_int = 0x0001;
|
||||
const WDIOF_MAGICCLOSE: u32 = 0x0100;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Default)]
|
||||
struct WatchdogInfo {
|
||||
options: u32,
|
||||
firmware_version: u32,
|
||||
identity: [u8; 32],
|
||||
}
|
||||
|
||||
pub struct LinuxBackend {
|
||||
sys_root: PathBuf,
|
||||
dev_root: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for LinuxBackend {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sys_root: PathBuf::from("/sys/class/watchdog"),
|
||||
dev_root: PathBuf::from("/dev"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Backend for LinuxBackend {
|
||||
fn discover(&self) -> io::Result<Vec<DiscoveredWatchdog>> {
|
||||
discover_at(&self.sys_root, &self.dev_root)
|
||||
}
|
||||
|
||||
fn open(&self, path: &Path) -> io::Result<Box<dyn Device>> {
|
||||
let file = OpenOptions::new().write(true).open(path)?;
|
||||
let mut info = WatchdogInfo::default();
|
||||
let supports_magic_close = unsafe {
|
||||
libc::ioctl(
|
||||
std::os::fd::AsRawFd::as_raw_fd(&file),
|
||||
WDIOC_GETSUPPORT,
|
||||
&mut info,
|
||||
) == 0
|
||||
&& info.options & WDIOF_MAGICCLOSE != 0
|
||||
};
|
||||
Ok(Box::new(LinuxDevice {
|
||||
file,
|
||||
supports_magic_close,
|
||||
nowayout: self.device_nowayout(path),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl LinuxBackend {
|
||||
fn device_nowayout(&self, path: &Path) -> Option<bool> {
|
||||
let direct_index = path
|
||||
.file_name()
|
||||
.and_then(|name| watchdog_index(&name.to_string_lossy()));
|
||||
if let Some(index) = direct_index {
|
||||
return read_trimmed(&self.sys_root.join(format!("watchdog{index}/nowayout")))
|
||||
.and_then(|value| parse_boolean_flag(&value));
|
||||
}
|
||||
|
||||
discover_at(&self.sys_root, &self.dev_root)
|
||||
.ok()
|
||||
.and_then(|devices| {
|
||||
devices.into_iter().find(|device| {
|
||||
device
|
||||
.paths
|
||||
.iter()
|
||||
.any(|candidate| same_file(candidate, path))
|
||||
})
|
||||
})
|
||||
.and_then(|device| {
|
||||
read_trimmed(
|
||||
&self
|
||||
.sys_root
|
||||
.join(format!("watchdog{}/nowayout", device.index)),
|
||||
)
|
||||
})
|
||||
.and_then(|value| parse_boolean_flag(&value))
|
||||
}
|
||||
}
|
||||
|
||||
struct LinuxDevice {
|
||||
file: File,
|
||||
supports_magic_close: bool,
|
||||
nowayout: Option<bool>,
|
||||
}
|
||||
|
||||
impl Device for LinuxDevice {
|
||||
fn keep_alive(&mut self) -> io::Result<()> {
|
||||
let result = unsafe {
|
||||
libc::ioctl(
|
||||
std::os::fd::AsRawFd::as_raw_fd(&self.file),
|
||||
WDIOC_KEEPALIVE,
|
||||
0,
|
||||
)
|
||||
};
|
||||
if result == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout(&mut self) -> io::Result<u32> {
|
||||
let mut timeout: libc::c_int = 0;
|
||||
let result = unsafe {
|
||||
libc::ioctl(
|
||||
std::os::fd::AsRawFd::as_raw_fd(&self.file),
|
||||
WDIOC_GETTIMEOUT,
|
||||
&mut timeout,
|
||||
)
|
||||
};
|
||||
if result == 0 && timeout > 0 {
|
||||
Ok(timeout as u32)
|
||||
} else if result == 0 {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"watchdog reported a zero timeout",
|
||||
))
|
||||
} else {
|
||||
Err(io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
fn disable(&mut self) -> io::Result<()> {
|
||||
if self.nowayout == Some(true) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"watchdog nowayout is enabled",
|
||||
));
|
||||
}
|
||||
let mut option = WDIOS_DISABLECARD;
|
||||
let result = unsafe {
|
||||
libc::ioctl(
|
||||
std::os::fd::AsRawFd::as_raw_fd(&self.file),
|
||||
WDIOC_SETOPTIONS,
|
||||
&mut option,
|
||||
)
|
||||
};
|
||||
if result == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ioctl_error = io::Error::last_os_error();
|
||||
if self.supports_magic_close && self.nowayout == Some(false) {
|
||||
self.file.write_all(b"V")?;
|
||||
self.file.flush()
|
||||
} else if self.supports_magic_close {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"watchdog nowayout state cannot be verified",
|
||||
))
|
||||
} else {
|
||||
Err(ioctl_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn watchdog_index(name: &str) -> Option<u32> {
|
||||
name.strip_prefix("watchdog")?.parse().ok()
|
||||
}
|
||||
|
||||
fn parse_boolean_flag(value: &str) -> Option<bool> {
|
||||
match value {
|
||||
"0" => Some(false),
|
||||
"1" => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_trimmed(path: &Path) -> Option<String> {
|
||||
fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
}
|
||||
|
||||
fn path_marker(path: &Path) -> Option<String> {
|
||||
fs::canonicalize(path)
|
||||
.ok()
|
||||
.or_else(|| fs::read_link(path).ok())
|
||||
.and_then(|path| {
|
||||
path.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
})
|
||||
}
|
||||
|
||||
fn is_softdog(entry: &Path) -> bool {
|
||||
let mut markers = Vec::new();
|
||||
for name in ["identity", "name"] {
|
||||
if let Some(value) = read_trimmed(&entry.join(name)) {
|
||||
markers.push(value);
|
||||
}
|
||||
}
|
||||
for path in [
|
||||
entry.join("device/driver"),
|
||||
entry.join("device/driver/module"),
|
||||
] {
|
||||
if let Some(value) = path_marker(&path) {
|
||||
markers.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
markers.into_iter().any(|value| {
|
||||
let value = value.to_ascii_lowercase();
|
||||
value.contains("softdog") || value.contains("software watchdog")
|
||||
})
|
||||
}
|
||||
|
||||
fn same_file(left: &Path, right: &Path) -> bool {
|
||||
if let (Ok(left), Ok(right)) = (fs::canonicalize(left), fs::canonicalize(right)) {
|
||||
if left == right {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
match (fs::metadata(left), fs::metadata(right)) {
|
||||
(Ok(left), Ok(right)) => {
|
||||
if left.file_type().is_char_device() && right.file_type().is_char_device() {
|
||||
left.rdev() == right.rdev()
|
||||
} else {
|
||||
left.dev() == right.dev() && left.ino() == right.ino()
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn alias_matches_sysfs(entry: &Path, alias: &Path) -> bool {
|
||||
let Some(dev) = read_trimmed(&entry.join("dev")) else {
|
||||
return false;
|
||||
};
|
||||
let Some((major, minor)) = dev.split_once(':') else {
|
||||
return false;
|
||||
};
|
||||
let (Ok(major), Ok(minor), Ok(metadata)) = (
|
||||
major.parse::<u32>(),
|
||||
minor.parse::<u32>(),
|
||||
fs::metadata(alias),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
metadata.file_type().is_char_device()
|
||||
&& libc::major(metadata.rdev()) == major
|
||||
&& libc::minor(metadata.rdev()) == minor
|
||||
}
|
||||
|
||||
pub(super) fn discover_at(
|
||||
sys_root: &Path,
|
||||
dev_root: &Path,
|
||||
) -> io::Result<Vec<DiscoveredWatchdog>> {
|
||||
let entries = match fs::read_dir(sys_root) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let alias = dev_root.join("watchdog");
|
||||
let mut devices = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
let Some(index) = watchdog_index(&name) else {
|
||||
continue;
|
||||
};
|
||||
if is_softdog(&entry.path()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let numbered = dev_root.join(name.as_ref());
|
||||
let mut paths = Vec::new();
|
||||
if numbered.exists() {
|
||||
paths.push(numbered.clone());
|
||||
}
|
||||
if alias.exists()
|
||||
&& (same_file(&numbered, &alias)
|
||||
|| (!numbered.exists() && alias_matches_sysfs(&entry.path(), &alias)))
|
||||
&& !paths.iter().any(|path| same_file(path, &alias))
|
||||
{
|
||||
paths.push(alias.clone());
|
||||
}
|
||||
if !paths.is_empty() {
|
||||
devices.push(DiscoveredWatchdog { index, paths });
|
||||
}
|
||||
}
|
||||
devices.sort_by_key(|device| device.index);
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::symlink;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn create_watchdog(sys: &Path, dev: &Path, index: u32, identity: &str) {
|
||||
let entry = sys.join(format!("watchdog{index}"));
|
||||
fs::create_dir_all(&entry).unwrap();
|
||||
fs::write(entry.join("identity"), identity).unwrap();
|
||||
File::create(dev.join(format!("watchdog{index}"))).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_hardware_in_numeric_order_and_excludes_softdog() {
|
||||
let temp = tempdir().unwrap();
|
||||
let sys = temp.path().join("sys");
|
||||
let dev = temp.path().join("dev");
|
||||
fs::create_dir_all(&sys).unwrap();
|
||||
fs::create_dir_all(&dev).unwrap();
|
||||
create_watchdog(&sys, &dev, 12, "Hardware watchdog");
|
||||
create_watchdog(&sys, &dev, 2, "Board WDT");
|
||||
create_watchdog(&sys, &dev, 1, "Software Watchdog");
|
||||
|
||||
let found = discover_at(&sys, &dev).unwrap();
|
||||
assert_eq!(
|
||||
found.iter().map(|item| item.index).collect::<Vec<_>>(),
|
||||
[2, 12]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_duplicate_matching_watchdog_alias() {
|
||||
let temp = tempdir().unwrap();
|
||||
let sys = temp.path().join("sys");
|
||||
let dev = temp.path().join("dev");
|
||||
fs::create_dir_all(&sys).unwrap();
|
||||
fs::create_dir_all(&dev).unwrap();
|
||||
create_watchdog(&sys, &dev, 0, "Board WDT");
|
||||
symlink("watchdog0", dev.join("watchdog")).unwrap();
|
||||
|
||||
let found = discover_at(&sys, &dev).unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].paths, vec![dev.join("watchdog0")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_sysfs_directory_means_unsupported() {
|
||||
let temp = tempdir().unwrap();
|
||||
let found = discover_at(&temp.path().join("missing"), temp.path()).unwrap();
|
||||
assert!(found.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_softdog_identified_by_driver() {
|
||||
let temp = tempdir().unwrap();
|
||||
let sys = temp.path().join("sys");
|
||||
let dev = temp.path().join("dev");
|
||||
fs::create_dir_all(&sys).unwrap();
|
||||
fs::create_dir_all(&dev).unwrap();
|
||||
create_watchdog(&sys, &dev, 0, "Watchdog");
|
||||
let device = sys.join("watchdog0/device");
|
||||
fs::create_dir_all(&device).unwrap();
|
||||
symlink("/sys/bus/platform/drivers/softdog", device.join("driver")).unwrap();
|
||||
|
||||
assert!(discover_at(&sys, &dev).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod platform {
|
||||
use super::{Backend, Device, DiscoveredWatchdog};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UnsupportedBackend;
|
||||
|
||||
impl Backend for UnsupportedBackend {
|
||||
fn discover(&self) -> io::Result<Vec<DiscoveredWatchdog>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn open(&self, _path: &Path) -> io::Result<Box<dyn Device>> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"hardware watchdog is unsupported on Windows",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DiscoveredWatchdog {
|
||||
index: u32,
|
||||
paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
trait Device: Send {
|
||||
fn keep_alive(&mut self) -> io::Result<()>;
|
||||
fn timeout(&mut self) -> io::Result<u32>;
|
||||
fn disable(&mut self) -> io::Result<()>;
|
||||
}
|
||||
|
||||
trait Backend: Send + Sync {
|
||||
fn discover(&self) -> io::Result<Vec<DiscoveredWatchdog>>;
|
||||
fn open(&self, path: &std::path::Path) -> io::Result<Box<dyn Device>>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WatchdogRuntimeStatus {
|
||||
pub supported: bool,
|
||||
pub running: bool,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SharedState {
|
||||
running: bool,
|
||||
last_error: Option<String>,
|
||||
}
|
||||
|
||||
enum WorkerCommand {
|
||||
Disable(oneshot::Sender<io::Result<()>>),
|
||||
}
|
||||
|
||||
struct RunningWatchdog {
|
||||
commands: mpsc::Sender<WorkerCommand>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
pub struct WatchdogController {
|
||||
backend: Arc<dyn Backend>,
|
||||
shared: Arc<StdMutex<SharedState>>,
|
||||
running: Mutex<Option<RunningWatchdog>>,
|
||||
}
|
||||
|
||||
impl Default for WatchdogController {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl WatchdogController {
|
||||
pub fn new() -> Self {
|
||||
#[cfg(target_os = "linux")]
|
||||
let backend = Arc::new(platform::LinuxBackend::default());
|
||||
#[cfg(windows)]
|
||||
let backend = Arc::new(platform::UnsupportedBackend);
|
||||
Self::with_backend(backend)
|
||||
}
|
||||
|
||||
fn with_backend(backend: Arc<dyn Backend>) -> Self {
|
||||
Self {
|
||||
backend,
|
||||
shared: Arc::new(StdMutex::new(SharedState::default())),
|
||||
running: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enable(&self) -> io::Result<()> {
|
||||
let mut running = self.running.lock().await;
|
||||
if running.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let devices = self.backend.discover().map_err(|error| {
|
||||
self.record_error(format!("Failed to discover hardware watchdog: {error}"));
|
||||
error
|
||||
})?;
|
||||
if devices.is_empty() {
|
||||
let message = "No hardware watchdog device found";
|
||||
self.record_error(message.to_string());
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, message));
|
||||
}
|
||||
|
||||
let mut open_errors = Vec::new();
|
||||
let mut selected = None;
|
||||
'devices: for device in devices {
|
||||
for path in device.paths {
|
||||
match self.backend.open(&path) {
|
||||
Ok(mut handle) => {
|
||||
let initial_error = handle
|
||||
.keep_alive()
|
||||
.err()
|
||||
.map(|error| format!("Watchdog initial keepalive failed: {error}"));
|
||||
selected = Some((path, handle, initial_error));
|
||||
break 'devices;
|
||||
}
|
||||
Err(error) => open_errors.push(format!("{}: {error}", path.display())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some((path, mut device, initial_error)) = selected else {
|
||||
let message = format!(
|
||||
"Failed to open a hardware watchdog: {}",
|
||||
open_errors.join("; ")
|
||||
);
|
||||
self.record_error(message.clone());
|
||||
return Err(io::Error::new(io::ErrorKind::Other, message));
|
||||
};
|
||||
|
||||
let timeout = device.timeout().unwrap_or(30);
|
||||
let period = Duration::from_secs(u64::from((timeout / 3).max(1)));
|
||||
let (commands, receiver) = mpsc::channel(1);
|
||||
let shared = self.shared.clone();
|
||||
{
|
||||
let mut state = shared.lock().unwrap();
|
||||
state.running = initial_error.is_none();
|
||||
state.last_error = initial_error;
|
||||
}
|
||||
let task = tokio::spawn(run_worker(device, receiver, shared, period, path));
|
||||
*running = Some(RunningWatchdog { commands, task });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn disable(&self) -> io::Result<()> {
|
||||
let mut running = self.running.lock().await;
|
||||
let Some(worker) = running.as_mut() else {
|
||||
let mut state = self.shared.lock().unwrap();
|
||||
state.running = false;
|
||||
state.last_error = None;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let (result_tx, result_rx) = oneshot::channel();
|
||||
worker
|
||||
.commands
|
||||
.send(WorkerCommand::Disable(result_tx))
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "watchdog worker stopped"))?;
|
||||
match result_rx.await {
|
||||
Ok(Ok(())) => {
|
||||
if let Some(worker) = running.take() {
|
||||
let _ = worker.task.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(error)) => Err(error),
|
||||
Err(_) => Err(io::Error::new(
|
||||
io::ErrorKind::BrokenPipe,
|
||||
"watchdog worker stopped",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn status(&self) -> WatchdogRuntimeStatus {
|
||||
let discovery = self.backend.discover();
|
||||
let supported = discovery.as_ref().is_ok_and(|devices| !devices.is_empty());
|
||||
let state = self.shared.lock().unwrap();
|
||||
let reason = if let Err(error) = discovery {
|
||||
Some(format!("Failed to discover hardware watchdog: {error}"))
|
||||
} else if !supported {
|
||||
Some("No hardware watchdog device found".to_string())
|
||||
} else {
|
||||
state.last_error.clone()
|
||||
};
|
||||
WatchdogRuntimeStatus {
|
||||
supported,
|
||||
running: state.running,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn record_error(&self, message: String) {
|
||||
let mut state = self.shared.lock().unwrap();
|
||||
state.running = false;
|
||||
state.last_error = Some(message);
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_worker(
|
||||
mut device: Box<dyn Device>,
|
||||
mut commands: mpsc::Receiver<WorkerCommand>,
|
||||
shared: Arc<StdMutex<SharedState>>,
|
||||
period: Duration,
|
||||
path: PathBuf,
|
||||
) {
|
||||
let mut ticker = tokio::time::interval(period);
|
||||
ticker.tick().await;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = ticker.tick() => {
|
||||
match device.keep_alive() {
|
||||
Ok(()) => {
|
||||
let mut state = shared.lock().unwrap();
|
||||
state.running = true;
|
||||
state.last_error = None;
|
||||
}
|
||||
Err(error) => {
|
||||
let message = format!("Watchdog keepalive failed: {error}");
|
||||
tracing::error!("{} ({})", message, path.display());
|
||||
let mut state = shared.lock().unwrap();
|
||||
state.running = false;
|
||||
state.last_error = Some(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
command = commands.recv() => {
|
||||
let Some(WorkerCommand::Disable(result_tx)) = command else {
|
||||
break;
|
||||
};
|
||||
match device.disable() {
|
||||
Ok(()) => {
|
||||
let mut state = shared.lock().unwrap();
|
||||
state.running = false;
|
||||
state.last_error = None;
|
||||
let _ = result_tx.send(Ok(()));
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
let message = format!("Hardware watchdog cannot be safely disabled: {error}");
|
||||
tracing::error!("{}; continuing keepalive", message);
|
||||
let mut state = shared.lock().unwrap();
|
||||
state.running = true;
|
||||
state.last_error = Some(message);
|
||||
let _ = result_tx.send(Err(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::Path;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
struct FakeDevice {
|
||||
feeds: Arc<AtomicUsize>,
|
||||
feed_results: Arc<StdMutex<VecDeque<io::Result<()>>>>,
|
||||
disable_result: Arc<StdMutex<Option<io::Result<()>>>>,
|
||||
timeout: u32,
|
||||
}
|
||||
|
||||
impl Device for FakeDevice {
|
||||
fn keep_alive(&mut self) -> io::Result<()> {
|
||||
self.feeds.fetch_add(1, Ordering::SeqCst);
|
||||
self.feed_results
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or(Ok(()))
|
||||
}
|
||||
fn timeout(&mut self) -> io::Result<u32> {
|
||||
Ok(self.timeout)
|
||||
}
|
||||
fn disable(&mut self) -> io::Result<()> {
|
||||
self.disable_result.lock().unwrap().take().unwrap_or(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeBackend {
|
||||
feeds: Arc<AtomicUsize>,
|
||||
feed_results: Arc<StdMutex<VecDeque<io::Result<()>>>>,
|
||||
disable_result: Arc<StdMutex<Option<io::Result<()>>>>,
|
||||
opens: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Backend for FakeBackend {
|
||||
fn discover(&self) -> io::Result<Vec<DiscoveredWatchdog>> {
|
||||
Ok(vec![
|
||||
DiscoveredWatchdog {
|
||||
index: 0,
|
||||
paths: vec![PathBuf::from("/dev/watchdog0")],
|
||||
},
|
||||
DiscoveredWatchdog {
|
||||
index: 1,
|
||||
paths: vec![PathBuf::from("/dev/watchdog1")],
|
||||
},
|
||||
])
|
||||
}
|
||||
fn open(&self, _path: &Path) -> io::Result<Box<dyn Device>> {
|
||||
self.opens.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Box::new(FakeDevice {
|
||||
feeds: self.feeds.clone(),
|
||||
feed_results: self.feed_results.clone(),
|
||||
disable_result: self.disable_result.clone(),
|
||||
timeout: 3,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_controller(
|
||||
disable_result: io::Result<()>,
|
||||
) -> (WatchdogController, Arc<AtomicUsize>, Arc<AtomicUsize>) {
|
||||
fake_controller_with_feeds(disable_result, VecDeque::new())
|
||||
}
|
||||
|
||||
fn fake_controller_with_feeds(
|
||||
disable_result: io::Result<()>,
|
||||
feed_results: VecDeque<io::Result<()>>,
|
||||
) -> (WatchdogController, Arc<AtomicUsize>, Arc<AtomicUsize>) {
|
||||
let feeds = Arc::new(AtomicUsize::new(0));
|
||||
let opens = Arc::new(AtomicUsize::new(0));
|
||||
let backend = FakeBackend {
|
||||
feeds: feeds.clone(),
|
||||
feed_results: Arc::new(StdMutex::new(feed_results)),
|
||||
disable_result: Arc::new(StdMutex::new(Some(disable_result))),
|
||||
opens: opens.clone(),
|
||||
};
|
||||
(
|
||||
WatchdogController::with_backend(Arc::new(backend)),
|
||||
feeds,
|
||||
opens,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_feeds_immediately_and_disable_stops_worker() {
|
||||
let (controller, feeds, opens) = fake_controller(Ok(()));
|
||||
controller.enable().await.unwrap();
|
||||
assert_eq!(opens.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(feeds.load(Ordering::SeqCst), 1);
|
||||
assert!(controller.status().await.running);
|
||||
controller.disable().await.unwrap();
|
||||
assert!(!controller.status().await.running);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_disable_keeps_watchdog_running() {
|
||||
let (controller, feeds, _) = fake_controller(Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"nowayout",
|
||||
)));
|
||||
controller.enable().await.unwrap();
|
||||
assert!(controller.disable().await.is_err());
|
||||
assert!(controller.status().await.running);
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
assert!(feeds.load(Ordering::SeqCst) >= 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initial_feed_failure_retries_the_same_device() {
|
||||
let feed_results = VecDeque::from([
|
||||
Err(io::Error::new(io::ErrorKind::Other, "temporary failure")),
|
||||
Ok(()),
|
||||
]);
|
||||
let (controller, feeds, opens) = fake_controller_with_feeds(Ok(()), feed_results);
|
||||
|
||||
controller.enable().await.unwrap();
|
||||
assert!(!controller.status().await.running);
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
assert!(controller.status().await.running);
|
||||
assert_eq!(opens.load(Ordering::SeqCst), 1);
|
||||
assert!(feeds.load(Ordering::SeqCst) >= 2);
|
||||
controller.disable().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub struct ErrorResponse {
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = status_code(&self);
|
||||
let body = ErrorResponse {
|
||||
success: false,
|
||||
message: self.to_string(),
|
||||
@@ -25,7 +26,59 @@ impl IntoResponse for AppError {
|
||||
"Request failed"
|
||||
);
|
||||
|
||||
// Always return 200 OK - success/failure is indicated by the success field
|
||||
(StatusCode::OK, Json(body)).into_response()
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
fn status_code(error: &AppError) -> StatusCode {
|
||||
match error {
|
||||
AppError::AuthError(_) | AppError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||
AppError::Conflict(_) => StatusCode::CONFLICT,
|
||||
AppError::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||
AppError::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
AppError::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_client_and_availability_errors_to_http_statuses() {
|
||||
assert_eq!(
|
||||
status_code(&AppError::BadRequest("invalid".to_string())),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
status_code(&AppError::AuthError("invalid".to_string())),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
status_code(&AppError::NotFound("missing".to_string())),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
status_code(&AppError::ServiceUnavailable("offline".to_string())),
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
);
|
||||
assert_eq!(
|
||||
status_code(&AppError::Conflict("exists".to_string())),
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
assert_eq!(
|
||||
status_code(&AppError::RateLimited("limited".to_string())),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_internal_errors_to_server_error() {
|
||||
assert_eq!(
|
||||
status_code(&AppError::Internal("failed".to_string())),
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::auth::server_time_unix_ms;
|
||||
use crate::state::ShutdownAction;
|
||||
|
||||
/// Change password request
|
||||
@@ -8,6 +9,133 @@ pub struct ChangePasswordRequest {
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TotpStatusResponse {
|
||||
pub enabled: bool,
|
||||
pub server_time_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct BeginTotpEnrollmentRequest {
|
||||
pub current_password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct TotpEnrollmentResponse {
|
||||
pub enrollment_id: String,
|
||||
pub secret: String,
|
||||
pub otpauth_uri: String,
|
||||
pub expires_at_unix_ms: u64,
|
||||
pub server_time_unix_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ConfirmTotpEnrollmentRequest {
|
||||
pub enrollment_id: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DisableTotpRequest {
|
||||
pub current_password: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
pub async fn totp_status(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Extension(session): axum::Extension<Session>,
|
||||
) -> Result<Json<TotpStatusResponse>> {
|
||||
Ok(Json(TotpStatusResponse {
|
||||
enabled: state.two_factor.is_enabled(&session.user_id).await?,
|
||||
server_time_unix_ms: server_time_unix_ms(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn begin_totp_enrollment(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Extension(session): axum::Extension<Session>,
|
||||
Json(req): Json<BeginTotpEnrollmentRequest>,
|
||||
) -> Result<Json<TotpEnrollmentResponse>> {
|
||||
let user = authenticated_user(&state, &session).await?;
|
||||
verify_current_password(&state, &user, &req.current_password).await?;
|
||||
let enrollment = state
|
||||
.two_factor
|
||||
.begin_enrollment(&session.id, &user.id, &user.username)
|
||||
.await?;
|
||||
Ok(Json(TotpEnrollmentResponse {
|
||||
enrollment_id: enrollment.id,
|
||||
secret: enrollment.secret,
|
||||
otpauth_uri: enrollment.otpauth_uri,
|
||||
expires_at_unix_ms: enrollment.expires_at_unix_ms,
|
||||
server_time_unix_ms: server_time_unix_ms(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn confirm_totp_enrollment(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Extension(session): axum::Extension<Session>,
|
||||
Json(req): Json<ConfirmTotpEnrollmentRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
authenticated_user(&state, &session).await?;
|
||||
state
|
||||
.two_factor
|
||||
.confirm_enrollment(&session.id, &session.user_id, &req.enrollment_id, &req.code)
|
||||
.await?;
|
||||
revoke_other_sessions(&state, &session.id).await?;
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn disable_totp(
|
||||
State(state): State<Arc<AppState>>,
|
||||
axum::Extension(session): axum::Extension<Session>,
|
||||
Json(req): Json<DisableTotpRequest>,
|
||||
) -> Result<Json<LoginResponse>> {
|
||||
let user = authenticated_user(&state, &session).await?;
|
||||
verify_current_password(&state, &user, &req.current_password).await?;
|
||||
state.two_factor.disable(&user.id, &req.code).await?;
|
||||
revoke_other_sessions(&state, &session.id).await?;
|
||||
Ok(Json(LoginResponse {
|
||||
success: true,
|
||||
message: None,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn authenticated_user(state: &Arc<AppState>, session: &Session) -> Result<crate::auth::User> {
|
||||
state
|
||||
.users
|
||||
.single_user()
|
||||
.await?
|
||||
.filter(|user| user.id == session.user_id)
|
||||
.ok_or_else(|| AppError::AuthError("Invalid session".to_string()))
|
||||
}
|
||||
|
||||
async fn verify_current_password(
|
||||
state: &Arc<AppState>,
|
||||
user: &crate::auth::User,
|
||||
password: &str,
|
||||
) -> Result<()> {
|
||||
if state
|
||||
.users
|
||||
.verify(&user.username, password)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(AppError::AuthError(
|
||||
"Current password is incorrect".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_other_sessions(state: &Arc<AppState>, current_session_id: &str) -> Result<()> {
|
||||
let revoked = state.sessions.delete_all_except(current_session_id).await?;
|
||||
state.remember_revoked_sessions(revoked).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change current user's password
|
||||
pub async fn change_password(
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
||||
@@ -12,11 +12,26 @@ pub struct LoginResponse {
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AuthLoginResponse {
|
||||
pub next: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub challenge_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at_unix_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TotpLoginRequest {
|
||||
pub challenge_id: String,
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<Arc<AppState>>,
|
||||
cookies: CookieJar,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<(CookieJar, Json<LoginResponse>)> {
|
||||
) -> Result<(CookieJar, Json<AuthLoginResponse>)> {
|
||||
let config = state.config.get();
|
||||
|
||||
// Check if system is initialized
|
||||
@@ -31,15 +46,43 @@ pub async fn login(
|
||||
.await?
|
||||
.ok_or_else(|| AppError::AuthError("Invalid username or password".to_string()))?;
|
||||
|
||||
if !config.auth.single_user_allow_multiple_sessions {
|
||||
// Kick existing sessions before creating a new one.
|
||||
let revoked_ids = state.sessions.list_ids().await?;
|
||||
state.sessions.delete_all().await?;
|
||||
state.remember_revoked_sessions(revoked_ids).await;
|
||||
if let Some(challenge) = state.two_factor.begin_login(&user.id).await? {
|
||||
return Ok((
|
||||
cookies,
|
||||
Json(AuthLoginResponse {
|
||||
next: "totp",
|
||||
challenge_id: Some(challenge.id),
|
||||
expires_at_unix_ms: Some(challenge.expires_at_unix_ms),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Create session
|
||||
let session = state.sessions.create(&user.id).await?;
|
||||
create_authenticated_session(&state, cookies, &user.id).await
|
||||
}
|
||||
|
||||
pub async fn login_totp(
|
||||
State(state): State<Arc<AppState>>,
|
||||
cookies: CookieJar,
|
||||
Json(req): Json<TotpLoginRequest>,
|
||||
) -> Result<(CookieJar, Json<AuthLoginResponse>)> {
|
||||
let user_id = state
|
||||
.two_factor
|
||||
.complete_login(&req.challenge_id, &req.code)
|
||||
.await?;
|
||||
create_authenticated_session(&state, cookies, &user_id).await
|
||||
}
|
||||
|
||||
async fn create_authenticated_session(
|
||||
state: &Arc<AppState>,
|
||||
cookies: CookieJar,
|
||||
user_id: &str,
|
||||
) -> Result<(CookieJar, Json<AuthLoginResponse>)> {
|
||||
let config = state.config.get();
|
||||
let (session, revoked_ids) = state
|
||||
.sessions
|
||||
.create_for_login(user_id, config.auth.single_user_allow_multiple_sessions)
|
||||
.await?;
|
||||
state.remember_revoked_sessions(revoked_ids).await;
|
||||
|
||||
// Set session cookie
|
||||
let cookie = Cookie::build((SESSION_COOKIE, session.id))
|
||||
@@ -53,9 +96,10 @@ pub async fn login(
|
||||
|
||||
Ok((
|
||||
cookies.add(cookie),
|
||||
Json(LoginResponse {
|
||||
success: true,
|
||||
message: None,
|
||||
Json(AuthLoginResponse {
|
||||
next: "authenticated",
|
||||
challenge_id: None,
|
||||
expires_at_unix_ms: None,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -14,11 +14,33 @@ use tokio::sync::{Mutex, OwnedMutexGuard};
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct ConfigApplyOptions {
|
||||
pub force: bool,
|
||||
pub preserve_service_state: bool,
|
||||
pub runtime_only: bool,
|
||||
}
|
||||
|
||||
impl ConfigApplyOptions {
|
||||
pub const fn forced() -> Self {
|
||||
Self { force: true }
|
||||
Self {
|
||||
force: true,
|
||||
preserve_service_state: false,
|
||||
runtime_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn preserving_service_state() -> Self {
|
||||
Self {
|
||||
force: false,
|
||||
preserve_service_state: true,
|
||||
runtime_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn runtime_only() -> Self {
|
||||
Self {
|
||||
force: false,
|
||||
preserve_service_state: false,
|
||||
runtime_only: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,21 +69,24 @@ fn hid_otg_config_changed(old_config: &HidConfig, new_config: &HidConfig) -> boo
|
||||
|| old_config.otg_descriptor != new_config.otg_descriptor
|
||||
|| old_config.constrained_otg_functions() != new_config.constrained_otg_functions()
|
||||
|| old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds()
|
||||
|| old_config.resolved_otg_endpoint_limit() != new_config.resolved_otg_endpoint_limit()
|
||||
}
|
||||
|
||||
async fn reconcile_otg_from_store(state: &Arc<AppState>) -> Result<()> {
|
||||
async fn reconcile_otg_config(
|
||||
state: &Arc<AppState>,
|
||||
hid: &HidConfig,
|
||||
msd: &MsdConfig,
|
||||
network: &OtgNetworkConfig,
|
||||
) -> Result<()> {
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = state;
|
||||
let _ = (state, hid, msd, network);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let config = state.config.get();
|
||||
state
|
||||
.otg_service
|
||||
.apply_config(&config.hid, &config.msd)
|
||||
.apply_config(hid, msd, network)
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("OTG reconcile failed: {}", e)))
|
||||
}
|
||||
@@ -167,11 +192,11 @@ pub async fn apply_hid_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &HidConfig,
|
||||
new_config: &HidConfig,
|
||||
msd_config: &MsdConfig,
|
||||
network_config: &OtgNetworkConfig,
|
||||
options: ConfigApplyOptions,
|
||||
) -> Result<()> {
|
||||
let current_config = state.config.get();
|
||||
let current_msd_enabled = current_config.msd.enabled && new_config.backend == HidBackend::Otg;
|
||||
new_config.validate_otg_endpoint_budget(current_msd_enabled)?;
|
||||
new_config.validate_otg_functions()?;
|
||||
|
||||
let descriptor_changed = old_config.otg_descriptor != new_config.otg_descriptor;
|
||||
let old_hid_functions = old_config.constrained_otg_functions();
|
||||
@@ -179,8 +204,6 @@ pub async fn apply_hid_config(
|
||||
let hid_functions_changed = old_hid_functions != new_hid_functions;
|
||||
let keyboard_leds_changed =
|
||||
old_config.effective_otg_keyboard_leds() != new_config.effective_otg_keyboard_leds();
|
||||
let endpoint_budget_changed =
|
||||
old_config.resolved_otg_endpoint_limit() != new_config.resolved_otg_endpoint_limit();
|
||||
let ch9329_runtime_changed = old_config.ch9329_hybrid_mouse != new_config.ch9329_hybrid_mouse;
|
||||
|
||||
if old_config.backend == new_config.backend
|
||||
@@ -191,7 +214,6 @@ pub async fn apply_hid_config(
|
||||
&& !descriptor_changed
|
||||
&& !hid_functions_changed
|
||||
&& !keyboard_leds_changed
|
||||
&& !endpoint_budget_changed
|
||||
&& !options.force
|
||||
{
|
||||
tracing::info!("HID config unchanged, skipping reload");
|
||||
@@ -214,7 +236,7 @@ pub async fn apply_hid_config(
|
||||
}
|
||||
|
||||
if otg_config_changed {
|
||||
reconcile_otg_from_store(state).await?;
|
||||
reconcile_otg_config(state, new_config, msd_config, network_config).await?;
|
||||
}
|
||||
|
||||
if !transitioning_away_from_otg {
|
||||
@@ -238,14 +260,12 @@ pub async fn apply_msd_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &MsdConfig,
|
||||
new_config: &MsdConfig,
|
||||
hid_config: &HidConfig,
|
||||
network_config: &OtgNetworkConfig,
|
||||
options: ConfigApplyOptions,
|
||||
) -> Result<()> {
|
||||
let current_config = state.config.get();
|
||||
let hid_backend_is_otg = current_config.hid.backend == HidBackend::Otg;
|
||||
let hid_backend_is_otg = hid_config.backend == HidBackend::Otg;
|
||||
let effective_new_msd_enabled = new_config.enabled && hid_backend_is_otg;
|
||||
current_config
|
||||
.hid
|
||||
.validate_otg_endpoint_budget(effective_new_msd_enabled)?;
|
||||
|
||||
tracing::info!("MSD config sent, checking if reload needed...");
|
||||
tracing::debug!("Old MSD config: {:?}", old_config);
|
||||
@@ -284,20 +304,21 @@ pub async fn apply_msd_config(
|
||||
if new_msd_enabled {
|
||||
tracing::info!("(Re)initializing MSD...");
|
||||
|
||||
reconcile_otg_from_store(state).await?;
|
||||
reconcile_otg_config(state, hid_config, new_config, network_config).await?;
|
||||
|
||||
let mut msd_guard = state.msd.write().await;
|
||||
if let Some(msd) = msd_guard.as_mut() {
|
||||
if let Err(e) = msd.shutdown().await {
|
||||
tracing::warn!("MSD shutdown failed: {}", e);
|
||||
}
|
||||
msd.shutdown()
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("MSD shutdown failed: {e}")))?;
|
||||
}
|
||||
*msd_guard = None;
|
||||
drop(msd_guard);
|
||||
|
||||
let msd =
|
||||
crate::msd::MsdController::new(state.otg_service.clone(), new_config.msd_dir_path());
|
||||
msd.init()
|
||||
let ventoy_resource_dir = state.data_dir().join("ventoy");
|
||||
msd.init(&ventoy_resource_dir)
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("MSD initialization failed: {}", e)))?;
|
||||
|
||||
@@ -311,18 +332,17 @@ pub async fn apply_msd_config(
|
||||
|
||||
let mut msd_guard = state.msd.write().await;
|
||||
if let Some(msd) = msd_guard.as_mut() {
|
||||
if let Err(e) = msd.shutdown().await {
|
||||
tracing::warn!("MSD shutdown failed: {}", e);
|
||||
}
|
||||
msd.shutdown()
|
||||
.await
|
||||
.map_err(|e| AppError::Config(format!("MSD shutdown failed: {e}")))?;
|
||||
}
|
||||
*msd_guard = None;
|
||||
tracing::info!("MSD shutdown complete");
|
||||
|
||||
reconcile_otg_from_store(state).await?;
|
||||
reconcile_otg_config(state, hid_config, new_config, network_config).await?;
|
||||
}
|
||||
|
||||
let current_config = state.config.get();
|
||||
if current_config.hid.backend == HidBackend::Otg
|
||||
if hid_config.backend == HidBackend::Otg
|
||||
&& (options.force || old_msd_enabled != new_msd_enabled)
|
||||
{
|
||||
state
|
||||
@@ -335,6 +355,55 @@ pub async fn apply_msd_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub async fn apply_otg_config(
|
||||
state: &Arc<AppState>,
|
||||
old_config: &AppConfig,
|
||||
new_config: &AppConfig,
|
||||
) -> Result<()> {
|
||||
let transitioning_away_from_otg =
|
||||
old_config.hid.backend == HidBackend::Otg && new_config.hid.backend != HidBackend::Otg;
|
||||
|
||||
if transitioning_away_from_otg {
|
||||
apply_hid_config(
|
||||
state,
|
||||
&old_config.hid,
|
||||
&new_config.hid,
|
||||
&new_config.msd,
|
||||
&new_config.otg_network,
|
||||
ConfigApplyOptions::default(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
reconcile_otg_config(
|
||||
state,
|
||||
&new_config.hid,
|
||||
&new_config.msd,
|
||||
&new_config.otg_network,
|
||||
)
|
||||
.await?;
|
||||
apply_hid_config(
|
||||
state,
|
||||
&old_config.hid,
|
||||
&new_config.hid,
|
||||
&new_config.msd,
|
||||
&new_config.otg_network,
|
||||
ConfigApplyOptions::default(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
apply_msd_config(
|
||||
state,
|
||||
&old_config.msd,
|
||||
&new_config.msd,
|
||||
&new_config.hid,
|
||||
&new_config.otg_network,
|
||||
ConfigApplyOptions::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn apply_atx_config(
|
||||
state: &Arc<AppState>,
|
||||
_old_config: &AtxConfig,
|
||||
@@ -403,13 +472,27 @@ pub async fn apply_audio_config(
|
||||
}
|
||||
|
||||
pub async fn enforce_stream_codec_constraints(state: &Arc<AppState>) -> Result<Option<String>> {
|
||||
let config = state.config.get();
|
||||
let config = state.runtime_third_party_config().await;
|
||||
let constraints = StreamCodecConstraints::from_config(&config);
|
||||
state
|
||||
.stream_manager
|
||||
.set_runtime_codec_constraints(constraints.clone())
|
||||
.await;
|
||||
let enforcement =
|
||||
enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await?;
|
||||
Ok(enforcement.message)
|
||||
}
|
||||
|
||||
async fn validate_runtime_candidate<T>(
|
||||
state: &Arc<AppState>,
|
||||
apply: impl FnOnce(&mut crate::config::AppConfig, T),
|
||||
config: T,
|
||||
) -> Result<()> {
|
||||
let mut candidate = state.runtime_third_party_config().await;
|
||||
apply(&mut candidate, config);
|
||||
validate_third_party_codec_compatibility(&candidate)
|
||||
}
|
||||
|
||||
fn validate_rustdesk_candidate(
|
||||
state: &Arc<AppState>,
|
||||
new_config: &crate::rustdesk::config::RustDeskConfig,
|
||||
@@ -439,12 +522,26 @@ pub async fn apply_rustdesk_config(
|
||||
) -> Result<()> {
|
||||
tracing::info!("Applying RustDesk config changes...");
|
||||
|
||||
validate_rustdesk_candidate(state, new_config)?;
|
||||
if options.runtime_only {
|
||||
validate_runtime_candidate(
|
||||
state,
|
||||
|candidate, config| candidate.rustdesk = config,
|
||||
new_config.clone(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
validate_rustdesk_candidate(state, new_config)?;
|
||||
}
|
||||
|
||||
let mut rustdesk_guard = state.rustdesk.write().await;
|
||||
let mut credentials_to_save = None;
|
||||
let need_restart = options.force
|
||||
|| old_config.codec != new_config.codec
|
||||
|| old_config.rendezvous_server != new_config.rendezvous_server
|
||||
|| old_config.device_id != new_config.device_id
|
||||
|| old_config.device_password != new_config.device_password;
|
||||
|
||||
if !new_config.enabled {
|
||||
if !options.preserve_service_state && !new_config.enabled {
|
||||
if let Some(ref service) = *rustdesk_guard {
|
||||
service
|
||||
.stop()
|
||||
@@ -455,39 +552,51 @@ pub async fn apply_rustdesk_config(
|
||||
*rustdesk_guard = None;
|
||||
}
|
||||
|
||||
if new_config.enabled {
|
||||
let need_restart = options.force
|
||||
|| old_config.codec != new_config.codec
|
||||
|| old_config.rendezvous_server != new_config.rendezvous_server
|
||||
|| old_config.device_id != new_config.device_id
|
||||
|| old_config.device_password != new_config.device_password;
|
||||
|
||||
if !options.preserve_service_state && new_config.enabled {
|
||||
if rustdesk_guard.is_none() {
|
||||
tracing::info!("Initializing RustDesk service...");
|
||||
let service = crate::rustdesk::RustDeskService::new(
|
||||
let service = std::sync::Arc::new(crate::rustdesk::RustDeskService::new(
|
||||
new_config.clone(),
|
||||
state.stream_manager.clone(),
|
||||
state.hid.clone(),
|
||||
state.audio.clone(),
|
||||
);
|
||||
));
|
||||
*rustdesk_guard = Some(service.clone());
|
||||
service.start().await.map_err(|e| {
|
||||
AppError::Config(format!("Failed to start RustDesk service: {}", e))
|
||||
})?;
|
||||
tracing::info!("RustDesk service started with ID: {}", new_config.device_id);
|
||||
credentials_to_save = service.save_credentials();
|
||||
*rustdesk_guard = Some(std::sync::Arc::new(service));
|
||||
} else if need_restart {
|
||||
} else {
|
||||
if let Some(ref service) = *rustdesk_guard {
|
||||
service.restart(new_config.clone()).await.map_err(|e| {
|
||||
AppError::Config(format!("Failed to restart RustDesk service: {}", e))
|
||||
})?;
|
||||
tracing::info!(
|
||||
"RustDesk service restarted with ID: {}",
|
||||
new_config.device_id
|
||||
);
|
||||
if service.is_listening() {
|
||||
if need_restart {
|
||||
service.restart(new_config.clone()).await.map_err(|e| {
|
||||
AppError::Config(format!("Failed to restart RustDesk service: {}", e))
|
||||
})?;
|
||||
tracing::info!(
|
||||
"RustDesk service restarted with ID: {}",
|
||||
new_config.device_id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
service.update_config(new_config.clone());
|
||||
service.start().await.map_err(|e| {
|
||||
AppError::Config(format!("Failed to start RustDesk service: {}", e))
|
||||
})?;
|
||||
}
|
||||
credentials_to_save = service.save_credentials();
|
||||
}
|
||||
}
|
||||
} else if options.preserve_service_state && need_restart {
|
||||
if let Some(ref service) = *rustdesk_guard {
|
||||
let mut runtime_config = new_config.clone();
|
||||
runtime_config.enabled = true;
|
||||
service.restart(runtime_config).await.map_err(|e| {
|
||||
AppError::Config(format!("Failed to restart RustDesk service: {}", e))
|
||||
})?;
|
||||
credentials_to_save = service.save_credentials();
|
||||
}
|
||||
}
|
||||
|
||||
drop(rustdesk_guard);
|
||||
@@ -521,11 +630,27 @@ pub async fn apply_vnc_config(
|
||||
) -> Result<()> {
|
||||
tracing::info!("Applying VNC config changes...");
|
||||
|
||||
validate_vnc_candidate(state, new_config)?;
|
||||
if options.runtime_only {
|
||||
validate_runtime_candidate(
|
||||
state,
|
||||
|candidate, config| candidate.vnc = config,
|
||||
new_config.clone(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
validate_vnc_candidate(state, new_config)?;
|
||||
}
|
||||
|
||||
if new_config.enabled {
|
||||
let mut candidate = state.config.get().as_ref().clone();
|
||||
let runtime_config = state.runtime_third_party_config().await;
|
||||
let will_run = if options.preserve_service_state {
|
||||
runtime_config.vnc.enabled
|
||||
} else {
|
||||
new_config.enabled
|
||||
};
|
||||
if will_run {
|
||||
let mut candidate = runtime_config;
|
||||
candidate.vnc = new_config.clone();
|
||||
candidate.vnc.enabled = true;
|
||||
let constraints = StreamCodecConstraints::from_config(&candidate);
|
||||
match enforce_constraints_with_stream_manager(&state.stream_manager, &constraints).await {
|
||||
Ok(result) if result.changed => {
|
||||
@@ -542,38 +667,52 @@ pub async fn apply_vnc_config(
|
||||
}
|
||||
|
||||
let mut vnc_guard = state.vnc.write().await;
|
||||
let need_restart = options.force
|
||||
|| old_config.bind != new_config.bind
|
||||
|| old_config.port != new_config.port
|
||||
|| old_config.encoding != new_config.encoding
|
||||
|| old_config.password != new_config.password
|
||||
|| old_config.allow_one_client != new_config.allow_one_client;
|
||||
|
||||
if !new_config.enabled {
|
||||
if !options.preserve_service_state && !new_config.enabled {
|
||||
if let Some(ref service) = *vnc_guard {
|
||||
service.stop().await?;
|
||||
}
|
||||
*vnc_guard = None;
|
||||
}
|
||||
|
||||
if new_config.enabled {
|
||||
let need_restart = options.force
|
||||
|| old_config.bind != new_config.bind
|
||||
|| old_config.port != new_config.port
|
||||
|| old_config.encoding != new_config.encoding
|
||||
|| old_config.password != new_config.password
|
||||
|| old_config.jpeg_quality != new_config.jpeg_quality
|
||||
|| old_config.allow_one_client != new_config.allow_one_client;
|
||||
|
||||
if !options.preserve_service_state && new_config.enabled {
|
||||
if vnc_guard.is_none() {
|
||||
let service = crate::vnc::VncService::new(
|
||||
let service = Arc::new(crate::vnc::VncService::new(
|
||||
new_config.clone(),
|
||||
state.stream_manager.clone(),
|
||||
state.hid.clone(),
|
||||
);
|
||||
));
|
||||
*vnc_guard = Some(service.clone());
|
||||
service.start().await?;
|
||||
*vnc_guard = Some(Arc::new(service));
|
||||
tracing::info!("VNC service started");
|
||||
} else if need_restart {
|
||||
} else {
|
||||
if let Some(ref service) = *vnc_guard {
|
||||
service.restart(new_config.clone()).await?;
|
||||
tracing::info!("VNC service restarted");
|
||||
if matches!(
|
||||
service.status().await,
|
||||
crate::vnc::VncServiceStatus::Running
|
||||
) {
|
||||
if need_restart {
|
||||
service.restart(new_config.clone()).await?;
|
||||
tracing::info!("VNC service restarted");
|
||||
}
|
||||
} else {
|
||||
service.update_config(new_config.clone()).await;
|
||||
service.start().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if options.preserve_service_state && need_restart {
|
||||
if let Some(ref service) = *vnc_guard {
|
||||
let mut runtime_config = new_config.clone();
|
||||
runtime_config.enabled = true;
|
||||
service.restart(runtime_config).await?;
|
||||
}
|
||||
}
|
||||
|
||||
drop(vnc_guard);
|
||||
@@ -592,11 +731,28 @@ pub async fn apply_rtsp_config(
|
||||
) -> Result<()> {
|
||||
tracing::info!("Applying RTSP config changes...");
|
||||
|
||||
validate_rtsp_candidate(state, new_config)?;
|
||||
if options.runtime_only {
|
||||
validate_runtime_candidate(
|
||||
state,
|
||||
|candidate, config| candidate.rtsp = config,
|
||||
new_config.clone(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
validate_rtsp_candidate(state, new_config)?;
|
||||
}
|
||||
|
||||
let mut rtsp_guard = state.rtsp.write().await;
|
||||
let need_restart = options.force
|
||||
|| old_config.bind != new_config.bind
|
||||
|| old_config.port != new_config.port
|
||||
|| old_config.path != new_config.path
|
||||
|| old_config.codec != new_config.codec
|
||||
|| old_config.username != new_config.username
|
||||
|| old_config.password != new_config.password
|
||||
|| old_config.allow_one_client != new_config.allow_one_client;
|
||||
|
||||
if !new_config.enabled {
|
||||
if !options.preserve_service_state && !new_config.enabled {
|
||||
if let Some(ref service) = *rtsp_guard {
|
||||
service
|
||||
.stop()
|
||||
@@ -606,27 +762,37 @@ pub async fn apply_rtsp_config(
|
||||
*rtsp_guard = None;
|
||||
}
|
||||
|
||||
if new_config.enabled {
|
||||
let need_restart = options.force
|
||||
|| old_config.bind != new_config.bind
|
||||
|| old_config.port != new_config.port
|
||||
|| old_config.path != new_config.path
|
||||
|| old_config.codec != new_config.codec
|
||||
|| old_config.username != new_config.username
|
||||
|| old_config.password != new_config.password
|
||||
|| old_config.allow_one_client != new_config.allow_one_client;
|
||||
|
||||
if !options.preserve_service_state && new_config.enabled {
|
||||
if rtsp_guard.is_none() {
|
||||
let service = RtspService::new(new_config.clone(), state.stream_manager.clone());
|
||||
let service = Arc::new(RtspService::new(
|
||||
new_config.clone(),
|
||||
state.stream_manager.clone(),
|
||||
));
|
||||
*rtsp_guard = Some(service.clone());
|
||||
service.start().await?;
|
||||
tracing::info!("RTSP service started");
|
||||
*rtsp_guard = Some(Arc::new(service));
|
||||
} else if need_restart {
|
||||
} else {
|
||||
if let Some(ref service) = *rtsp_guard {
|
||||
service.restart(new_config.clone()).await?;
|
||||
tracing::info!("RTSP service restarted");
|
||||
if matches!(
|
||||
service.status().await,
|
||||
crate::rtsp::RtspServiceStatus::Running
|
||||
) {
|
||||
if need_restart {
|
||||
service.restart(new_config.clone()).await?;
|
||||
tracing::info!("RTSP service restarted");
|
||||
}
|
||||
} else {
|
||||
service.update_config(new_config.clone()).await;
|
||||
service.start().await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if options.preserve_service_state && need_restart {
|
||||
if let Some(ref service) = *rtsp_guard {
|
||||
let mut runtime_config = new_config.clone();
|
||||
runtime_config.enabled = true;
|
||||
service.restart(runtime_config).await?;
|
||||
}
|
||||
}
|
||||
|
||||
drop(rtsp_guard);
|
||||
|
||||
@@ -7,11 +7,8 @@ use crate::state::AppState;
|
||||
|
||||
use super::types::AuthConfigUpdate;
|
||||
|
||||
/// Get auth configuration (sensitive fields are cleared)
|
||||
pub async fn get_auth_config(State(state): State<Arc<AppState>>) -> Json<AuthConfig> {
|
||||
let mut auth = state.config.get().auth.clone();
|
||||
auth.totp_secret = None;
|
||||
Json(auth)
|
||||
Json(state.config.get().auth.clone())
|
||||
}
|
||||
|
||||
pub async fn update_auth_config(
|
||||
@@ -26,7 +23,5 @@ pub async fn update_auth_config(
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut auth = state.config.get().auth.clone();
|
||||
auth.totp_secret = None;
|
||||
Ok(Json(auth))
|
||||
Ok(Json(state.config.get().auth.clone()))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use axum::{extract::State, Json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::{HidBackend, HidConfig};
|
||||
use crate::config::HidConfig;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::{apply_hid_config, try_apply_lock, ConfigApplyOptions};
|
||||
use super::types::HidConfigUpdate;
|
||||
use super::otg::update_otg_config_inner;
|
||||
use super::types::{HidConfigUpdate, OtgConfigUpdate};
|
||||
|
||||
pub async fn get_hid_config(State(state): State<Arc<AppState>>) -> Json<HidConfig> {
|
||||
Json(state.config.get().hid.clone())
|
||||
@@ -16,54 +16,13 @@ pub async fn update_hid_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<HidConfigUpdate>,
|
||||
) -> Result<Json<HidConfig>> {
|
||||
req.validate()?;
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?;
|
||||
let old_hid_config = state.config.get().hid.clone();
|
||||
|
||||
let mut staged_hid_config = old_hid_config.clone();
|
||||
req.apply_to(&mut staged_hid_config);
|
||||
let descriptor_update = req
|
||||
.ch9329_descriptor
|
||||
.as_ref()
|
||||
.map(|_| staged_hid_config.ch9329_descriptor.clone());
|
||||
if descriptor_update.is_some() {
|
||||
staged_hid_config.ch9329_descriptor = old_hid_config.ch9329_descriptor.clone();
|
||||
}
|
||||
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
config.hid = staged_hid_config.clone();
|
||||
config.enforce_invariants();
|
||||
})
|
||||
.await?;
|
||||
|
||||
let new_hid_config = state.config.get().hid.clone();
|
||||
|
||||
apply_hid_config(
|
||||
let response = update_otg_config_inner(
|
||||
&state,
|
||||
&old_hid_config,
|
||||
&new_hid_config,
|
||||
ConfigApplyOptions::forced(),
|
||||
OtgConfigUpdate {
|
||||
hid: Some(req),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(descriptor) = descriptor_update {
|
||||
if new_hid_config.backend != HidBackend::Ch9329 {
|
||||
return Ok(Json(new_hid_config));
|
||||
}
|
||||
|
||||
let actual = state.hid.apply_ch9329_descriptor(&descriptor).await?;
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
config.hid.ch9329_descriptor = actual.descriptor.clone();
|
||||
config.enforce_invariants();
|
||||
})
|
||||
.await?;
|
||||
return Ok(Json(state.config.get().hid.clone()));
|
||||
}
|
||||
|
||||
Ok(Json(new_hid_config))
|
||||
Ok(Json(response.hid))
|
||||
}
|
||||
|
||||
@@ -7,12 +7,17 @@ mod auth;
|
||||
mod hid;
|
||||
#[cfg(unix)]
|
||||
mod msd;
|
||||
#[cfg(unix)]
|
||||
mod otg;
|
||||
#[cfg(unix)]
|
||||
mod otg_network;
|
||||
mod redfish;
|
||||
mod rtsp;
|
||||
mod rustdesk;
|
||||
mod stream;
|
||||
pub(crate) mod video;
|
||||
mod vnc;
|
||||
mod watchdog;
|
||||
mod web;
|
||||
|
||||
pub use atx::{get_atx_config, update_atx_config};
|
||||
@@ -21,6 +26,10 @@ pub use auth::{get_auth_config, update_auth_config};
|
||||
pub use hid::{get_hid_config, update_hid_config};
|
||||
#[cfg(unix)]
|
||||
pub use msd::{get_msd_config, update_msd_config};
|
||||
#[cfg(unix)]
|
||||
pub use otg::update_otg_config;
|
||||
#[cfg(unix)]
|
||||
pub use otg_network::{get_otg_network_config, get_otg_network_status, update_otg_network_config};
|
||||
pub use redfish::{get_redfish_config, update_redfish_config};
|
||||
pub use rtsp::{
|
||||
get_rtsp_config, get_rtsp_status, start_rtsp_service, stop_rtsp_service, update_rtsp_config,
|
||||
@@ -35,6 +44,7 @@ pub use video::{get_video_config, update_video_config};
|
||||
pub use vnc::{
|
||||
get_vnc_config, get_vnc_status, start_vnc_service, stop_vnc_service, update_vnc_config,
|
||||
};
|
||||
pub use watchdog::{get_watchdog_config, update_watchdog_config};
|
||||
pub use web::{get_web_config, update_web_config};
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
@@ -44,10 +54,8 @@ use crate::config::AppConfig;
|
||||
use crate::state::AppState;
|
||||
|
||||
fn sanitize_config_for_api(config: &mut AppConfig) {
|
||||
config.auth.totp_secret = None;
|
||||
|
||||
config.stream.turn_password = None;
|
||||
config.computer_use.openai_api_key = None;
|
||||
config.computer_use.api_key = None;
|
||||
|
||||
config.rustdesk.device_password.clear();
|
||||
config.rustdesk.relay_key = None;
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::config::MsdConfig;
|
||||
use crate::error::Result;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::{apply_msd_config, try_apply_lock, ConfigApplyOptions};
|
||||
use super::types::MsdConfigUpdate;
|
||||
use super::otg::update_otg_config_inner;
|
||||
use super::types::{MsdConfigUpdate, OtgConfigUpdate};
|
||||
|
||||
pub async fn get_msd_config(State(state): State<Arc<AppState>>) -> Json<MsdConfig> {
|
||||
Json(state.config.get().msd.clone())
|
||||
@@ -16,28 +16,13 @@ pub async fn update_msd_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<MsdConfigUpdate>,
|
||||
) -> Result<Json<MsdConfig>> {
|
||||
req.validate()?;
|
||||
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?;
|
||||
let old_msd_config = state.config.get().msd.clone();
|
||||
|
||||
state
|
||||
.config
|
||||
.update(|config| {
|
||||
req.apply_to(&mut config.msd);
|
||||
config.enforce_invariants();
|
||||
})
|
||||
.await?;
|
||||
|
||||
let new_msd_config = state.config.get().msd.clone();
|
||||
|
||||
apply_msd_config(
|
||||
let response = update_otg_config_inner(
|
||||
&state,
|
||||
&old_msd_config,
|
||||
&new_msd_config,
|
||||
ConfigApplyOptions::forced(),
|
||||
OtgConfigUpdate {
|
||||
msd: Some(req),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(new_msd_config))
|
||||
Ok(Json(response.msd))
|
||||
}
|
||||
|
||||
166
src/web/handlers/config/otg.rs
Normal file
166
src/web/handlers/config/otg.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
use serde::Serialize;
|
||||
use typeshare::typeshare;
|
||||
|
||||
use crate::config::{HidBackend, HidConfig, MsdConfig, OtgNetworkConfig};
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::otg::OtgNetworkStatus;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::{apply_otg_config, try_apply_lock};
|
||||
use super::types::OtgConfigUpdate;
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OtgConfigResponse {
|
||||
pub hid: HidConfig,
|
||||
pub msd: MsdConfig,
|
||||
pub network: OtgNetworkConfig,
|
||||
pub status: OtgNetworkStatus,
|
||||
}
|
||||
|
||||
pub async fn update_otg_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<OtgConfigUpdate>,
|
||||
) -> Result<Json<OtgConfigResponse>> {
|
||||
update_otg_config_inner(&state, request).await.map(Json)
|
||||
}
|
||||
|
||||
pub(super) async fn update_otg_config_inner(
|
||||
state: &Arc<AppState>,
|
||||
request: OtgConfigUpdate,
|
||||
) -> Result<OtgConfigResponse> {
|
||||
let _guard = try_apply_lock(&state.config_apply_locks.otg, "otg")?;
|
||||
|
||||
if let Some(ref update) = request.hid {
|
||||
update.validate()?;
|
||||
}
|
||||
if let Some(ref update) = request.msd {
|
||||
update.validate()?;
|
||||
}
|
||||
|
||||
let old_config = state.config.get();
|
||||
let mut staged_config = (*old_config).clone();
|
||||
let requested_ch9329_descriptor = request.hid.as_ref().and_then(|update| {
|
||||
update.ch9329_descriptor.as_ref().map(|_| {
|
||||
let mut hid = staged_config.hid.clone();
|
||||
update.apply_to(&mut hid);
|
||||
hid.ch9329_descriptor
|
||||
})
|
||||
});
|
||||
|
||||
if let Some(ref update) = request.hid {
|
||||
update.apply_to(&mut staged_config.hid);
|
||||
}
|
||||
if requested_ch9329_descriptor.is_some() {
|
||||
staged_config.hid.ch9329_descriptor = old_config.hid.ch9329_descriptor.clone();
|
||||
}
|
||||
if let Some(ref update) = request.msd {
|
||||
update.apply_to(&mut staged_config.msd);
|
||||
}
|
||||
if let Some(ref update) = request.network {
|
||||
update.apply_to(&mut staged_config.otg_network);
|
||||
}
|
||||
staged_config.enforce_invariants();
|
||||
|
||||
if staged_config.otg_network.enabled
|
||||
&& (staged_config.otg_network.device_mac.is_empty()
|
||||
|| staged_config.otg_network.host_mac.is_empty())
|
||||
{
|
||||
let (device_mac, host_mac) =
|
||||
crate::otg::network::resolved_mac_pair(&staged_config.otg_network);
|
||||
staged_config.otg_network.device_mac = device_mac;
|
||||
staged_config.otg_network.host_mac = host_mac;
|
||||
}
|
||||
staged_config.hid.validate_otg_functions()?;
|
||||
staged_config.otg_network.validate()?;
|
||||
|
||||
if let Err(error) = apply_otg_config(state, &old_config, &staged_config).await {
|
||||
return Err(rollback_after_failure(state, &staged_config, &old_config, error, false).await);
|
||||
}
|
||||
|
||||
let descriptor_was_applied = if let Some(ref descriptor) = requested_ch9329_descriptor {
|
||||
if staged_config.hid.backend == HidBackend::Ch9329 {
|
||||
match state.hid.apply_ch9329_descriptor(descriptor).await {
|
||||
Ok(actual) => {
|
||||
staged_config.hid.ch9329_descriptor = actual.descriptor;
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(rollback_after_failure(
|
||||
state,
|
||||
&staged_config,
|
||||
&old_config,
|
||||
error,
|
||||
true,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if let Err(error) = state
|
||||
.config
|
||||
.update(|config| {
|
||||
config.hid = staged_config.hid.clone();
|
||||
config.msd = staged_config.msd.clone();
|
||||
config.otg_network = staged_config.otg_network.clone();
|
||||
config.enforce_invariants();
|
||||
})
|
||||
.await
|
||||
{
|
||||
return Err(rollback_after_failure(
|
||||
state,
|
||||
&staged_config,
|
||||
&old_config,
|
||||
AppError::Config(format!("Failed to persist OTG config after apply: {error}")),
|
||||
descriptor_was_applied,
|
||||
)
|
||||
.await);
|
||||
}
|
||||
|
||||
Ok(OtgConfigResponse {
|
||||
hid: staged_config.hid,
|
||||
msd: staged_config.msd,
|
||||
network: staged_config.otg_network,
|
||||
status: state.otg_service.network_status().await,
|
||||
})
|
||||
}
|
||||
|
||||
async fn rollback_after_failure(
|
||||
state: &Arc<AppState>,
|
||||
failed_config: &crate::config::AppConfig,
|
||||
old_config: &crate::config::AppConfig,
|
||||
primary_error: AppError,
|
||||
restore_descriptor: bool,
|
||||
) -> AppError {
|
||||
let mut rollback_errors = Vec::new();
|
||||
|
||||
if let Err(error) = apply_otg_config(state, failed_config, old_config).await {
|
||||
rollback_errors.push(format!("runtime rollback failed: {error}"));
|
||||
}
|
||||
if restore_descriptor && old_config.hid.backend == HidBackend::Ch9329 {
|
||||
if let Err(error) = state
|
||||
.hid
|
||||
.apply_ch9329_descriptor(&old_config.hid.ch9329_descriptor)
|
||||
.await
|
||||
{
|
||||
rollback_errors.push(format!("CH9329 descriptor rollback failed: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
if rollback_errors.is_empty() {
|
||||
return primary_error;
|
||||
}
|
||||
|
||||
let message = format!("{primary_error}; {}", rollback_errors.join("; "));
|
||||
state.otg_service.mark_degraded(message.clone()).await;
|
||||
AppError::Config(message)
|
||||
}
|
||||
34
src/web/handlers/config/otg_network.rs
Normal file
34
src/web/handlers/config/otg_network.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
|
||||
use crate::config::OtgNetworkConfig;
|
||||
use crate::error::Result;
|
||||
use crate::otg::OtgNetworkStatus;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::otg::update_otg_config_inner;
|
||||
use super::types::{OtgConfigUpdate, OtgNetworkConfigUpdate};
|
||||
|
||||
pub async fn get_otg_network_config(State(state): State<Arc<AppState>>) -> Json<OtgNetworkConfig> {
|
||||
Json(state.config.get().otg_network.clone())
|
||||
}
|
||||
|
||||
pub async fn update_otg_network_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(request): Json<OtgNetworkConfigUpdate>,
|
||||
) -> Result<Json<OtgNetworkConfig>> {
|
||||
let response = update_otg_config_inner(
|
||||
&state,
|
||||
OtgConfigUpdate {
|
||||
network: Some(request),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(response.network))
|
||||
}
|
||||
|
||||
pub async fn get_otg_network_status(State(state): State<Arc<AppState>>) -> Json<OtgNetworkStatus> {
|
||||
Json(state.otg_service.network_status().await)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ async fn persist_and_apply(
|
||||
state,
|
||||
&old_config,
|
||||
&stored_config,
|
||||
ConfigApplyOptions::forced(),
|
||||
ConfigApplyOptions::preserving_service_state(),
|
||||
)
|
||||
.await?;
|
||||
Ok(stored_config)
|
||||
@@ -76,10 +76,17 @@ pub async fn start_rtsp_service(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<RtspStatusResponse>> {
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?;
|
||||
let current_config = state.config.get().rtsp.clone();
|
||||
let mut start_config = current_config.clone();
|
||||
let stored_config = state.config.get().rtsp.clone();
|
||||
let runtime_config = state.runtime_third_party_config().await.rtsp;
|
||||
let mut start_config = stored_config.clone();
|
||||
start_config.enabled = true;
|
||||
let stored_config = persist_and_apply(&state, current_config, start_config).await?;
|
||||
apply_rtsp_config(
|
||||
&state,
|
||||
&runtime_config,
|
||||
&start_config,
|
||||
ConfigApplyOptions::runtime_only(),
|
||||
)
|
||||
.await?;
|
||||
let status = current_status(&state).await;
|
||||
|
||||
Ok(Json(RtspStatusResponse::new(&stored_config, status)))
|
||||
@@ -89,11 +96,17 @@ pub async fn stop_rtsp_service(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<RtspStatusResponse>> {
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.rtsp, "rtsp")?;
|
||||
let current_config = state.config.get().rtsp.clone();
|
||||
let mut stop_config = current_config.clone();
|
||||
let stored_config = state.config.get().rtsp.clone();
|
||||
let runtime_config = state.runtime_third_party_config().await.rtsp;
|
||||
let mut stop_config = stored_config.clone();
|
||||
stop_config.enabled = false;
|
||||
|
||||
let stored_config = persist_and_apply(&state, current_config, stop_config).await?;
|
||||
apply_rtsp_config(
|
||||
&state,
|
||||
&runtime_config,
|
||||
&stop_config,
|
||||
ConfigApplyOptions::runtime_only(),
|
||||
)
|
||||
.await?;
|
||||
let status = current_status(&state).await;
|
||||
|
||||
Ok(Json(RtspStatusResponse::new(&stored_config, status)))
|
||||
|
||||
@@ -31,7 +31,7 @@ async fn persist_and_apply(
|
||||
state,
|
||||
&old_config,
|
||||
&stored_config,
|
||||
ConfigApplyOptions::forced(),
|
||||
ConfigApplyOptions::preserving_service_state(),
|
||||
)
|
||||
.await?;
|
||||
Ok(stored_config)
|
||||
@@ -168,10 +168,18 @@ pub async fn start_rustdesk_service(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<RustDeskStatusResponse>> {
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?;
|
||||
let current_config = state.config.get().rustdesk.clone();
|
||||
let mut start_config = current_config.clone();
|
||||
let stored_config = state.config.get().rustdesk.clone();
|
||||
let runtime_config = state.runtime_third_party_config().await.rustdesk;
|
||||
let mut start_config = stored_config.clone();
|
||||
start_config.enabled = true;
|
||||
let stored_config = persist_and_apply(&state, current_config, start_config).await?;
|
||||
apply_rustdesk_config(
|
||||
&state,
|
||||
&runtime_config,
|
||||
&start_config,
|
||||
ConfigApplyOptions::runtime_only(),
|
||||
)
|
||||
.await?;
|
||||
let stored_config = state.config.get().rustdesk.clone();
|
||||
Ok(Json(current_status(&state, stored_config).await))
|
||||
}
|
||||
|
||||
@@ -179,10 +187,16 @@ pub async fn stop_rustdesk_service(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<RustDeskStatusResponse>> {
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.rustdesk, "rustdesk")?;
|
||||
let current_config = state.config.get().rustdesk.clone();
|
||||
let mut stop_config = current_config.clone();
|
||||
let stored_config = state.config.get().rustdesk.clone();
|
||||
let runtime_config = state.runtime_third_party_config().await.rustdesk;
|
||||
let mut stop_config = stored_config.clone();
|
||||
stop_config.enabled = false;
|
||||
|
||||
let stored_config = persist_and_apply(&state, current_config, stop_config).await?;
|
||||
apply_rustdesk_config(
|
||||
&state,
|
||||
&runtime_config,
|
||||
&stop_config,
|
||||
ConfigApplyOptions::runtime_only(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(current_status(&state, stored_config).await))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,21 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use typeshare::typeshare;
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct WatchdogConfigUpdate {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WatchdogConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub supported: bool,
|
||||
pub running: bool,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthConfigUpdate {
|
||||
@@ -353,12 +368,20 @@ pub struct HidConfigUpdate {
|
||||
pub otg_udc: Option<String>,
|
||||
pub otg_descriptor: Option<OtgDescriptorConfigUpdate>,
|
||||
pub otg_profile: Option<OtgHidProfile>,
|
||||
pub otg_endpoint_budget: Option<OtgEndpointBudget>,
|
||||
pub otg_functions: Option<OtgHidFunctionsUpdate>,
|
||||
pub otg_keyboard_leds: Option<bool>,
|
||||
pub mouse_absolute: Option<bool>,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct OtgConfigUpdate {
|
||||
pub hid: Option<HidConfigUpdate>,
|
||||
pub msd: Option<MsdConfigUpdate>,
|
||||
pub network: Option<OtgNetworkConfigUpdate>,
|
||||
}
|
||||
|
||||
impl HidConfigUpdate {
|
||||
pub fn validate(&self) -> crate::error::Result<()> {
|
||||
if let Some(baudrate) = self.ch9329_baudrate {
|
||||
@@ -403,9 +426,6 @@ impl HidConfigUpdate {
|
||||
if let Some(profile) = self.otg_profile.clone() {
|
||||
config.otg_profile = profile;
|
||||
}
|
||||
if let Some(budget) = self.otg_endpoint_budget {
|
||||
config.otg_endpoint_budget = budget;
|
||||
}
|
||||
if let Some(ref functions) = self.otg_functions {
|
||||
functions.apply_to(&mut config.otg_functions);
|
||||
}
|
||||
@@ -418,6 +438,38 @@ impl HidConfigUpdate {
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OtgNetworkConfigUpdate {
|
||||
pub enabled: Option<bool>,
|
||||
pub driver_mode: Option<OtgNetworkDriverMode>,
|
||||
pub bridge_interface: Option<String>,
|
||||
pub host_mac: Option<String>,
|
||||
pub device_mac: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl OtgNetworkConfigUpdate {
|
||||
pub fn apply_to(&self, config: &mut OtgNetworkConfig) {
|
||||
if let Some(enabled) = self.enabled {
|
||||
config.enabled = enabled;
|
||||
}
|
||||
if let Some(driver_mode) = self.driver_mode {
|
||||
config.driver_mode = driver_mode;
|
||||
}
|
||||
if let Some(ref interface) = self.bridge_interface {
|
||||
config.bridge_interface = interface.trim().to_string();
|
||||
}
|
||||
if let Some(ref mac) = self.host_mac {
|
||||
config.host_mac = mac.trim().to_ascii_lowercase();
|
||||
}
|
||||
if let Some(ref mac) = self.device_mac {
|
||||
config.device_mac = mac.trim().to_ascii_lowercase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -941,7 +993,6 @@ pub struct VncConfigResponse {
|
||||
pub bind: String,
|
||||
pub port: u16,
|
||||
pub encoding: VncEncoding,
|
||||
pub jpeg_quality: u8,
|
||||
pub allow_one_client: bool,
|
||||
pub has_password: bool,
|
||||
}
|
||||
@@ -953,7 +1004,6 @@ impl From<&VncConfig> for VncConfigResponse {
|
||||
bind: config.bind.clone(),
|
||||
port: config.port,
|
||||
encoding: config.encoding.clone(),
|
||||
jpeg_quality: config.jpeg_quality,
|
||||
allow_one_client: config.allow_one_client,
|
||||
has_password: config.password.as_deref().is_some_and(|p| !p.is_empty()),
|
||||
}
|
||||
@@ -985,7 +1035,6 @@ pub struct VncConfigUpdate {
|
||||
pub bind: Option<String>,
|
||||
pub port: Option<u16>,
|
||||
pub encoding: Option<VncEncoding>,
|
||||
pub jpeg_quality: Option<u8>,
|
||||
pub allow_one_client: Option<bool>,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
@@ -1002,13 +1051,6 @@ impl VncConfigUpdate {
|
||||
return Err(AppError::BadRequest("VNC bind must be a valid IP".into()));
|
||||
}
|
||||
}
|
||||
if let Some(quality) = self.jpeg_quality {
|
||||
if !(10..=100).contains(&quality) {
|
||||
return Err(AppError::BadRequest(
|
||||
"VNC JPEG quality must be 10-100".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(ref password) = self.password {
|
||||
if !password.is_empty() && password.len() > 8 {
|
||||
return Err(AppError::BadRequest(
|
||||
@@ -1039,9 +1081,6 @@ impl VncConfigUpdate {
|
||||
if let Some(ref encoding) = self.encoding {
|
||||
config.encoding = encoding.clone();
|
||||
}
|
||||
if let Some(quality) = self.jpeg_quality {
|
||||
config.jpeg_quality = quality;
|
||||
}
|
||||
if let Some(allow_one_client) = self.allow_one_client {
|
||||
config.allow_one_client = allow_one_client;
|
||||
}
|
||||
@@ -1403,7 +1442,6 @@ mod tests {
|
||||
bind: Some(bind.to_string()),
|
||||
port: Some(5900),
|
||||
encoding: None,
|
||||
jpeg_quality: None,
|
||||
allow_one_client: None,
|
||||
password: None,
|
||||
};
|
||||
@@ -1422,7 +1460,6 @@ mod tests {
|
||||
bind: Some(bind.to_string()),
|
||||
port: Some(5900),
|
||||
encoding: None,
|
||||
jpeg_quality: None,
|
||||
allow_one_client: None,
|
||||
password: None,
|
||||
};
|
||||
@@ -1472,4 +1509,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_vnc_jpeg_quality_is_ignored_and_not_returned() {
|
||||
let config: VncConfig = serde_json::from_value(serde_json::json!({
|
||||
"enabled": false,
|
||||
"bind": "0.0.0.0",
|
||||
"port": 5900,
|
||||
"encoding": "tight_jpeg",
|
||||
"jpeg_quality": 37,
|
||||
"allow_one_client": true
|
||||
}))
|
||||
.expect("legacy VNC config should deserialize");
|
||||
let update: VncConfigUpdate = serde_json::from_value(serde_json::json!({
|
||||
"jpeg_quality": 37,
|
||||
"allow_one_client": false
|
||||
}))
|
||||
.expect("legacy VNC update should deserialize");
|
||||
assert_eq!(update.allow_one_client, Some(false));
|
||||
|
||||
let response = serde_json::to_value(VncConfigResponse::from(&config))
|
||||
.expect("VNC response should serialize");
|
||||
assert!(response.get("jpeg_quality").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ async fn persist_and_apply(
|
||||
state,
|
||||
&old_config,
|
||||
&stored_config,
|
||||
ConfigApplyOptions::forced(),
|
||||
ConfigApplyOptions::preserving_service_state(),
|
||||
)
|
||||
.await?;
|
||||
Ok(stored_config)
|
||||
@@ -76,13 +76,20 @@ pub async fn start_vnc_service(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<VncStatusResponse>> {
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.vnc, "vnc")?;
|
||||
let current_config = state.config.get().vnc.clone();
|
||||
let mut start_config = current_config.clone();
|
||||
let stored_config = state.config.get().vnc.clone();
|
||||
let runtime_config = state.runtime_third_party_config().await.vnc;
|
||||
let mut start_config = stored_config.clone();
|
||||
start_config.enabled = true;
|
||||
if start_config.password.as_deref().unwrap_or("").is_empty() {
|
||||
start_config.password = current_config.password.clone();
|
||||
start_config.password = stored_config.password.clone();
|
||||
}
|
||||
let stored_config = persist_and_apply(&state, current_config, start_config).await?;
|
||||
apply_vnc_config(
|
||||
&state,
|
||||
&runtime_config,
|
||||
&start_config,
|
||||
ConfigApplyOptions::runtime_only(),
|
||||
)
|
||||
.await?;
|
||||
let (status, connection_count) = current_status(&state).await;
|
||||
|
||||
Ok(Json(VncStatusResponse::new(
|
||||
@@ -96,11 +103,17 @@ pub async fn stop_vnc_service(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<VncStatusResponse>> {
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.vnc, "vnc")?;
|
||||
let current_config = state.config.get().vnc.clone();
|
||||
let mut stop_config = current_config.clone();
|
||||
let stored_config = state.config.get().vnc.clone();
|
||||
let runtime_config = state.runtime_third_party_config().await.vnc;
|
||||
let mut stop_config = stored_config.clone();
|
||||
stop_config.enabled = false;
|
||||
|
||||
let stored_config = persist_and_apply(&state, current_config, stop_config).await?;
|
||||
apply_vnc_config(
|
||||
&state,
|
||||
&runtime_config,
|
||||
&stop_config,
|
||||
ConfigApplyOptions::runtime_only(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(VncStatusResponse::new(
|
||||
&stored_config,
|
||||
|
||||
79
src/web/handlers/config/watchdog.rs
Normal file
79
src/web/handlers/config/watchdog.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{extract::State, Json};
|
||||
|
||||
use crate::error::{AppError, Result};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::apply::try_apply_lock;
|
||||
use super::types::{WatchdogConfigResponse, WatchdogConfigUpdate};
|
||||
|
||||
async fn response(state: &AppState) -> WatchdogConfigResponse {
|
||||
let runtime = state.watchdog.status().await;
|
||||
WatchdogConfigResponse {
|
||||
enabled: state.config.get().watchdog.enabled,
|
||||
supported: runtime.supported,
|
||||
running: runtime.running,
|
||||
reason: runtime.reason,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_watchdog_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Json<WatchdogConfigResponse> {
|
||||
Json(response(&state).await)
|
||||
}
|
||||
|
||||
pub async fn update_watchdog_config(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<WatchdogConfigUpdate>,
|
||||
) -> Result<Json<WatchdogConfigResponse>> {
|
||||
let _apply_guard = try_apply_lock(&state.config_apply_locks.watchdog, "watchdog")?;
|
||||
let old_enabled = state.config.get().watchdog.enabled;
|
||||
|
||||
if req.enabled {
|
||||
state.watchdog.enable().await.map_err(|error| {
|
||||
AppError::Config(format!("Failed to enable hardware watchdog: {error}"))
|
||||
})?;
|
||||
|
||||
if let Err(error) = state
|
||||
.config
|
||||
.update(|config| config.watchdog.enabled = true)
|
||||
.await
|
||||
{
|
||||
if !old_enabled {
|
||||
if let Err(disable_error) = state.watchdog.disable().await {
|
||||
tracing::error!(
|
||||
"Failed to roll back watchdog after persistence error: {}",
|
||||
disable_error
|
||||
);
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
} else {
|
||||
state.watchdog.disable().await.map_err(|error| {
|
||||
AppError::Config(format!(
|
||||
"Hardware watchdog cannot be safely disabled; keepalive continues: {error}"
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Err(error) = state
|
||||
.config
|
||||
.update(|config| config.watchdog.enabled = false)
|
||||
.await
|
||||
{
|
||||
if old_enabled {
|
||||
if let Err(enable_error) = state.watchdog.enable().await {
|
||||
tracing::error!(
|
||||
"Failed to restore watchdog after persistence error: {}",
|
||||
enable_error
|
||||
);
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(response(&state).await))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user