feat: add TCP transport support
Add TCP address types, parsing, and connect/listen functions alongside the existing vsock transport. Update all CLI commands (client listen, host connect, test_hid connect) to support both vsock: and tcp: address prefixes. Add hostname resolution for TCP connections. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b66cd0f7e9
commit
09b8012f8c
8 changed files with 1214 additions and 87 deletions
|
|
@ -3,7 +3,7 @@ name = "usbip-rs-cli"
|
|||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "USB/IP over vsock CLI tool"
|
||||
description = "USB/IP over vsock/TCP CLI tool"
|
||||
|
||||
[[bin]]
|
||||
name = "usbip-rs"
|
||||
|
|
@ -13,7 +13,7 @@ path = "src/main.rs"
|
|||
usbip-rs = { path = "../lib" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
tokio-vsock = "0.7"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "net"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
nusb = "0.2.1"
|
||||
|
|
|
|||
|
|
@ -6,12 +6,19 @@ use tokio::signal;
|
|||
use usbip_rs::UsbDevice;
|
||||
use usbip_rs::usbip_protocol::{OP_REP_IMPORT, USBIP_VERSION};
|
||||
|
||||
use crate::transport;
|
||||
use crate::transport::{self, TransportAddr};
|
||||
use crate::vhci;
|
||||
|
||||
const HANDSHAKE_SIZE: usize = 320;
|
||||
|
||||
pub async fn run(port: u32) -> std::io::Result<()> {
|
||||
pub async fn run(addr: TransportAddr) -> std::io::Result<()> {
|
||||
match addr {
|
||||
TransportAddr::Vsock(ref v) => run_vsock(v.port).await,
|
||||
TransportAddr::Tcp(ref t) => run_tcp(t).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_vsock(port: u32) -> std::io::Result<()> {
|
||||
let listener = transport::listen_vsock(port)?;
|
||||
info!("Client listening on vsock port {port}");
|
||||
|
||||
|
|
@ -22,8 +29,9 @@ pub async fn run(port: u32) -> std::io::Result<()> {
|
|||
Ok((stream, addr)) => {
|
||||
info!("Accepted connection from CID={}", addr.cid());
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream).await {
|
||||
error!("Connection handler error: {e}");
|
||||
match handle_vsock_connection(stream).await {
|
||||
Ok(()) => info!("Connection from CID={} closed, device detached", addr.cid()),
|
||||
Err(e) => error!("Connection from CID={} error: {e}", addr.cid()),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -42,8 +50,40 @@ pub async fn run(port: u32) -> std::io::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_connection(mut stream: tokio_vsock::VsockStream) -> std::io::Result<()> {
|
||||
// Read handshake (320 bytes)
|
||||
async fn run_tcp(addr: &transport::TcpAddr) -> std::io::Result<()> {
|
||||
let listener = transport::listen_tcp(addr).await?;
|
||||
info!("Client listening on TCP port {}", addr.port);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, peer)) => {
|
||||
info!("Accepted connection from {peer}");
|
||||
tokio::spawn(async move {
|
||||
match handle_tcp_connection(stream).await {
|
||||
Ok(()) => info!("Connection from {peer} closed, device detached"),
|
||||
Err(e) => error!("Connection from {peer} error: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Accept error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = signal::ctrl_c() => {
|
||||
info!("Received shutdown signal, stopping listener");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read and validate the 320-byte handshake, return parsed device info.
|
||||
async fn read_handshake<S: AsyncReadExt + Unpin>(stream: &mut S) -> std::io::Result<UsbDevice> {
|
||||
let mut handshake = [0u8; HANDSHAKE_SIZE];
|
||||
stream
|
||||
.read_exact(&mut handshake)
|
||||
|
|
@ -52,7 +92,6 @@ async fn handle_connection(mut stream: tokio_vsock::VsockStream) -> std::io::Res
|
|||
|
||||
trace!("Handshake bytes: {:02x?}", &handshake[..]);
|
||||
|
||||
// Validate header
|
||||
let version = u16::from_be_bytes(handshake[0..2].try_into().unwrap());
|
||||
let command = u16::from_be_bytes(handshake[2..4].try_into().unwrap());
|
||||
let status = u32::from_be_bytes(handshake[4..8].try_into().unwrap());
|
||||
|
|
@ -72,7 +111,6 @@ async fn handle_connection(mut stream: tokio_vsock::VsockStream) -> std::io::Res
|
|||
return Err(std::io::Error::other("Handshake status indicates failure"));
|
||||
}
|
||||
|
||||
// Parse device info
|
||||
let device = UsbDevice::from_bytes(&handshake[8..]);
|
||||
info!(
|
||||
"Importing device {:04x}:{:04x} (bus_id={}, speed={})",
|
||||
|
|
@ -87,21 +125,32 @@ async fn handle_connection(mut stream: tokio_vsock::VsockStream) -> std::io::Res
|
|||
);
|
||||
trace!("Full device: {:?}", device);
|
||||
|
||||
// Find free vhci_hcd port
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
/// Attach the device fd to vhci_hcd.
|
||||
fn attach_to_vhci(raw_fd: i32, device: &UsbDevice) -> std::io::Result<()> {
|
||||
let (vhci_port, attach_path) = vhci::find_free_port(device.speed)?;
|
||||
|
||||
// Extract raw fd — into_raw_fd() takes ownership without closing
|
||||
// The kernel's vhci_hcd will own the fd after the sysfs write
|
||||
let raw_fd = stream.into_raw_fd();
|
||||
debug!("Extracted raw fd={raw_fd} for vhci handoff");
|
||||
|
||||
// Attach to vhci_hcd (devid=0 for simplified protocol)
|
||||
vhci::attach(vhci_port, raw_fd, 0, device.speed, &attach_path)?;
|
||||
|
||||
info!(
|
||||
"Device {:04x}:{:04x} attached on vhci port {vhci_port}",
|
||||
device.vendor_id, device.product_id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_vsock_connection(mut stream: tokio_vsock::VsockStream) -> std::io::Result<()> {
|
||||
let device = read_handshake(&mut stream).await?;
|
||||
let raw_fd = stream.into_raw_fd();
|
||||
attach_to_vhci(raw_fd, &device)
|
||||
}
|
||||
|
||||
async fn handle_tcp_connection(mut stream: tokio::net::TcpStream) -> std::io::Result<()> {
|
||||
let device = read_handshake(&mut stream).await?;
|
||||
let std_stream = stream.into_std().map_err(|e| {
|
||||
std::io::Error::other(format!("Failed to convert TcpStream to std: {e}"))
|
||||
})?;
|
||||
let raw_fd = std_stream.into_raw_fd();
|
||||
attach_to_vhci(raw_fd, &device)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ use log::{debug, error, info, warn};
|
|||
use nusb::MaybeFuture;
|
||||
use std::io::Result;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use usbip_rs::{
|
||||
EndpointAttributes, NusbUsbHostDeviceHandler, NusbUsbHostInterfaceHandler, UsbDevice,
|
||||
UsbEndpoint, UsbInterface, UsbInterfaceHandler, usbip_protocol::UsbIpResponse,
|
||||
|
|
@ -235,9 +233,36 @@ fn build_usb_device(dev: nusb::Device, dev_info: nusb::DeviceInfo) -> Result<Usb
|
|||
Ok(device)
|
||||
}
|
||||
|
||||
/// Send handshake and run URB loop over any async stream.
|
||||
async fn do_host_session<S: tokio::io::AsyncReadExt + tokio::io::AsyncWriteExt + Unpin>(
|
||||
stream: &mut S,
|
||||
device: &UsbDevice,
|
||||
) -> Result<()> {
|
||||
// Send OP_REP_IMPORT with device info (simplified handshake)
|
||||
let response = UsbIpResponse::op_rep_import_success(device);
|
||||
let response_bytes = response.to_bytes()?;
|
||||
debug!("Sending OP_REP_IMPORT ({} bytes)", response_bytes.len());
|
||||
stream
|
||||
.write_all(&response_bytes)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("Failed to send device info: {e}")))?;
|
||||
info!("Sent device info to client");
|
||||
|
||||
// Enter URB handling loop
|
||||
info!("Entering URB handling loop");
|
||||
let result = usbip_rs::handle_urb_loop(stream, device).await;
|
||||
|
||||
match &result {
|
||||
Ok(()) => info!("URB loop ended normally"),
|
||||
Err(e) => error!("URB loop ended with error: {e}"),
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Main entry point for the host connect command.
|
||||
pub async fn run(cid: u32, port: u32, device_arg: &str) -> Result<()> {
|
||||
info!("Host connect: device={device_arg} -> vsock CID={cid} port={port}");
|
||||
pub async fn run(addr: transport::TransportAddr, device_arg: &str) -> Result<()> {
|
||||
info!("Host connect: device={device_arg} -> {addr:?}");
|
||||
|
||||
// 1. Parse device argument
|
||||
let (bus, dev) = parse_device_arg(device_arg)?;
|
||||
|
|
@ -254,28 +279,15 @@ pub async fn run(cid: u32, port: u32, device_arg: &str) -> Result<()> {
|
|||
// 3. Build a UsbDevice with interface handlers
|
||||
let device = build_usb_device(nusb_dev, dev_info)?;
|
||||
|
||||
// 4. Connect via vsock
|
||||
let mut stream = transport::connect_vsock(cid, port).await?;
|
||||
info!("Connected to vsock CID={cid} port={port}");
|
||||
|
||||
// 5. Send OP_REP_IMPORT with device info (simplified handshake)
|
||||
let response = UsbIpResponse::op_rep_import_success(&device);
|
||||
let response_bytes = response.to_bytes()?;
|
||||
debug!("Sending OP_REP_IMPORT ({} bytes)", response_bytes.len());
|
||||
stream
|
||||
.write_all(&response_bytes)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("Failed to send device info: {e}")))?;
|
||||
info!("Sent device info to client");
|
||||
|
||||
// 6. Enter URB handling loop
|
||||
info!("Entering URB handling loop");
|
||||
let result = usbip_rs::handle_urb_loop(&mut stream, &device).await;
|
||||
|
||||
match &result {
|
||||
Ok(()) => info!("URB loop ended normally"),
|
||||
Err(e) => error!("URB loop ended with error: {e}"),
|
||||
// 4. Connect via transport and run session
|
||||
match addr {
|
||||
transport::TransportAddr::Vsock(v) => {
|
||||
let mut stream = transport::connect_vsock(v.cid, v.port).await?;
|
||||
do_host_session(&mut stream, &device).await
|
||||
}
|
||||
transport::TransportAddr::Tcp(ref t) => {
|
||||
let mut stream = transport::connect_tcp(t).await?;
|
||||
do_host_session(&mut stream, &device).await
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ mod transport;
|
|||
mod vhci;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "usbip-rs", about = "USB/IP over vsock")]
|
||||
#[command(name = "usbip-rs", about = "USB/IP over vsock/TCP")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
|
|
@ -37,7 +37,7 @@ enum Commands {
|
|||
enum ClientAction {
|
||||
/// Listen for incoming connections
|
||||
Listen {
|
||||
/// Vsock address: vsock:<port>
|
||||
/// Transport address: vsock:[<cid>:]<port> or tcp:[<host>:]<port>
|
||||
address: String,
|
||||
},
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ enum ClientAction {
|
|||
enum HostAction {
|
||||
/// Connect to a listening client
|
||||
Connect {
|
||||
/// Vsock address: vsock:[<cid>:]<port>
|
||||
/// Transport address: vsock:[<cid>:]<port> or tcp:[<host>:]<port>
|
||||
address: String,
|
||||
/// USB device: /dev/bus/usb/BBB/DDD or bus ID (e.g. 1-2)
|
||||
device: String,
|
||||
|
|
@ -57,7 +57,7 @@ enum HostAction {
|
|||
enum TestHidAction {
|
||||
/// Connect to a listening client with a simulated HID keyboard
|
||||
Connect {
|
||||
/// Vsock address: vsock:[<cid>:]<port>
|
||||
/// Transport address: vsock:[<cid>:]<port> or tcp:[<host>:]<port>
|
||||
address: String,
|
||||
},
|
||||
}
|
||||
|
|
@ -73,20 +73,20 @@ async fn main() {
|
|||
let result = match cli.command {
|
||||
Commands::Client { action } => match action {
|
||||
ClientAction::Listen { address } => {
|
||||
let addr = transport::parse_vsock_addr(&address).expect("Invalid vsock address");
|
||||
client::run(addr.port).await
|
||||
let addr = transport::parse_address(&address).expect("Invalid transport address");
|
||||
client::run(addr).await
|
||||
}
|
||||
},
|
||||
Commands::Host { action } => match action {
|
||||
HostAction::Connect { address, device } => {
|
||||
let addr = transport::parse_vsock_addr(&address).expect("Invalid vsock address");
|
||||
host::run(addr.cid, addr.port, &device).await
|
||||
let addr = transport::parse_address(&address).expect("Invalid transport address");
|
||||
host::run(addr, &device).await
|
||||
}
|
||||
},
|
||||
Commands::TestHid { action } => match action {
|
||||
TestHidAction::Connect { address } => {
|
||||
let addr = transport::parse_vsock_addr(&address).expect("Invalid vsock address");
|
||||
test_hid::run(addr.cid, addr.port).await
|
||||
let addr = transport::parse_address(&address).expect("Invalid transport address");
|
||||
test_hid::run(addr).await
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use log::info;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use usbip_rs::{
|
||||
ClassCode, UsbDevice, UsbEndpoint, UsbInterfaceHandler, hid::UsbHidKeyboardHandler,
|
||||
|
|
@ -9,36 +9,14 @@ use usbip_rs::{
|
|||
|
||||
use crate::transport;
|
||||
|
||||
pub async fn run(cid: u32, port: u32) -> std::io::Result<()> {
|
||||
// Create simulated HID keyboard
|
||||
let handler = Arc::new(Mutex::new(
|
||||
Box::new(UsbHidKeyboardHandler::new_keyboard()) as Box<dyn UsbInterfaceHandler + Send>
|
||||
));
|
||||
|
||||
let device = UsbDevice::new(0)?.with_interface(
|
||||
ClassCode::HID as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
Some("Test HID Keyboard"),
|
||||
vec![UsbEndpoint {
|
||||
address: 0x81, // IN
|
||||
attributes: 0x03, // Interrupt
|
||||
max_packet_size: 0x08, // 8 bytes
|
||||
interval: 10,
|
||||
}],
|
||||
handler.clone(),
|
||||
)?;
|
||||
|
||||
info!(
|
||||
"Created simulated HID keyboard {:04x}:{:04x}",
|
||||
device.vendor_id, device.product_id
|
||||
);
|
||||
|
||||
// Connect via vsock
|
||||
let mut stream = transport::connect_vsock(cid, port).await?;
|
||||
|
||||
/// Send handshake, spawn key simulator, and run URB loop.
|
||||
async fn do_test_hid_session<S: AsyncReadExt + AsyncWriteExt + Unpin>(
|
||||
stream: &mut S,
|
||||
device: &UsbDevice,
|
||||
handler: Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>>,
|
||||
) -> std::io::Result<()> {
|
||||
// Send device info (simplified handshake)
|
||||
let handshake = UsbIpResponse::op_rep_import_success(&device).to_bytes()?;
|
||||
let handshake = UsbIpResponse::op_rep_import_success(device).to_bytes()?;
|
||||
stream
|
||||
.write_all(&handshake)
|
||||
.await
|
||||
|
|
@ -66,5 +44,43 @@ pub async fn run(cid: u32, port: u32) -> std::io::Result<()> {
|
|||
});
|
||||
|
||||
// Handle URBs
|
||||
usbip_rs::handle_urb_loop(&mut stream, &device).await
|
||||
usbip_rs::handle_urb_loop(stream, device).await
|
||||
}
|
||||
|
||||
pub async fn run(addr: transport::TransportAddr) -> std::io::Result<()> {
|
||||
// Create simulated HID keyboard
|
||||
let handler = Arc::new(Mutex::new(
|
||||
Box::new(UsbHidKeyboardHandler::new_keyboard()) as Box<dyn UsbInterfaceHandler + Send>
|
||||
));
|
||||
|
||||
let device = UsbDevice::new(0)?.with_interface(
|
||||
ClassCode::HID as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
Some("Test HID Keyboard"),
|
||||
vec![UsbEndpoint {
|
||||
address: 0x81, // IN
|
||||
attributes: 0x03, // Interrupt
|
||||
max_packet_size: 0x08, // 8 bytes
|
||||
interval: 10,
|
||||
}],
|
||||
handler.clone(),
|
||||
)?;
|
||||
|
||||
info!(
|
||||
"Created simulated HID keyboard {:04x}:{:04x}",
|
||||
device.vendor_id, device.product_id
|
||||
);
|
||||
|
||||
// Connect via transport and run session
|
||||
match addr {
|
||||
transport::TransportAddr::Vsock(v) => {
|
||||
let mut stream = transport::connect_vsock(v.cid, v.port).await?;
|
||||
do_test_hid_session(&mut stream, &device, handler).await
|
||||
}
|
||||
transport::TransportAddr::Tcp(ref t) => {
|
||||
let mut stream = transport::connect_tcp(t).await?;
|
||||
do_test_hid_session(&mut stream, &device, handler).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
use std::io::Result;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use tokio::net::{self, TcpListener, TcpStream};
|
||||
use tokio_vsock::{VMADDR_CID_ANY, VsockListener, VsockStream};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
@ -15,6 +17,10 @@ pub fn parse_vsock_addr(addr: &str) -> Result<VsockAddr> {
|
|||
std::io::Error::other(format!("Address must start with 'vsock:', got '{addr}'"))
|
||||
})?;
|
||||
|
||||
if rest.is_empty() {
|
||||
return Err(std::io::Error::other("Empty address after 'vsock:' prefix"));
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = rest.split(':').collect();
|
||||
match parts.len() {
|
||||
1 => {
|
||||
|
|
@ -60,3 +66,213 @@ pub fn listen_vsock(port: u32) -> Result<VsockListener> {
|
|||
.map_err(|e| std::io::Error::other(format!("Failed to bind vsock port={port}: {e}")))?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TcpAddr {
|
||||
pub host: Option<String>,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TransportAddr {
|
||||
Vsock(VsockAddr),
|
||||
Tcp(TcpAddr),
|
||||
}
|
||||
|
||||
/// Parse "tcp:<port>" or "tcp:<host>:<port>".
|
||||
/// When host is omitted, callers apply context-specific defaults.
|
||||
fn parse_tcp_addr(addr: &str) -> Result<TcpAddr> {
|
||||
let rest = addr.strip_prefix("tcp:").ok_or_else(|| {
|
||||
std::io::Error::other(format!("Address must start with 'tcp:', got '{addr}'"))
|
||||
})?;
|
||||
|
||||
if rest.is_empty() {
|
||||
return Err(std::io::Error::other("Empty address after 'tcp:' prefix"));
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = rest.split(':').collect();
|
||||
match parts.len() {
|
||||
1 => {
|
||||
let port = parts[0]
|
||||
.parse::<u16>()
|
||||
.map_err(|e| std::io::Error::other(format!("Invalid port '{}': {e}", parts[0])))?;
|
||||
Ok(TcpAddr { host: None, port })
|
||||
}
|
||||
2 => {
|
||||
let host = parts[0].to_string();
|
||||
let port = parts[1]
|
||||
.parse::<u16>()
|
||||
.map_err(|e| std::io::Error::other(format!("Invalid port '{}': {e}", parts[1])))?;
|
||||
Ok(TcpAddr {
|
||||
host: Some(host),
|
||||
port,
|
||||
})
|
||||
}
|
||||
_ => Err(std::io::Error::other(format!(
|
||||
"Invalid TCP address format: '{addr}'. Expected 'tcp:<port>' or 'tcp:<host>:<port>'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a transport address string. Dispatches on prefix.
|
||||
///
|
||||
/// Supported formats:
|
||||
/// - `vsock:<port>` or `vsock:<cid>:<port>`
|
||||
/// - `tcp:<port>` or `tcp:<host>:<port>`
|
||||
pub fn parse_address(addr: &str) -> Result<TransportAddr> {
|
||||
if addr.starts_with("vsock:") {
|
||||
parse_vsock_addr(addr).map(TransportAddr::Vsock)
|
||||
} else if addr.starts_with("tcp:") {
|
||||
parse_tcp_addr(addr).map(TransportAddr::Tcp)
|
||||
} else {
|
||||
Err(std::io::Error::other(format!(
|
||||
"Unknown transport prefix in '{addr}'. Expected 'vsock:' or 'tcp:'"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect_tcp(addr: &TcpAddr) -> Result<TcpStream> {
|
||||
let host = addr.host.as_deref().unwrap_or("127.0.0.1");
|
||||
log::info!("Connecting to TCP {host}:{}", addr.port);
|
||||
// Resolve hostname to socket addresses (supports both hostnames and IPs)
|
||||
let socket_addrs: Vec<SocketAddr> = net::lookup_host((host, addr.port))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
std::io::Error::other(format!(
|
||||
"Failed to resolve TCP address {host}:{}: {e}",
|
||||
addr.port
|
||||
))
|
||||
})?
|
||||
.collect();
|
||||
if socket_addrs.is_empty() {
|
||||
return Err(std::io::Error::other(format!(
|
||||
"No addresses found for {host}:{}",
|
||||
addr.port
|
||||
)));
|
||||
}
|
||||
log::debug!("Resolved {host} to {socket_addrs:?}");
|
||||
let stream = TcpStream::connect(socket_addrs.as_slice())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
std::io::Error::other(format!(
|
||||
"Failed to connect to TCP {host}:{}: {e}",
|
||||
addr.port
|
||||
))
|
||||
})?;
|
||||
log::info!("Connected to TCP {host}:{}", addr.port);
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
pub async fn listen_tcp(addr: &TcpAddr) -> Result<TcpListener> {
|
||||
let host: IpAddr = match &addr.host {
|
||||
Some(h) => h
|
||||
.parse()
|
||||
.map_err(|e| std::io::Error::other(format!("Invalid listen address '{h}': {e}")))?,
|
||||
None => IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
|
||||
};
|
||||
let socket_addr = SocketAddr::new(host, addr.port);
|
||||
log::info!("Listening on TCP {socket_addr}");
|
||||
let listener = TcpListener::bind(socket_addr).await.map_err(|e| {
|
||||
std::io::Error::other(format!("Failed to bind TCP {socket_addr}: {e}"))
|
||||
})?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_vsock_port_only() {
|
||||
let addr = parse_address("vsock:5000").unwrap();
|
||||
match addr {
|
||||
TransportAddr::Vsock(v) => {
|
||||
assert_eq!(v.cid, 2);
|
||||
assert_eq!(v.port, 5000);
|
||||
}
|
||||
_ => panic!("expected Vsock"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vsock_cid_and_port() {
|
||||
let addr = parse_address("vsock:3:5000").unwrap();
|
||||
match addr {
|
||||
TransportAddr::Vsock(v) => {
|
||||
assert_eq!(v.cid, 3);
|
||||
assert_eq!(v.port, 5000);
|
||||
}
|
||||
_ => panic!("expected Vsock"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tcp_port_only() {
|
||||
let addr = parse_address("tcp:3240").unwrap();
|
||||
match addr {
|
||||
TransportAddr::Tcp(t) => {
|
||||
assert_eq!(t.host, None);
|
||||
assert_eq!(t.port, 3240);
|
||||
}
|
||||
_ => panic!("expected Tcp"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tcp_host_and_port() {
|
||||
let addr = parse_address("tcp:192.168.1.5:3240").unwrap();
|
||||
match addr {
|
||||
TransportAddr::Tcp(t) => {
|
||||
assert_eq!(t.host.as_deref(), Some("192.168.1.5"));
|
||||
assert_eq!(t.port, 3240);
|
||||
}
|
||||
_ => panic!("expected Tcp"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tcp_hostname_and_port() {
|
||||
let addr = parse_address("tcp:myhost.local:3240").unwrap();
|
||||
match addr {
|
||||
TransportAddr::Tcp(t) => {
|
||||
assert_eq!(t.host.as_deref(), Some("myhost.local"));
|
||||
assert_eq!(t.port, 3240);
|
||||
}
|
||||
_ => panic!("expected Tcp"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_prefix_fails() {
|
||||
assert!(parse_address("udp:5000").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tcp_invalid_port_fails() {
|
||||
assert!(parse_address("tcp:notaport").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tcp_port_too_large_fails() {
|
||||
assert!(parse_address("tcp:99999").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tcp_any_host_accepted() {
|
||||
// Hostnames are accepted at parse time; resolution happens at connect time
|
||||
let addr = parse_address("tcp:not.valid.ip:3240").unwrap();
|
||||
match addr {
|
||||
TransportAddr::Tcp(t) => {
|
||||
assert_eq!(t.host.as_deref(), Some("not.valid.ip"));
|
||||
assert_eq!(t.port, 3240);
|
||||
}
|
||||
_ => panic!("expected Tcp"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_after_prefix_fails() {
|
||||
assert!(parse_address("tcp:").is_err());
|
||||
assert!(parse_address("vsock:").is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
751
docs/superpowers/plans/2026-03-19-tcp-transport.md
Normal file
751
docs/superpowers/plans/2026-03-19-tcp-transport.md
Normal file
|
|
@ -0,0 +1,751 @@
|
|||
# TCP Transport Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add TCP as an alternative transport alongside vsock in the CLI tool.
|
||||
|
||||
**Architecture:** Extend `transport.rs` with a `TransportAddr` enum and TCP connect/listen functions. Update `client.rs`, `host.rs`, `test_hid.rs`, and `main.rs` to accept `TransportAddr` and branch on the variant. The library crate is unchanged — `handle_urb_loop` is already generic.
|
||||
|
||||
**Tech Stack:** Rust 2024, tokio (adding `net` feature), existing tokio-vsock
|
||||
|
||||
**Build/test command:** `nix develop -c cargo test` (all tests), `nix develop -c cargo test -p usbip-rs-cli` (CLI crate only), `nix develop -c cargo build -p usbip-rs-cli` (build only)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-19-tcp-transport-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|----------------|
|
||||
| `cli/Cargo.toml` | Modify | Add tokio `"net"` feature, update description |
|
||||
| `cli/src/transport.rs` | Modify | Add `TcpAddr`, `TransportAddr`, `parse_address()`, `parse_tcp_addr()`, `connect_tcp()`, `listen_tcp()`, unit tests |
|
||||
| `cli/src/client.rs` | Modify | Accept `TransportAddr`, split into `read_handshake()` + per-transport handlers |
|
||||
| `cli/src/host.rs` | Modify | Accept `TransportAddr`, extract `do_host_session()` helper |
|
||||
| `cli/src/test_hid.rs` | Modify | Accept `TransportAddr`, extract `do_test_hid_session()` helper |
|
||||
| `cli/src/main.rs` | Modify | Use `parse_address()`, pass `TransportAddr` to subcommand runners, update help text |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add tokio `net` feature and update Cargo.toml
|
||||
|
||||
**Files:**
|
||||
- Modify: `cli/Cargo.toml`
|
||||
|
||||
- [ ] **Step 1: Add `"net"` to tokio features and update description**
|
||||
|
||||
In `cli/Cargo.toml`, change:
|
||||
```toml
|
||||
description = "USB/IP over vsock/TCP CLI tool"
|
||||
```
|
||||
and change the tokio line to:
|
||||
```toml
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time", "net"] }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `nix develop -c cargo build -p usbip-rs-cli`
|
||||
Expected: Compiles successfully (no code changes yet, just the feature addition)
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add cli/Cargo.toml
|
||||
git commit -m "feat: enable tokio net feature for TCP transport"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add TCP address types and parsing to transport.rs
|
||||
|
||||
**Files:**
|
||||
- Modify: `cli/src/transport.rs`
|
||||
|
||||
- [ ] **Step 1: Write unit tests for address parsing**
|
||||
|
||||
Add the following at the bottom of `cli/src/transport.rs`:
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_vsock_port_only() {
|
||||
let addr = parse_address("vsock:5000").unwrap();
|
||||
match addr {
|
||||
TransportAddr::Vsock(v) => {
|
||||
assert_eq!(v.cid, 2);
|
||||
assert_eq!(v.port, 5000);
|
||||
}
|
||||
_ => panic!("expected Vsock"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vsock_cid_and_port() {
|
||||
let addr = parse_address("vsock:3:5000").unwrap();
|
||||
match addr {
|
||||
TransportAddr::Vsock(v) => {
|
||||
assert_eq!(v.cid, 3);
|
||||
assert_eq!(v.port, 5000);
|
||||
}
|
||||
_ => panic!("expected Vsock"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tcp_port_only() {
|
||||
let addr = parse_address("tcp:3240").unwrap();
|
||||
match addr {
|
||||
TransportAddr::Tcp(t) => {
|
||||
assert_eq!(t.host, None);
|
||||
assert_eq!(t.port, 3240);
|
||||
}
|
||||
_ => panic!("expected Tcp"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tcp_host_and_port() {
|
||||
let addr = parse_address("tcp:192.168.1.5:3240").unwrap();
|
||||
match addr {
|
||||
TransportAddr::Tcp(t) => {
|
||||
assert_eq!(t.host, Some(std::net::Ipv4Addr::new(192, 168, 1, 5).into()));
|
||||
assert_eq!(t.port, 3240);
|
||||
}
|
||||
_ => panic!("expected Tcp"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_unknown_prefix_fails() {
|
||||
assert!(parse_address("udp:5000").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tcp_invalid_port_fails() {
|
||||
assert!(parse_address("tcp:notaport").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tcp_port_too_large_fails() {
|
||||
assert!(parse_address("tcp:99999").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_tcp_invalid_host_fails() {
|
||||
assert!(parse_address("tcp:not.valid.ip:3240").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_after_prefix_fails() {
|
||||
assert!(parse_address("tcp:").is_err());
|
||||
assert!(parse_address("vsock:").is_err());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `nix develop -c cargo test -p usbip-rs-cli`
|
||||
Expected: Compilation error — `parse_address` and `TransportAddr` don't exist yet.
|
||||
|
||||
- [ ] **Step 3: Add TcpAddr, TransportAddr, and parse functions**
|
||||
|
||||
Add the following to `cli/src/transport.rs` (after the existing imports, before `parse_vsock_addr`):
|
||||
|
||||
```rust
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
```
|
||||
|
||||
Add the following types and functions (after `listen_vsock`, before the `#[cfg(test)]` block):
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TcpAddr {
|
||||
pub host: Option<IpAddr>,
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TransportAddr {
|
||||
Vsock(VsockAddr),
|
||||
Tcp(TcpAddr),
|
||||
}
|
||||
|
||||
/// Parse "tcp:<port>" or "tcp:<host>:<port>".
|
||||
/// When host is omitted, callers apply context-specific defaults.
|
||||
fn parse_tcp_addr(addr: &str) -> Result<TcpAddr> {
|
||||
let rest = addr.strip_prefix("tcp:").ok_or_else(|| {
|
||||
std::io::Error::other(format!("Address must start with 'tcp:', got '{addr}'"))
|
||||
})?;
|
||||
|
||||
if rest.is_empty() {
|
||||
return Err(std::io::Error::other("Empty address after 'tcp:' prefix"));
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = rest.split(':').collect();
|
||||
match parts.len() {
|
||||
1 => {
|
||||
let port = parts[0]
|
||||
.parse::<u16>()
|
||||
.map_err(|e| std::io::Error::other(format!("Invalid port '{}': {e}", parts[0])))?;
|
||||
Ok(TcpAddr { host: None, port })
|
||||
}
|
||||
2 => {
|
||||
let host: IpAddr = parts[0]
|
||||
.parse()
|
||||
.map_err(|e| std::io::Error::other(format!("Invalid host '{}': {e}", parts[0])))?;
|
||||
let port = parts[1]
|
||||
.parse::<u16>()
|
||||
.map_err(|e| std::io::Error::other(format!("Invalid port '{}': {e}", parts[1])))?;
|
||||
Ok(TcpAddr {
|
||||
host: Some(host),
|
||||
port,
|
||||
})
|
||||
}
|
||||
_ => Err(std::io::Error::other(format!(
|
||||
"Invalid TCP address format: '{addr}'. Expected 'tcp:<port>' or 'tcp:<host>:<port>'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a transport address string. Dispatches on prefix.
|
||||
///
|
||||
/// Supported formats:
|
||||
/// - `vsock:<port>` or `vsock:<cid>:<port>`
|
||||
/// - `tcp:<port>` or `tcp:<host>:<port>`
|
||||
pub fn parse_address(addr: &str) -> Result<TransportAddr> {
|
||||
if addr.starts_with("vsock:") {
|
||||
parse_vsock_addr(addr).map(TransportAddr::Vsock)
|
||||
} else if addr.starts_with("tcp:") {
|
||||
parse_tcp_addr(addr).map(TransportAddr::Tcp)
|
||||
} else {
|
||||
Err(std::io::Error::other(format!(
|
||||
"Unknown transport prefix in '{addr}'. Expected 'vsock:' or 'tcp:'"
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect_tcp(addr: &TcpAddr) -> Result<TcpStream> {
|
||||
let host = addr.host.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
|
||||
let socket_addr = SocketAddr::new(host, addr.port);
|
||||
log::info!("Connecting to TCP {socket_addr}");
|
||||
let stream = TcpStream::connect(socket_addr).await.map_err(|e| {
|
||||
std::io::Error::other(format!("Failed to connect to TCP {socket_addr}: {e}"))
|
||||
})?;
|
||||
log::info!("Connected to TCP {socket_addr}");
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
pub async fn listen_tcp(addr: &TcpAddr) -> Result<TcpListener> {
|
||||
let host = addr.host.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
|
||||
let socket_addr = SocketAddr::new(host, addr.port);
|
||||
log::info!("Listening on TCP {socket_addr}");
|
||||
let listener = TcpListener::bind(socket_addr).await.map_err(|e| {
|
||||
std::io::Error::other(format!("Failed to bind TCP {socket_addr}: {e}"))
|
||||
})?;
|
||||
Ok(listener)
|
||||
}
|
||||
```
|
||||
|
||||
Also update the `parse_vsock_addr` function to handle the empty-after-prefix case. Add this check at the start of `parse_vsock_addr`, right after stripping the prefix:
|
||||
|
||||
```rust
|
||||
if rest.is_empty() {
|
||||
return Err(std::io::Error::other("Empty address after 'vsock:' prefix"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `nix develop -c cargo test -p usbip-rs-cli`
|
||||
Expected: All 9 new tests pass. (The `parse_tcp_invalid_host_fails` test expects that `"not.valid.ip"` fails to parse as an `IpAddr`, which it will.)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add cli/src/transport.rs
|
||||
git commit -m "feat: add TCP address types, parsing, and connect/listen functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Refactor all command modules to accept TransportAddr
|
||||
|
||||
All four files are modified together in one task so that every commit compiles.
|
||||
|
||||
**Files:**
|
||||
- Modify: `cli/src/client.rs`
|
||||
- Modify: `cli/src/host.rs`
|
||||
- Modify: `cli/src/test_hid.rs`
|
||||
- Modify: `cli/src/main.rs`
|
||||
|
||||
- [ ] **Step 1: Replace `cli/src/client.rs`**
|
||||
|
||||
Replace the entire contents with:
|
||||
|
||||
```rust
|
||||
use log::{debug, error, info, trace, warn};
|
||||
use std::os::unix::io::IntoRawFd;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::signal;
|
||||
|
||||
use usbip_rs::UsbDevice;
|
||||
use usbip_rs::usbip_protocol::{OP_REP_IMPORT, USBIP_VERSION};
|
||||
|
||||
use crate::transport::{self, TransportAddr};
|
||||
use crate::vhci;
|
||||
|
||||
const HANDSHAKE_SIZE: usize = 320;
|
||||
|
||||
pub async fn run(addr: TransportAddr) -> std::io::Result<()> {
|
||||
match addr {
|
||||
TransportAddr::Vsock(ref v) => run_vsock(v.port).await,
|
||||
TransportAddr::Tcp(ref t) => run_tcp(t).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_vsock(port: u32) -> std::io::Result<()> {
|
||||
let listener = transport::listen_vsock(port)?;
|
||||
info!("Client listening on vsock port {port}");
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, addr)) => {
|
||||
info!("Accepted connection from CID={}", addr.cid());
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_vsock_connection(stream).await {
|
||||
error!("Connection handler error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Accept error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = signal::ctrl_c() => {
|
||||
info!("Received shutdown signal, stopping listener");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_tcp(addr: &transport::TcpAddr) -> std::io::Result<()> {
|
||||
let listener = transport::listen_tcp(addr).await?;
|
||||
info!("Client listening on TCP port {}", addr.port);
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
accept_result = listener.accept() => {
|
||||
match accept_result {
|
||||
Ok((stream, peer)) => {
|
||||
info!("Accepted connection from {peer}");
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_tcp_connection(stream).await {
|
||||
error!("Connection handler error: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Accept error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = signal::ctrl_c() => {
|
||||
info!("Received shutdown signal, stopping listener");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read and validate the 320-byte handshake, return parsed device info.
|
||||
async fn read_handshake<S: AsyncReadExt + Unpin>(stream: &mut S) -> std::io::Result<UsbDevice> {
|
||||
let mut handshake = [0u8; HANDSHAKE_SIZE];
|
||||
stream
|
||||
.read_exact(&mut handshake)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("Failed to read handshake: {e}")))?;
|
||||
|
||||
trace!("Handshake bytes: {:02x?}", &handshake[..]);
|
||||
|
||||
let version = u16::from_be_bytes(handshake[0..2].try_into().unwrap());
|
||||
let command = u16::from_be_bytes(handshake[2..4].try_into().unwrap());
|
||||
let status = u32::from_be_bytes(handshake[4..8].try_into().unwrap());
|
||||
|
||||
if version != USBIP_VERSION {
|
||||
warn!("Invalid handshake version: {version:#06x}, expected {USBIP_VERSION:#06x}");
|
||||
return Err(std::io::Error::other("Invalid handshake version"));
|
||||
}
|
||||
|
||||
if command != OP_REP_IMPORT {
|
||||
warn!("Invalid handshake command: {command:#06x}, expected {OP_REP_IMPORT:#06x}");
|
||||
return Err(std::io::Error::other("Invalid handshake command"));
|
||||
}
|
||||
|
||||
if status != 0 {
|
||||
warn!("Handshake status indicates failure: {status}");
|
||||
return Err(std::io::Error::other("Handshake status indicates failure"));
|
||||
}
|
||||
|
||||
let device = UsbDevice::from_bytes(&handshake[8..]);
|
||||
info!(
|
||||
"Importing device {:04x}:{:04x} (bus_id={}, speed={})",
|
||||
device.vendor_id, device.product_id, device.bus_id, device.speed
|
||||
);
|
||||
debug!(
|
||||
"Device details: class={:02x} subclass={:02x} protocol={:02x} configs={}",
|
||||
device.device_class,
|
||||
device.device_subclass,
|
||||
device.device_protocol,
|
||||
device.num_configurations
|
||||
);
|
||||
trace!("Full device: {:?}", device);
|
||||
|
||||
Ok(device)
|
||||
}
|
||||
|
||||
/// Attach the device fd to vhci_hcd.
|
||||
fn attach_to_vhci(raw_fd: i32, device: &UsbDevice) -> std::io::Result<()> {
|
||||
let (vhci_port, attach_path) = vhci::find_free_port(device.speed)?;
|
||||
debug!("Extracted raw fd={raw_fd} for vhci handoff");
|
||||
vhci::attach(vhci_port, raw_fd, 0, device.speed, &attach_path)?;
|
||||
info!(
|
||||
"Device {:04x}:{:04x} attached on vhci port {vhci_port}",
|
||||
device.vendor_id, device.product_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_vsock_connection(mut stream: tokio_vsock::VsockStream) -> std::io::Result<()> {
|
||||
let device = read_handshake(&mut stream).await?;
|
||||
let raw_fd = stream.into_raw_fd();
|
||||
attach_to_vhci(raw_fd, &device)
|
||||
}
|
||||
|
||||
async fn handle_tcp_connection(mut stream: tokio::net::TcpStream) -> std::io::Result<()> {
|
||||
let device = read_handshake(&mut stream).await?;
|
||||
let std_stream = stream.into_std().map_err(|e| {
|
||||
std::io::Error::other(format!("Failed to convert TcpStream to std: {e}"))
|
||||
})?;
|
||||
let raw_fd = std_stream.into_raw_fd();
|
||||
attach_to_vhci(raw_fd, &device)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `cli/src/host.rs`**
|
||||
|
||||
The device parsing/opening/building code (lines 1-236) stays unchanged. Replace `run()` (lines 238-281) with:
|
||||
|
||||
```rust
|
||||
/// Send handshake and run URB loop over any async stream.
|
||||
async fn do_host_session<S: tokio::io::AsyncReadExt + tokio::io::AsyncWriteExt + Unpin>(
|
||||
stream: &mut S,
|
||||
device: &UsbDevice,
|
||||
) -> Result<()> {
|
||||
// Send OP_REP_IMPORT with device info (simplified handshake)
|
||||
let response = UsbIpResponse::op_rep_import_success(device);
|
||||
let response_bytes = response.to_bytes()?;
|
||||
debug!("Sending OP_REP_IMPORT ({} bytes)", response_bytes.len());
|
||||
stream
|
||||
.write_all(&response_bytes)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("Failed to send device info: {e}")))?;
|
||||
info!("Sent device info to client");
|
||||
|
||||
// Enter URB handling loop
|
||||
info!("Entering URB handling loop");
|
||||
let result = usbip_rs::handle_urb_loop(stream, device).await;
|
||||
|
||||
match &result {
|
||||
Ok(()) => info!("URB loop ended normally"),
|
||||
Err(e) => error!("URB loop ended with error: {e}"),
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Main entry point for the host connect command.
|
||||
pub async fn run(addr: transport::TransportAddr, device_arg: &str) -> Result<()> {
|
||||
info!("Host connect: device={device_arg} -> {addr:?}");
|
||||
|
||||
// 1. Parse device argument
|
||||
let (bus, dev) = parse_device_arg(device_arg)?;
|
||||
info!("Resolved device: bus={bus} dev={dev}");
|
||||
|
||||
// 2. Open the USB device via nusb
|
||||
let (nusb_dev, dev_info) = open_device(bus, dev)?;
|
||||
info!(
|
||||
"Opened USB device: {:04x}:{:04x}",
|
||||
dev_info.vendor_id(),
|
||||
dev_info.product_id()
|
||||
);
|
||||
|
||||
// 3. Build a UsbDevice with interface handlers
|
||||
let device = build_usb_device(nusb_dev, dev_info)?;
|
||||
|
||||
// 4. Connect via transport and run session
|
||||
match addr {
|
||||
transport::TransportAddr::Vsock(v) => {
|
||||
let mut stream = transport::connect_vsock(v.cid, v.port).await?;
|
||||
do_host_session(&mut stream, &device).await
|
||||
}
|
||||
transport::TransportAddr::Tcp(ref t) => {
|
||||
let mut stream = transport::connect_tcp(t).await?;
|
||||
do_host_session(&mut stream, &device).await
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `cli/src/test_hid.rs`**
|
||||
|
||||
Replace the entire contents with:
|
||||
|
||||
```rust
|
||||
use log::info;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
use usbip_rs::{
|
||||
ClassCode, UsbDevice, UsbEndpoint, UsbInterfaceHandler, hid::UsbHidKeyboardHandler,
|
||||
usbip_protocol::UsbIpResponse,
|
||||
};
|
||||
|
||||
use crate::transport;
|
||||
|
||||
/// Send handshake, spawn key simulator, and run URB loop.
|
||||
async fn do_test_hid_session<S: AsyncReadExt + AsyncWriteExt + Unpin>(
|
||||
stream: &mut S,
|
||||
device: &UsbDevice,
|
||||
handler: Arc<Mutex<Box<dyn UsbInterfaceHandler + Send>>>,
|
||||
) -> std::io::Result<()> {
|
||||
// Send device info (simplified handshake)
|
||||
let handshake = UsbIpResponse::op_rep_import_success(device).to_bytes()?;
|
||||
stream
|
||||
.write_all(&handshake)
|
||||
.await
|
||||
.map_err(|e| std::io::Error::other(format!("Failed to send handshake: {e}")))?;
|
||||
info!("Handshake sent, entering URB loop");
|
||||
|
||||
// Spawn key event simulator
|
||||
let handler_clone = handler.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
let mut h = handler_clone.lock().unwrap();
|
||||
if let Some(hid) = h.as_any().downcast_mut::<UsbHidKeyboardHandler>() {
|
||||
match usbip_rs::hid::UsbHidKeyboardReport::from_ascii(b'1') {
|
||||
Ok(report) => {
|
||||
hid.pending_key_events.push_back(report);
|
||||
info!("Simulated key event '1'");
|
||||
}
|
||||
Err(e) => {
|
||||
info!("Failed to create key report: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle URBs
|
||||
usbip_rs::handle_urb_loop(stream, device).await
|
||||
}
|
||||
|
||||
pub async fn run(addr: transport::TransportAddr) -> std::io::Result<()> {
|
||||
// Create simulated HID keyboard
|
||||
let handler = Arc::new(Mutex::new(
|
||||
Box::new(UsbHidKeyboardHandler::new_keyboard()) as Box<dyn UsbInterfaceHandler + Send>
|
||||
));
|
||||
|
||||
let device = UsbDevice::new(0)?.with_interface(
|
||||
ClassCode::HID as u8,
|
||||
0x00,
|
||||
0x00,
|
||||
Some("Test HID Keyboard"),
|
||||
vec![UsbEndpoint {
|
||||
address: 0x81, // IN
|
||||
attributes: 0x03, // Interrupt
|
||||
max_packet_size: 0x08, // 8 bytes
|
||||
interval: 10,
|
||||
}],
|
||||
handler.clone(),
|
||||
)?;
|
||||
|
||||
info!(
|
||||
"Created simulated HID keyboard {:04x}:{:04x}",
|
||||
device.vendor_id, device.product_id
|
||||
);
|
||||
|
||||
// Connect via transport and run session
|
||||
match addr {
|
||||
transport::TransportAddr::Vsock(v) => {
|
||||
let mut stream = transport::connect_vsock(v.cid, v.port).await?;
|
||||
do_test_hid_session(&mut stream, &device, handler).await
|
||||
}
|
||||
transport::TransportAddr::Tcp(ref t) => {
|
||||
let mut stream = transport::connect_tcp(t).await?;
|
||||
do_test_hid_session(&mut stream, &device, handler).await
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace `cli/src/main.rs`**
|
||||
|
||||
Replace the entire contents with:
|
||||
|
||||
```rust
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
mod client;
|
||||
mod host;
|
||||
mod test_hid;
|
||||
mod transport;
|
||||
mod vhci;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "usbip-rs", about = "USB/IP over vsock/TCP")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Import USB devices from a remote host
|
||||
Client {
|
||||
#[command(subcommand)]
|
||||
action: ClientAction,
|
||||
},
|
||||
/// Export a USB device to a remote client
|
||||
Host {
|
||||
#[command(subcommand)]
|
||||
action: HostAction,
|
||||
},
|
||||
/// Export a simulated HID keyboard for testing
|
||||
#[command(name = "test_hid")]
|
||||
TestHid {
|
||||
#[command(subcommand)]
|
||||
action: TestHidAction,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ClientAction {
|
||||
/// Listen for incoming connections
|
||||
Listen {
|
||||
/// Transport address: vsock:[<cid>:]<port> or tcp:[<host>:]<port>
|
||||
address: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum HostAction {
|
||||
/// Connect to a listening client
|
||||
Connect {
|
||||
/// Transport address: vsock:[<cid>:]<port> or tcp:[<host>:]<port>
|
||||
address: String,
|
||||
/// USB device: /dev/bus/usb/BBB/DDD or bus ID (e.g. 1-2)
|
||||
device: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum TestHidAction {
|
||||
/// Connect to a listening client with a simulated HID keyboard
|
||||
Connect {
|
||||
/// Transport address: vsock:[<cid>:]<port> or tcp:[<host>:]<port>
|
||||
address: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::Builder::from_default_env()
|
||||
.format_timestamp(None)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Client { action } => match action {
|
||||
ClientAction::Listen { address } => {
|
||||
let addr = transport::parse_address(&address).expect("Invalid transport address");
|
||||
client::run(addr).await
|
||||
}
|
||||
},
|
||||
Commands::Host { action } => match action {
|
||||
HostAction::Connect { address, device } => {
|
||||
let addr = transport::parse_address(&address).expect("Invalid transport address");
|
||||
host::run(addr, &device).await
|
||||
}
|
||||
},
|
||||
Commands::TestHid { action } => match action {
|
||||
TestHidAction::Connect { address } => {
|
||||
let addr = transport::parse_address(&address).expect("Invalid transport address");
|
||||
test_hid::run(addr).await
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
log::error!("{e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build and test**
|
||||
|
||||
Run: `nix develop -c cargo build -p usbip-rs-cli`
|
||||
Expected: Compiles successfully.
|
||||
|
||||
Run: `nix develop -c cargo test`
|
||||
Expected: All tests pass (36 existing + 9 new transport parsing tests).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add cli/src/client.rs cli/src/host.rs cli/src/test_hid.rs cli/src/main.rs
|
||||
git commit -m "feat: add TCP transport support to all CLI commands"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Final verification
|
||||
|
||||
- [ ] **Step 1: Run full test suite**
|
||||
|
||||
Run: `nix develop -c cargo test`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 2: Verify CLI help output**
|
||||
|
||||
Run: `nix develop -c cargo run -p usbip-rs-cli -- --help`
|
||||
Expected: Shows "USB/IP over vsock/TCP" in the about line.
|
||||
|
||||
Run: `nix develop -c cargo run -p usbip-rs-cli -- client listen --help`
|
||||
Expected: Shows "Transport address: vsock:[<cid>:]<port> or tcp:[<host>:]<port>" for the address argument.
|
||||
|
||||
- [ ] **Step 3: Verify vsock commands still parse correctly**
|
||||
|
||||
Run: `nix develop -c cargo run -p usbip-rs-cli -- test_hid connect vsock:5000 2>&1 | head -5`
|
||||
Expected: Attempts to connect to vsock CID=2 port=5000 (will fail with connection error, but that's fine — it means parsing worked).
|
||||
|
||||
- [ ] **Step 4: Verify TCP commands parse correctly**
|
||||
|
||||
Run: `nix develop -c cargo run -p usbip-rs-cli -- test_hid connect tcp:3240 2>&1 | head -5`
|
||||
Expected: Attempts to connect to TCP 127.0.0.1:3240 (will fail with connection refused, but that's fine — it means parsing worked).
|
||||
83
docs/superpowers/specs/2026-03-19-tcp-transport-design.md
Normal file
83
docs/superpowers/specs/2026-03-19-tcp-transport-design.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# TCP Transport Support for usbip-rs CLI
|
||||
|
||||
## Purpose
|
||||
|
||||
Add TCP as an alternative transport alongside vsock for the CLI tool. This is primarily for testing — production use remains vsock. The security model is unchanged: client listens, host connects outbound.
|
||||
|
||||
## Scope
|
||||
|
||||
CLI crate only. No changes to the library crate. `handle_urb_loop` is already generic over `AsyncReadExt + AsyncWriteExt`, so it works with any stream type.
|
||||
|
||||
## Address Format
|
||||
|
||||
Unified address parsing dispatches on prefix:
|
||||
|
||||
- **vsock** (existing): `vsock:<port>`, `vsock:<cid>:<port>` (CID defaults to 2)
|
||||
- **tcp** (new): `tcp:<port>`, `tcp:<host>:<port>`
|
||||
- When host is omitted (`tcp:<port>`), callers apply context-specific defaults: `listen_tcp` uses `0.0.0.0`, `connect_tcp` uses `127.0.0.1`
|
||||
- IPv6 is out of scope — the colon-delimited format only supports IPv4 addresses and hostnames
|
||||
|
||||
## Changes by File
|
||||
|
||||
### `Cargo.toml`
|
||||
|
||||
- Add `"net"` to tokio features (for `TcpStream`/`TcpListener`)
|
||||
- Update package description to `"USB/IP over vsock/TCP CLI tool"`
|
||||
|
||||
### `transport.rs`
|
||||
|
||||
Add `TransportAddr` enum and unified parsing:
|
||||
|
||||
```rust
|
||||
struct TcpAddr {
|
||||
host: Option<IpAddr>, // None when user omitted host
|
||||
port: u16,
|
||||
}
|
||||
|
||||
enum TransportAddr {
|
||||
Vsock(VsockAddr),
|
||||
Tcp(TcpAddr),
|
||||
}
|
||||
|
||||
fn parse_address(addr: &str) -> Result<TransportAddr>
|
||||
```
|
||||
|
||||
Add `connect_tcp(addr: &TcpAddr) -> Result<TcpStream>` (defaults host to `127.0.0.1`) and `listen_tcp(addr: &TcpAddr) -> Result<TcpListener>` (defaults host to `0.0.0.0`) alongside existing vsock functions. This mirrors the vsock pattern where `parse_vsock_addr` returns the CID and `listen_vsock` overrides it with `VMADDR_CID_ANY`.
|
||||
|
||||
Add unit tests for address parsing: valid inputs, defaults, and error cases.
|
||||
|
||||
### `client.rs`
|
||||
|
||||
- `run()` takes `TransportAddr` instead of `port: u32`
|
||||
- Branches on variant to create `VsockListener` or `TcpListener`
|
||||
- Handshake parsing is generic: `async fn read_handshake<S: AsyncReadExt + Unpin>(stream: &mut S) -> Result<UsbDevice>` reads 320 bytes, validates header, and returns the parsed device
|
||||
- Fd extraction is per-transport because `tokio::net::TcpStream` does not implement `IntoRawFd` directly:
|
||||
- `handle_vsock_connection(VsockStream)`: calls `read_handshake`, then `stream.into_raw_fd()`
|
||||
- `handle_tcp_connection(TcpStream)`: calls `read_handshake`, then `stream.into_std()?.into_raw_fd()`
|
||||
- Both handlers share the same vhci attach logic after fd extraction
|
||||
|
||||
### `host.rs`
|
||||
|
||||
- `run()` takes `TransportAddr` instead of `(cid, port)`
|
||||
- Shared helper `do_host_session<S: AsyncReadExt + AsyncWriteExt + Unpin>(stream: &mut S, device: &UsbDevice)` extracts handshake + URB loop logic
|
||||
- `run()` branches on variant to connect, then calls the helper
|
||||
|
||||
### `test_hid.rs`
|
||||
|
||||
- Same pattern as `host.rs`: `run()` takes `TransportAddr`, branches on variant to connect, shared post-connect logic
|
||||
|
||||
### `main.rs`
|
||||
|
||||
- Calls `parse_address()` instead of `parse_vsock_addr()`
|
||||
- Passes `TransportAddr` to `client::run`, `host::run`, `test_hid::run`
|
||||
- Updated help text: `"Transport address: vsock:[<cid>:]<port> or tcp:[<host>:]<port>"`
|
||||
- Updated about string: `"USB/IP over vsock/TCP"`
|
||||
|
||||
## Dependencies
|
||||
|
||||
No new crate dependencies. Only adding the `"net"` feature to the existing tokio dependency.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests for `parse_address()` covering vsock and TCP variants, defaults, and error cases
|
||||
- Manual end-to-end: `usbip-rs client listen tcp:3240` + `usbip-rs test_hid connect tcp:3240` on loopback
|
||||
Loading…
Add table
Add a link
Reference in a new issue