Skip to main content

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}