pub mod actions;
#[cfg(all(unix, feature = "libusb"))]
pub use actions::UsbMscProtocol;
#[cfg(all(unix, feature = "libusb"))]
pub mod disk;
pub mod image_dir;
#[cfg(all(unix, feature = "libusb"))]
pub mod fat16;
#[cfg(all(unix, feature = "libusb"))]
pub mod handler;
#[cfg(all(unix, feature = "libusb"))]
use anyhow::{Context, Result};
#[cfg(all(unix, feature = "libusb"))]
use std::collections::HashMap;
#[cfg(all(unix, feature = "libusb"))]
use std::net::SocketAddr;
#[cfg(all(unix, feature = "libusb"))]
use std::path::PathBuf;
#[cfg(all(unix, feature = "libusb"))]
use std::sync::Arc;
#[cfg(all(unix, feature = "libusb"))]
use tokio::sync::{mpsc, Mutex};
#[cfg(all(unix, feature = "libusb"))]
use tracing::{debug, error, info, warn};
#[cfg(all(unix, feature = "libusb"))]
use crate::console_error;
#[cfg(all(unix, feature = "libusb"))]
use crate::llm::action_helper::call_llm;
#[cfg(all(unix, feature = "libusb"))]
use crate::llm::ollama_client::OllamaClient;
#[cfg(all(unix, feature = "libusb"))]
use crate::protocol::Event;
#[cfg(all(unix, feature = "libusb"))]
use crate::server::accept_bounded::{accept_bounded, ConnectionLimiter};
#[cfg(all(unix, feature = "libusb"))]
use crate::server::connection::ConnectionId;
#[cfg(all(unix, feature = "libusb"))]
use crate::server::usb::guard::{MAX_USBIP_CONNECTIONS, USBIP_NO_REFUSAL};
#[cfg(all(unix, feature = "libusb"))]
use crate::state::app_state::AppState;
#[cfg(all(unix, feature = "libusb"))]
use actions::{
USB_MSC_ATTACHED_EVENT, USB_MSC_DETACHED_EVENT, USB_MSC_READ_EVENT, USB_MSC_WRITE_EVENT,
};
#[cfg(all(unix, feature = "libusb"))]
use handler::MscIoEvent;
#[cfg(all(unix, feature = "libusb"))]
const DEFAULT_DISK_SIZE_MB: u32 = 10;
#[cfg(all(unix, feature = "libusb"))]
#[derive(Debug, Clone, PartialEq)]
enum ConnectionState {
Idle,
Processing,
}
#[cfg(all(unix, feature = "libusb"))]
#[derive(Clone)]
struct ConnectionData {
state: ConnectionState,
}
#[cfg(all(unix, feature = "libusb"))]
pub struct UsbMscServer;
#[cfg(all(unix, feature = "libusb"))]
impl UsbMscServer {
#[allow(clippy::too_many_arguments)]
pub async fn spawn_with_llm_actions(
listen_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
image_dir: image_dir::ImageDir,
disk_image: Option<PathBuf>,
first_byte_timeout_secs: Option<u64>,
idle_timeout_secs: Option<u64>,
) -> Result<SocketAddr> {
let listener =
crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
let local_addr = listener.local_addr()?;
info!("USB Mass Storage server listening on {}", local_addr);
let _ = status_tx.send(format!(
"USB Mass Storage server listening on {}",
local_addr
));
let connections = Arc::new(Mutex::new(HashMap::new()));
let protocol = Arc::new(UsbMscProtocol::with_image_dir(image_dir));
let deadlines = crate::server::usb::guard::UsbIpDeadlines::from_secs(
first_byte_timeout_secs,
idle_timeout_secs,
);
let task_registrar = app_state.clone();
let limiter = ConnectionLimiter::new(MAX_USBIP_CONNECTIONS);
let accept_handle = tokio::spawn(async move {
loop {
match accept_bounded(
&listener,
&limiter,
USBIP_NO_REFUSAL,
"USB MSC",
Some(&status_tx),
)
.await
{
Ok((stream, remote_addr, permit)) => {
let connection_id =
ConnectionId::new(app_state.get_next_unified_id().await);
let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
info!(
"USB/IP connection {} from {} (USB MSC device)",
connection_id, remote_addr
);
use crate::state::server::{
ConnectionState as ServerConnectionState, ConnectionStatus,
ProtocolConnectionInfo,
};
let now = crate::utils::clock::Instant::now();
let conn_state = ServerConnectionState {
id: connection_id,
remote_addr,
local_addr: local_addr_conn,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
last_activity: now,
status: ConnectionStatus::Active,
status_changed_at: now,
protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
"state": "WaitingForImport",
"write_protect": true,
"total_sectors": 0
})),
};
app_state
.add_connection_to_server(server_id, conn_state)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
let llm_client_clone = llm_client.clone();
let app_state_clone = app_state.clone();
let status_tx_clone = status_tx.clone();
let connections_clone = connections.clone();
let protocol_clone = protocol.clone();
let disk_image_clone = disk_image.clone();
let task_owner = app_state.clone();
task_owner
.spawn_server_task(server_id, async move {
let _permit = permit;
if let Err(e) = Self::handle_connection(
stream,
connection_id,
remote_addr,
llm_client_clone,
app_state_clone,
status_tx_clone,
connections_clone,
protocol_clone,
server_id,
disk_image_clone,
deadlines,
)
.await
{
error!("USB MSC connection {} error: {}", connection_id, e);
}
})
.await;
}
Err(e) => {
error!("USB MSC accept failed, stopping accept loop: {}", e);
break;
}
}
}
});
task_registrar
.register_server_task(server_id, accept_handle)
.await;
Ok(local_addr)
}
#[allow(clippy::too_many_arguments)]
async fn handle_connection(
stream: tokio::net::TcpStream,
connection_id: ConnectionId,
remote_addr: SocketAddr,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
protocol: Arc<UsbMscProtocol>,
server_id: crate::state::ServerId,
disk_image: Option<PathBuf>,
deadlines: crate::server::usb::guard::UsbIpDeadlines,
) -> Result<()> {
info!(
"USB MSC connection {} from {} - mass storage device initialization",
connection_id, remote_addr
);
let write_protect = true;
let (disk, medium) = match &disk_image {
Some(path) => (
disk::DiskImage::open_or_create(path, DEFAULT_DISK_SIZE_MB)
.with_context(|| format!("Failed to open disk image {}", path.display()))?,
path.display().to_string(),
),
None => (
disk::DiskImage::in_memory(
fat16::build_volume(&[], fat16::DEFAULT_TOTAL_SECTORS, "NETGET")
.context("Failed to build the empty in-memory volume")?,
)
.context("Failed to create the in-memory disk")?,
"in-memory FAT16 volume (use serve_files to fill it)".to_string(),
),
};
let disk = Arc::new(std::sync::Mutex::new(disk));
let (total_sectors, bytes_per_sector) = {
let d = disk
.lock()
.map_err(|_| anyhow::anyhow!("disk image mutex poisoned"))?;
(d.total_sectors(), d.bytes_per_sector())
};
let capacity_mb =
(total_sectors as u64 * bytes_per_sector as u64) as f64 / (1024.0 * 1024.0);
connections.lock().await.insert(
connection_id,
ConnectionData {
state: ConnectionState::Idle,
},
);
info!(
"USB MSC device: medium={}, sectors={}, bytes_per_sector={}, write_protect={}",
medium, total_sectors, bytes_per_sector, write_protect
);
let (io_tx, mut io_rx) = mpsc::unbounded_channel::<MscIoEvent>();
let msc_handler = Arc::new(std::sync::Mutex::new(Box::new(
handler::UsbMscHandler::new(disk, write_protect).with_io_events(io_tx),
)
as Box<dyn usbip::UsbInterfaceHandler + Send>));
protocol.set_handler(connection_id, msc_handler.clone());
let device = usbip::UsbDevice::new(0).with_interface(
0x08, 0x06, 0x50, Some("NetGet Virtual Disk"),
vec![
usbip::UsbEndpoint {
address: 0x81, attributes: 0x02, max_packet_size: 512, interval: 0, },
usbip::UsbEndpoint {
address: 0x01, attributes: 0x02, max_packet_size: 512, interval: 0, },
],
msc_handler.clone(),
);
let usbip_server = Arc::new(usbip::UsbIpServer::new_simulated(vec![device]));
let _ = status_tx.send(format!(
"USB MSC device ready: {} ({} sectors, {:.1} MB) - run: sudo usbip attach -r {} -b 0-0-0",
medium,
total_sectors,
capacity_mb,
remote_addr.ip()
));
let guard_status_tx = status_tx.clone();
let (import_tx, mut import_rx) = tokio::sync::oneshot::channel();
let mut usbip_task = tokio::spawn(async move {
match crate::server::usb::guard::run_guarded_usbip(
stream,
usbip_server,
"USB MSC",
connection_id.to_string(),
guard_status_tx,
Some(import_tx),
deadlines,
)
.await
{
Ok(()) => debug!("USB/IP session ended for MSC connection {}", connection_id),
Err(e) => debug!(
"USB/IP session for MSC connection {} ended with error: {}",
connection_id, e
),
}
});
let mut import_pending = true;
let mut imported = false;
loop {
tokio::select! {
asked = &mut import_rx, if import_pending => {
import_pending = false;
if asked.is_ok() {
imported = true;
Self::call_llm_for_event(
connection_id,
&llm_client,
&app_state,
&connections,
&protocol,
&status_tx,
server_id,
Event::new(
&USB_MSC_ATTACHED_EVENT,
serde_json::json!({
"connection_id": connection_id.to_string(),
"remote_addr": remote_addr.to_string(),
"total_sectors": total_sectors,
"capacity_mb": (capacity_mb * 100.0).round() / 100.0,
}),
),
"attach",
)
.await;
}
}
received = io_rx.recv() => {
let Some(first) = received else { break };
let mut batch = vec![first];
while let Ok(more) = io_rx.try_recv() {
batch.push(more);
}
if let Some(event) = Self::summarize(connection_id, &batch, false) {
Self::call_llm_for_event(
connection_id, &llm_client, &app_state, &connections, &protocol,
&status_tx, server_id, event, "read",
)
.await;
}
if let Some(event) = Self::summarize(connection_id, &batch, true) {
Self::call_llm_for_event(
connection_id, &llm_client, &app_state, &connections, &protocol,
&status_tx, server_id, event, "write",
)
.await;
}
}
_ = &mut usbip_task => break,
}
}
info!(
"USB MSC host detached on connection {} from {}",
connection_id, remote_addr
);
if imported {
Self::call_llm_for_event(
connection_id,
&llm_client,
&app_state,
&connections,
&protocol,
&status_tx,
server_id,
Event::new(
&USB_MSC_DETACHED_EVENT,
serde_json::json!({ "connection_id": connection_id.to_string() }),
),
"detach",
)
.await;
}
protocol.remove_handler(connection_id);
connections.lock().await.remove(&connection_id);
app_state
.close_connection_on_server(server_id, connection_id)
.await;
let _ = status_tx.send("__UPDATE_UI__".to_string());
Ok(())
}
fn summarize(connection_id: ConnectionId, batch: &[MscIoEvent], writes: bool) -> Option<Event> {
let mut first_lba = None;
let mut sectors: u64 = 0;
let mut bytes: u64 = 0;
for io in batch {
let (lba, s, b) = match (io, writes) {
(
MscIoEvent::Read {
lba,
sectors,
bytes,
},
false,
)
| (
MscIoEvent::Write {
lba,
sectors,
bytes,
},
true,
) => (*lba, *sectors, *bytes),
_ => continue,
};
first_lba.get_or_insert(lba);
sectors += s as u64;
bytes += b as u64;
}
let lba = first_lba?;
Some(if writes {
Event::new(
&USB_MSC_WRITE_EVENT,
serde_json::json!({
"connection_id": connection_id.to_string(),
"lba": lba,
"sector_count": sectors,
"bytes_written": bytes,
}),
)
} else {
Event::new(
&USB_MSC_READ_EVENT,
serde_json::json!({
"connection_id": connection_id.to_string(),
"lba": lba,
"sector_count": sectors,
"bytes_read": bytes,
}),
)
})
}
#[allow(clippy::too_many_arguments)]
async fn call_llm_for_event(
connection_id: ConnectionId,
llm_client: &OllamaClient,
app_state: &Arc<AppState>,
connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
protocol: &Arc<UsbMscProtocol>,
status_tx: &mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
event: Event,
what: &str,
) {
{
let mut conns = connections.lock().await;
match conns.get_mut(&connection_id) {
Some(conn) if conn.state != ConnectionState::Idle => {
debug!(
"USB MSC connection {} already processing, skipping {} event",
connection_id, what
);
return;
}
Some(conn) => conn.state = ConnectionState::Processing,
None => {}
}
}
let result = call_llm(
llm_client,
app_state,
server_id,
Some(connection_id),
&event,
protocol.as_ref(),
)
.await;
match result {
Ok(_) => info!(
"USB MSC LLM call completed ({}) for connection {} decision=model_answered",
what, connection_id
),
Err(e) => {
console_error!(
status_tx,
"LLM call failed for USB MSC connection {} ({}) \
decision=fail_closed_llm_error: {}",
connection_id,
what,
e
);
if what == "attach" && protocol.fail_closed_eject(connection_id) {
let _ = status_tx.send(format!(
"[ERROR] USB MSC device on {connection_id} reports NOT READY / MEDIUM \
NOT PRESENT: the model never said what the drive contains"
));
warn!(
"USB MSC connection {} ejected the medium after the attach handler \
failed; the host will see NOT READY / MEDIUM NOT PRESENT until a \
serve_files or mount_disk succeeds",
connection_id
);
}
}
}
let mut conns = connections.lock().await;
if let Some(conn) = conns.get_mut(&connection_id) {
conn.state = ConnectionState::Idle;
}
}
}