netget/server/usb/msc/mod.rs
1//! USB Mass Storage Class (MSC) server implementation
2//!
3//! This module implements a virtual USB Mass Storage device using the USB/IP protocol.
4//! The device uses Bulk-Only Transport (BOT) and SCSI transparent command set to expose
5//! a virtual disk that can be read (and, if write protection is off, written) by anything
6//! that speaks USB/IP.
7//!
8//! The USB/IP session runs on the socket netget already accepted (`usbip::handler`). It used
9//! to call `usbip::server(remote_addr, ...)` instead, which tries to *bind* a fresh listener
10//! to the client's own address and drops the accepted socket on the floor — no host could ever
11//! have talked to this device.
12//!
13//! Event flow:
14//!
15//! * `usb_msc_attached` — a host connected; the device is exported.
16//! * `usb_msc_read` / `usb_msc_write` — the host moved sectors. `handle_urb` is synchronous
17//! and cannot call the LLM, so the handler reports transfers over a channel and this module
18//! raises the events from the connection task. Reads are served from the image first: the
19//! event is a notification, never something the data path waits on.
20//! * `usb_msc_detached` — the USB/IP session ended.
21
22pub mod actions;
23
24// Re-export protocol struct for registration
25#[cfg(all(unix, feature = "libusb"))]
26pub use actions::UsbMscProtocol;
27
28#[cfg(all(unix, feature = "libusb"))]
29pub mod disk;
30pub mod image_dir;
31
32#[cfg(all(unix, feature = "libusb"))]
33pub mod fat16;
34
35#[cfg(all(unix, feature = "libusb"))]
36pub mod handler;
37
38#[cfg(all(unix, feature = "libusb"))]
39use anyhow::{Context, Result};
40#[cfg(all(unix, feature = "libusb"))]
41use std::collections::HashMap;
42#[cfg(all(unix, feature = "libusb"))]
43use std::net::SocketAddr;
44#[cfg(all(unix, feature = "libusb"))]
45use std::path::PathBuf;
46#[cfg(all(unix, feature = "libusb"))]
47use std::sync::Arc;
48#[cfg(all(unix, feature = "libusb"))]
49use tokio::sync::{mpsc, Mutex};
50#[cfg(all(unix, feature = "libusb"))]
51use tracing::{debug, error, info, warn};
52
53#[cfg(all(unix, feature = "libusb"))]
54use crate::console_error;
55#[cfg(all(unix, feature = "libusb"))]
56use crate::llm::action_helper::call_llm;
57#[cfg(all(unix, feature = "libusb"))]
58use crate::llm::ollama_client::OllamaClient;
59#[cfg(all(unix, feature = "libusb"))]
60use crate::protocol::Event;
61#[cfg(all(unix, feature = "libusb"))]
62use crate::server::accept_bounded::{accept_bounded, ConnectionLimiter};
63#[cfg(all(unix, feature = "libusb"))]
64use crate::server::connection::ConnectionId;
65#[cfg(all(unix, feature = "libusb"))]
66use crate::server::usb::guard::{MAX_USBIP_CONNECTIONS, USBIP_NO_REFUSAL};
67#[cfg(all(unix, feature = "libusb"))]
68use crate::state::app_state::AppState;
69#[cfg(all(unix, feature = "libusb"))]
70use actions::{
71 USB_MSC_ATTACHED_EVENT, USB_MSC_DETACHED_EVENT, USB_MSC_READ_EVENT, USB_MSC_WRITE_EVENT,
72};
73#[cfg(all(unix, feature = "libusb"))]
74use handler::MscIoEvent;
75
76/// Default size of a disk image netget has to create itself.
77#[cfg(all(unix, feature = "libusb"))]
78const DEFAULT_DISK_SIZE_MB: u32 = 10;
79
80/// Connection state for LLM processing
81#[cfg(all(unix, feature = "libusb"))]
82#[derive(Debug, Clone, PartialEq)]
83enum ConnectionState {
84 Idle,
85 Processing,
86}
87
88/// Per-connection data for USB MSC
89#[cfg(all(unix, feature = "libusb"))]
90#[derive(Clone)]
91struct ConnectionData {
92 state: ConnectionState,
93}
94
95/// USB Mass Storage Class server
96#[cfg(all(unix, feature = "libusb"))]
97pub struct UsbMscServer;
98
99#[cfg(all(unix, feature = "libusb"))]
100impl UsbMscServer {
101 /// Spawn the USB MSC server with LLM integration
102 ///
103 /// This creates a USB/IP server that exports a virtual mass storage device.
104 /// The LLM can control the device through actions like mount_disk, eject_disk, etc.
105 #[allow(clippy::too_many_arguments)]
106 pub async fn spawn_with_llm_actions(
107 listen_addr: SocketAddr,
108 llm_client: OllamaClient,
109 app_state: Arc<AppState>,
110 status_tx: mpsc::UnboundedSender<String>,
111 server_id: crate::state::ServerId,
112 image_dir: image_dir::ImageDir,
113 disk_image: Option<PathBuf>,
114 first_byte_timeout_secs: Option<u64>,
115 idle_timeout_secs: Option<u64>,
116 ) -> Result<SocketAddr> {
117 // Create and bind TCP server for USB/IP protocol
118 let listener =
119 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
120 let local_addr = listener.local_addr()?;
121 info!("USB Mass Storage server listening on {}", local_addr);
122 let _ = status_tx.send(format!(
123 "USB Mass Storage server listening on {}",
124 local_addr
125 ));
126
127 let connections = Arc::new(Mutex::new(HashMap::new()));
128 let protocol = Arc::new(UsbMscProtocol::with_image_dir(image_dir));
129
130 // The two read deadlines, argued in `src/server/usb/guard.rs` and overridable per
131 // server. Copied into each connection task; the screen is the only thing that reads
132 // from the socket, so it is the only place they can be applied.
133 let deadlines = crate::server::usb::guard::UsbIpDeadlines::from_secs(
134 first_byte_timeout_secs,
135 idle_timeout_secs,
136 );
137 let task_registrar = app_state.clone();
138 // One emulated device per connection, so the cap is small and lives with the screen.
139 let limiter = ConnectionLimiter::new(MAX_USBIP_CONNECTIONS);
140 let accept_handle = tokio::spawn(async move {
141 loop {
142 match accept_bounded(
143 &listener,
144 &limiter,
145 USBIP_NO_REFUSAL,
146 "USB MSC",
147 Some(&status_tx),
148 )
149 .await
150 {
151 Ok((stream, remote_addr, permit)) => {
152 let connection_id =
153 ConnectionId::new(app_state.get_next_unified_id().await);
154 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
155 info!(
156 "USB/IP connection {} from {} (USB MSC device)",
157 connection_id, remote_addr
158 );
159
160 // Add connection to ServerInstance
161 use crate::state::server::{
162 ConnectionState as ServerConnectionState, ConnectionStatus,
163 ProtocolConnectionInfo,
164 };
165 let now = crate::utils::clock::Instant::now();
166 let conn_state = ServerConnectionState {
167 id: connection_id,
168 remote_addr,
169 local_addr: local_addr_conn,
170 bytes_sent: 0,
171 bytes_received: 0,
172 packets_sent: 0,
173 packets_received: 0,
174 last_activity: now,
175 status: ConnectionStatus::Active,
176 status_changed_at: now,
177 protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
178 "state": "WaitingForImport",
179 "write_protect": true,
180 "total_sectors": 0
181 })),
182 };
183 app_state
184 .add_connection_to_server(server_id, conn_state)
185 .await;
186 let _ = status_tx.send("__UPDATE_UI__".to_string());
187
188 // Handle USB/IP connection
189 let llm_client_clone = llm_client.clone();
190 let app_state_clone = app_state.clone();
191 let status_tx_clone = status_tx.clone();
192 let connections_clone = connections.clone();
193 let protocol_clone = protocol.clone();
194 let disk_image_clone = disk_image.clone();
195
196 // Tracked, not detached: stop_server must abort this task too.
197 let task_owner = app_state.clone();
198 task_owner
199 .spawn_server_task(server_id, async move {
200 // Held for the life of the connection: dropping it early
201 // releases the slot while the device is still exported.
202 let _permit = permit;
203 if let Err(e) = Self::handle_connection(
204 stream,
205 connection_id,
206 remote_addr,
207 llm_client_clone,
208 app_state_clone,
209 status_tx_clone,
210 connections_clone,
211 protocol_clone,
212 server_id,
213 disk_image_clone,
214 deadlines,
215 )
216 .await
217 {
218 error!("USB MSC connection {} error: {}", connection_id, e);
219 }
220 })
221 .await;
222 }
223 Err(e) => {
224 // A persistent accept error (EMFILE, socket torn down) recurs
225 // immediately, so continuing spins a hot loop on an unbounded status
226 // channel. Give up the listener instead.
227 error!("USB MSC accept failed, stopping accept loop: {}", e);
228 break;
229 }
230 }
231 }
232 });
233
234 task_registrar
235 .register_server_task(server_id, accept_handle)
236 .await;
237
238 Ok(local_addr)
239 }
240
241 /// Drive one USB/IP session and the events that hang off it.
242 #[allow(clippy::too_many_arguments)]
243 async fn handle_connection(
244 stream: tokio::net::TcpStream,
245 connection_id: ConnectionId,
246 remote_addr: SocketAddr,
247 llm_client: OllamaClient,
248 app_state: Arc<AppState>,
249 status_tx: mpsc::UnboundedSender<String>,
250 connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
251 protocol: Arc<UsbMscProtocol>,
252 server_id: crate::state::ServerId,
253 disk_image: Option<PathBuf>,
254 deadlines: crate::server::usb::guard::UsbIpDeadlines,
255 ) -> Result<()> {
256 info!(
257 "USB MSC connection {} from {} - mass storage device initialization",
258 connection_id, remote_addr
259 );
260
261 let write_protect = true; // Start write-protected for safety
262
263 // The medium. Default is an **empty in-memory FAT16 volume**, which the model then
264 // fills with `serve_files` in response to `usb_msc_attached`. A path is used only when
265 // the caller named one.
266 //
267 // The default used to be `./tmp/netget_msc_disk.img` in the process's working
268 // directory, which made the protocol implement storage: the sectors a host read came
269 // from a file netget created, not from the model, and the read/write events were
270 // notifications about data nobody had been asked for.
271 let (disk, medium) = match &disk_image {
272 Some(path) => (
273 disk::DiskImage::open_or_create(path, DEFAULT_DISK_SIZE_MB)
274 .with_context(|| format!("Failed to open disk image {}", path.display()))?,
275 path.display().to_string(),
276 ),
277 None => (
278 disk::DiskImage::in_memory(
279 fat16::build_volume(&[], fat16::DEFAULT_TOTAL_SECTORS, "NETGET")
280 .context("Failed to build the empty in-memory volume")?,
281 )
282 .context("Failed to create the in-memory disk")?,
283 "in-memory FAT16 volume (use serve_files to fill it)".to_string(),
284 ),
285 };
286 let disk = Arc::new(std::sync::Mutex::new(disk));
287
288 let (total_sectors, bytes_per_sector) = {
289 let d = disk
290 .lock()
291 .map_err(|_| anyhow::anyhow!("disk image mutex poisoned"))?;
292 (d.total_sectors(), d.bytes_per_sector())
293 };
294 let capacity_mb =
295 (total_sectors as u64 * bytes_per_sector as u64) as f64 / (1024.0 * 1024.0);
296
297 connections.lock().await.insert(
298 connection_id,
299 ConnectionData {
300 state: ConnectionState::Idle,
301 },
302 );
303
304 info!(
305 "USB MSC device: medium={}, sectors={}, bytes_per_sector={}, write_protect={}",
306 medium, total_sectors, bytes_per_sector, write_protect
307 );
308
309 // Sector transfers arrive on a synchronous URB callback, which cannot await an LLM
310 // call. The channel is the seam between the two.
311 let (io_tx, mut io_rx) = mpsc::unbounded_channel::<MscIoEvent>();
312
313 let msc_handler = Arc::new(std::sync::Mutex::new(Box::new(
314 handler::UsbMscHandler::new(disk, write_protect).with_io_events(io_tx),
315 )
316 as Box<dyn usbip::UsbInterfaceHandler + Send>));
317
318 protocol.set_handler(connection_id, msc_handler.clone());
319
320 // Bulk IN 0x81 and bulk OUT 0x01, i.e. endpoint 1 in both directions, as a real
321 // BOT device presents itself.
322 let device = usbip::UsbDevice::new(0).with_interface(
323 0x08, // Mass Storage Class
324 0x06, // SCSI Transparent Command Set
325 0x50, // Bulk-Only Transport
326 Some("NetGet Virtual Disk"),
327 vec![
328 usbip::UsbEndpoint {
329 address: 0x81, // EP1 IN (Bulk)
330 attributes: 0x02, // Bulk transfer
331 max_packet_size: 512, // 512 bytes
332 interval: 0, // Not used for bulk
333 },
334 usbip::UsbEndpoint {
335 address: 0x01, // EP1 OUT (Bulk)
336 attributes: 0x02, // Bulk transfer
337 max_packet_size: 512, // 512 bytes
338 interval: 0, // Not used for bulk
339 },
340 ],
341 msc_handler.clone(),
342 );
343
344 let usbip_server = Arc::new(usbip::UsbIpServer::new_simulated(vec![device]));
345
346 let _ = status_tx.send(format!(
347 "USB MSC device ready: {} ({} sectors, {:.1} MB) - run: sudo usbip attach -r {} -b 0-0-0",
348 medium,
349 total_sectors,
350 capacity_mb,
351 remote_addr.ip()
352 ));
353
354 // Not `usbip::handler(&mut stream, ...)` directly: the crate allocates from the peer's
355 // `transfer_buffer_length` and `number_of_packets` without checking either, on a socket
356 // USB/IP never authenticates. `run_guarded_usbip` screens both before a byte reaches it
357 // -- see `src/server/usb/guard.rs`.
358 //
359 // The attach event hangs off `import_rx`, not off the accept: see the loop below.
360 let guard_status_tx = status_tx.clone();
361 let (import_tx, mut import_rx) = tokio::sync::oneshot::channel();
362 let mut usbip_task = tokio::spawn(async move {
363 match crate::server::usb::guard::run_guarded_usbip(
364 stream,
365 usbip_server,
366 "USB MSC",
367 connection_id.to_string(),
368 guard_status_tx,
369 Some(import_tx),
370 deadlines,
371 )
372 .await
373 {
374 Ok(()) => debug!("USB/IP session ended for MSC connection {}", connection_id),
375 Err(e) => debug!(
376 "USB/IP session for MSC connection {} ended with error: {}",
377 connection_id, e
378 ),
379 }
380 });
381
382 // Serve the host until the USB/IP session ends. This loop is the whole reason
383 // usb_msc_read / usb_msc_write / usb_msc_detached can fire: the previous version
384 // parked on sleep(u64::MAX) right here.
385 //
386 // It is also where the attach event is raised. USB/IP authenticates nothing, so a bare
387 // TCP connect must cost nothing: `import_rx` fires only once the screen has admitted an
388 // `OP_REQ_IMPORT`, which is the first moment the peer has asked for the disk.
389 // `import_pending` is the select! guard — a `oneshot::Receiver` must not be polled again
390 // once it has resolved — and `imported` is the record, because the detach event is the
391 // other half of an attachment and must not fire without one.
392 let mut import_pending = true;
393 let mut imported = false;
394 loop {
395 tokio::select! {
396 asked = &mut import_rx, if import_pending => {
397 import_pending = false;
398 if asked.is_ok() {
399 imported = true;
400 Self::call_llm_for_event(
401 connection_id,
402 &llm_client,
403 &app_state,
404 &connections,
405 &protocol,
406 &status_tx,
407 server_id,
408 Event::new(
409 &USB_MSC_ATTACHED_EVENT,
410 serde_json::json!({
411 "connection_id": connection_id.to_string(),
412 "remote_addr": remote_addr.to_string(),
413 "total_sectors": total_sectors,
414 "capacity_mb": (capacity_mb * 100.0).round() / 100.0,
415 }),
416 ),
417 "attach",
418 )
419 .await;
420 }
421 }
422 received = io_rx.recv() => {
423 let Some(first) = received else { break };
424
425 // A single mount does hundreds of sector transfers. One LLM round trip per
426 // URB would be unusable, so drain whatever else is already queued and
427 // report it as one read event and one write event.
428 let mut batch = vec![first];
429 while let Ok(more) = io_rx.try_recv() {
430 batch.push(more);
431 }
432
433 if let Some(event) = Self::summarize(connection_id, &batch, false) {
434 Self::call_llm_for_event(
435 connection_id, &llm_client, &app_state, &connections, &protocol,
436 &status_tx, server_id, event, "read",
437 )
438 .await;
439 }
440 if let Some(event) = Self::summarize(connection_id, &batch, true) {
441 Self::call_llm_for_event(
442 connection_id, &llm_client, &app_state, &connections, &protocol,
443 &status_tx, server_id, event, "write",
444 )
445 .await;
446 }
447 }
448 _ = &mut usbip_task => break,
449 }
450 }
451
452 info!(
453 "USB MSC host detached on connection {} from {}",
454 connection_id, remote_addr
455 );
456
457 // Only for a host that actually attached: a peer that opened a socket and closed it
458 // again detached nothing.
459 if imported {
460 Self::call_llm_for_event(
461 connection_id,
462 &llm_client,
463 &app_state,
464 &connections,
465 &protocol,
466 &status_tx,
467 server_id,
468 Event::new(
469 &USB_MSC_DETACHED_EVENT,
470 serde_json::json!({ "connection_id": connection_id.to_string() }),
471 ),
472 "detach",
473 )
474 .await;
475 }
476
477 // The session owned this handler; drop it so a later action cannot mount a disk into
478 // a device that no longer exists.
479 protocol.remove_handler(connection_id);
480 connections.lock().await.remove(&connection_id);
481 app_state
482 .close_connection_on_server(server_id, connection_id)
483 .await;
484 let _ = status_tx.send("__UPDATE_UI__".to_string());
485
486 Ok(())
487 }
488
489 /// Fold a batch of sector transfers into one read (or write) event.
490 ///
491 /// Returns `None` when the batch contains nothing of that direction.
492 fn summarize(connection_id: ConnectionId, batch: &[MscIoEvent], writes: bool) -> Option<Event> {
493 let mut first_lba = None;
494 let mut sectors: u64 = 0;
495 let mut bytes: u64 = 0;
496
497 for io in batch {
498 let (lba, s, b) = match (io, writes) {
499 (
500 MscIoEvent::Read {
501 lba,
502 sectors,
503 bytes,
504 },
505 false,
506 )
507 | (
508 MscIoEvent::Write {
509 lba,
510 sectors,
511 bytes,
512 },
513 true,
514 ) => (*lba, *sectors, *bytes),
515 _ => continue,
516 };
517 first_lba.get_or_insert(lba);
518 sectors += s as u64;
519 bytes += b as u64;
520 }
521
522 let lba = first_lba?;
523 Some(if writes {
524 Event::new(
525 &USB_MSC_WRITE_EVENT,
526 serde_json::json!({
527 "connection_id": connection_id.to_string(),
528 "lba": lba,
529 "sector_count": sectors,
530 "bytes_written": bytes,
531 }),
532 )
533 } else {
534 Event::new(
535 &USB_MSC_READ_EVENT,
536 serde_json::json!({
537 "connection_id": connection_id.to_string(),
538 "lba": lba,
539 "sector_count": sectors,
540 "bytes_read": bytes,
541 }),
542 )
543 })
544 }
545
546 /// Raise one event with the LLM, guarding against overlapping calls on the same device.
547 ///
548 /// On failure the device answers in SCSI's own vocabulary — see the `Err` arm.
549 #[allow(clippy::too_many_arguments)]
550 async fn call_llm_for_event(
551 connection_id: ConnectionId,
552 llm_client: &OllamaClient,
553 app_state: &Arc<AppState>,
554 connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
555 protocol: &Arc<UsbMscProtocol>,
556 status_tx: &mpsc::UnboundedSender<String>,
557 server_id: crate::state::ServerId,
558 event: Event,
559 what: &str,
560 ) {
561 {
562 let mut conns = connections.lock().await;
563 match conns.get_mut(&connection_id) {
564 Some(conn) if conn.state != ConnectionState::Idle => {
565 debug!(
566 "USB MSC connection {} already processing, skipping {} event",
567 connection_id, what
568 );
569 return;
570 }
571 Some(conn) => conn.state = ConnectionState::Processing,
572 // Detach removes the entry before raising its own event; not an error.
573 None => {}
574 }
575 }
576
577 let result = call_llm(
578 llm_client,
579 app_state,
580 server_id,
581 Some(connection_id),
582 &event,
583 protocol.as_ref(),
584 )
585 .await;
586
587 match result {
588 // The event kind comes before the id so a test can wait on one specific event
589 // with a substring match.
590 Ok(_) => info!(
591 "USB MSC LLM call completed ({}) for connection {} decision=model_answered",
592 what, connection_id
593 ),
594 Err(e) => {
595 // `decision=` tags, as `src/server/radius/` does. SCSI cannot distinguish a
596 // refusal from an outage -- both leave the host with NOT READY -- so the log
597 // has to, or the two are indistinguishable after the fact.
598 console_error!(
599 status_tx,
600 "LLM call failed for USB MSC connection {} ({}) \
601 decision=fail_closed_llm_error: {}",
602 connection_id,
603 what,
604 e
605 );
606
607 // `attach` is the only event whose answer the host depends on: it is where the
608 // model says what is on the drive (`serve_files`). Without it the device would
609 // present an empty but perfectly valid FAT16 volume, and a host cannot tell
610 // "the model was unreachable" from "the model served an empty disk" — the
611 // fail-open shape this project keeps getting bitten by.
612 //
613 // So take the medium away. Every command that needs one then fails CHECK
614 // CONDITION with NOT READY / MEDIUM NOT PRESENT (02/3A/00), which is exactly
615 // what SCSI has to say about a drive with nothing in it. INQUIRY still answers,
616 // so the device stays enumerable and the host learns *why* rather than hanging.
617 //
618 // Read and write events are notifications about transfers the image already
619 // served, so there is nothing for the host to be told and nothing to withdraw;
620 // detach has no wire left. Those log and stop.
621 if what == "attach" && protocol.fail_closed_eject(connection_id) {
622 let _ = status_tx.send(format!(
623 "[ERROR] USB MSC device on {connection_id} reports NOT READY / MEDIUM \
624 NOT PRESENT: the model never said what the drive contains"
625 ));
626 warn!(
627 "USB MSC connection {} ejected the medium after the attach handler \
628 failed; the host will see NOT READY / MEDIUM NOT PRESENT until a \
629 serve_files or mount_disk succeeds",
630 connection_id
631 );
632 }
633 }
634 }
635
636 let mut conns = connections.lock().await;
637 if let Some(conn) = conns.get_mut(&connection_id) {
638 conn.state = ConnectionState::Idle;
639 }
640 }
641}