diff --git a/.github/workflows/qemu.yml b/.github/workflows/qemu.yml new file mode 100644 index 0000000..7b4ff83 --- /dev/null +++ b/.github/workflows/qemu.yml @@ -0,0 +1,54 @@ +name: QEMU usbip integration test + +# Boot a real Linux inside QEMU and verify that the *simulated* USB devices +# exported by this crate actually work (a CDC ACM serial port and a HID keyboard). +on: [push, pull_request] + +jobs: + qemu-test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Install system packages + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + qemu-system-x86 busybox-static cpio usb.ids + + # The usbip userspace tool ships in the distro 'usbip' package (universe), + # or in linux-tools. Try both and make sure a binary is available. + if ! sudo apt-get install -y usbip; then + sudo apt-get install -y linux-tools-common linux-tools-generic + fi + + echo "--- installed binaries ---" + ls -l /usr/bin/busybox /usr/bin/qemu-system-x86_64 2>/dev/null || true + ls -l /usr/sbin/usbip /usr/bin/usbip 2>/dev/null || true + + - name: Preflight checks + run: | + set -euo pipefail + echo "kernel: $(uname -r)" + ls -l /boot/vmlinuz-$(uname -r) + ls -l /lib/modules/$(uname -r)/kernel/drivers/usb/usbip/ 2>/dev/null || true + ls -l /lib/modules/$(uname -r)/kernel/drivers/usb/class/cdc-acm.ko 2>/dev/null || true + command -v usbip >/dev/null 2>&1 || test -x /usr/sbin/usbip + test -e /dev/kvm && echo "KVM: available" || echo "KVM: not available (will use TCG)" + + - name: Run QEMU end-to-end test + run: | + ./scripts/qemu/run-qemu-test.sh --build --dump-log + + - name: Upload serial console log + if: always() + uses: actions/upload-artifact@v4 + with: + name: qemu-serial-log + path: .qemu-serial.log + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 96ef6c0..e1e372f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target Cargo.lock +/.qemu-serial.log diff --git a/README.md b/README.md index f7db312..01a57ff 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,12 @@ cargo build --release ### Examples -The `examples/` directory contains three example programs: +The `examples/` directory contains four example programs: 1. **hid_keyboard**: Simulate a HID keyboard that types something every second 2. **cdc_acm_serial**: Simulate a CDC ACM serial device that receives a character every second 3. **host**: Act as a USB/IP server, sharing physical devices from the host machine to remote clients +4. **demo**: Simulate a HID keyboard *and* a CDC ACM serial device together (used by the QEMU test) #### Running an example @@ -55,6 +56,25 @@ usbip list -r $remote_ip usbip attach -r $remote_ip -b $bus_id ``` +### QEMU end-to-end test + +The simulated devices can be verified against a real Linux kernel booted under +QEMU. The test assembles a minimal initramfs from the running kernel, its USB/IP +and cdc_acm modules, a static busybox and the `usbip` userspace client, then +boots it and uses `vhci-hcd` to attach the simulated keyboard and serial device. +Inside the guest it confirms the serial port emits `'a'` and that the keyboard +generates a `KEY_1` input event. + +Run it locally (requires `qemu-system-x86`, a static `busybox`, `cpio`, and the +`usbip` tool): + +```bash +./scripts/qemu/run-qemu-test.sh --build --dump-log +``` + +It runs under KVM when available and falls back to QEMU TCG otherwise. It is +also wired into CI via `.github/workflows/qemu.yml`. + ## License MIT License - see [LICENSE](LICENSE) file for details. diff --git a/examples/demo.rs b/examples/demo.rs new file mode 100644 index 0000000..b79a9c2 --- /dev/null +++ b/examples/demo.rs @@ -0,0 +1,94 @@ +//! Simulate a HID keyboard **and** a CDC ACM serial device on a single USB/IP server. +//! +//! This example is intended for the QEMU end-to-end test: it exports two simulated +//! devices on one server port (`0.0.0.0:3240`) so a real Linux (booted inside QEMU) +//! can attach to both of them and verify that the simulated serial port and keyboard +//! actually work. +use std::net::*; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use log::*; +use tokio::time; + +/// Build a simulated HID keyboard device. +fn keyboard_device() -> usbip::UsbDevice { + let handler = Arc::new(Mutex::new( + Box::new(usbip::hid::UsbHidKeyboardHandler::new_keyboard()) + as Box, + )); + let mut device = usbip::UsbDevice::new(1).with_interface( + usbip::ClassCode::HID as u8, + 0x00, + 0x00, + Some("Test HID"), + vec![usbip::UsbEndpoint { + address: 0x81, // IN + attributes: 0x03, // Interrupt + max_packet_size: 0x08, // 8 bytes + interval: 10, + }], + handler.clone(), + ); + device.bus_id = "1-1".to_string(); + device +} + +/// Build a simulated CDC ACM serial device. +fn serial_device() -> usbip::UsbDevice { + let handler = + Arc::new(Mutex::new(Box::new(usbip::cdc::UsbCdcAcmHandler::new()) + as Box)); + let mut device = usbip::UsbDevice::new(2).with_interface( + usbip::ClassCode::CDC as u8, + usbip::cdc::CDC_ACM_SUBCLASS, + 0x00, + Some("Test CDC ACM"), + usbip::cdc::UsbCdcAcmHandler::endpoints(), + handler.clone(), + ); + device.bus_id = "1-2".to_string(); + device +} + +#[tokio::main] +async fn main() { + env_logger::init(); + let keyboard = keyboard_device(); + let handler = keyboard.interfaces.first().unwrap().handler.clone(); + + let serial = serial_device(); + let serial_handler = serial.interfaces.first().unwrap().handler.clone(); + + let server = Arc::new(usbip::UsbIpServer::new_simulated(vec![keyboard, serial])); + let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 3240); + tokio::spawn(usbip::server(addr, server)); + + loop { + // Drive the simulated devices every second: + // - the keyboard presses "1" + // - the serial device emits a character 'a' + time::sleep(Duration::new(1, 0)).await; + + if let Some(hid) = handler + .lock() + .unwrap() + .as_any() + .downcast_mut::() + { + hid.pending_key_events + .push_back(usbip::hid::UsbHidKeyboardReport::from_ascii(b'1')); + info!("Simulate a key event"); + } + + if let Some(acm) = serial_handler + .lock() + .unwrap() + .as_any() + .downcast_mut::() + { + acm.tx_buffer.push(b'a'); + info!("Simulate a char input"); + } + } +} diff --git a/scripts/qemu/build-initramfs.sh b/scripts/qemu/build-initramfs.sh new file mode 100755 index 0000000..e52858e --- /dev/null +++ b/scripts/qemu/build-initramfs.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Build a minimal initramfs for the QEMU usbip end-to-end test. +# +# The initramfs bundles everything the guest needs to: +# 1. boot (via the host kernel, /boot/vmlinuz-$(uname -r)) +# 2. load the vhci-hcd + cdc_acm kernel modules +# 3. run the compiled usbip demo server (our library under test) +# 4. run the usbip userspace client and attach the simulated devices +# +# Usage: build-initramfs.sh +# +# Overridable via env: +# KERNEL kernel version whose modules/kernel to use (default: uname -r) +# BUSYBOX path to a *static* busybox binary (default: `which busybox`) +# USBIP path to the usbip userspace binary (default: auto-detect) +# DEMO_BIN path to the compiled demo example (default: target/release/examples/demo) +set -euo pipefail + +OUT="${1:?usage: build-initramfs.sh }" +KERNEL="${KERNEL:-$(uname -r)}" +BUSYBOX="${BUSYBOX:-$(command -v busybox)}" +USBIP="${USBIP:-$(command -v usbip || echo /usr/sbin/usbip)}" +DEMO_BIN="${DEMO_BIN:-target/release/examples/demo}" + +[ -e "$BUSYBOX" ] || { echo "ERROR: busybox not found at $BUSYBOX"; exit 1; } +[ -e "$USBIP" ] || { echo "ERROR: usbip not found at $USBIP"; exit 1; } +[ -e "$DEMO_BIN" ] || { echo "ERROR: demo binary not found at $DEMO_BIN"; exit 1; } + +ROOT=$(mktemp -d) +trap 'rm -rf "$ROOT"' EXIT + +echo "kernel=$KERNEL busybox=$BUSYBOX usbip=$USBIP demo=$DEMO_BIN" + +# --- binaries --- +mkdir -p "$ROOT/bin" "$ROOT/sbin" "$ROOT/usr/sbin" "$ROOT/usr/share/misc" +cp "$BUSYBOX" "$ROOT/bin/busybox" + +# busybox applet symlinks (see init.sh for the applets we use) +for a in sh insmod modprobe ls cat dmesg grep sleep dd hexdump od mount mknod \ + poweroff reboot echo printf test head tail cp rm more seq timeout mkdir \ + sync umount true false mdev find ifconfig stty; do + ln -sf busybox "$ROOT/bin/$a" +done +ln -sf busybox "$ROOT/sbin/modprobe" +ln -sf busybox "$ROOT/sbin/insmod" +ln -sf busybox "$ROOT/sbin/mdev" + +cp "$USBIP" "$ROOT/usr/sbin/usbip" +cp "$DEMO_BIN" "$ROOT/demo_server" + +# --- kernel modules (preserve layout so the guest can `find` them) --- +for mod in usbip-core vhci-hcd cdc-acm; do + ko=$(find "/lib/modules/$KERNEL" -name "$mod.ko" 2>/dev/null | head -1) + if [ -z "$ko" ]; then + echo "ERROR: kernel module $mod.ko not found for kernel $KERNEL" + exit 1 + fi + mkdir -p "$ROOT$(dirname "$ko")" + cp "$ko" "$ROOT$ko" +done + +# --- usb.ids database required by the usbip tool --- +UI="$(find /usr/share -name usb.ids 2>/dev/null | head -1)" +[ -n "$UI" ] && cp "$UI" "$ROOT/usr/share/misc/usb.ids" || echo "WARN: usb.ids not found" + +# --- libraries needed by usbip + demo server (copy preserving absolute path) --- +copy_libs() { + local bin="$1" + ldd "$bin" 2>/dev/null \ + | grep -oE '/[^ ]+\.so[^ ]*' \ + | sort -u \ + | while read -r lib; do + [ -e "$lib" ] || continue + mkdir -p "$ROOT$(dirname "$lib")" + cp -L "$lib" "$ROOT$lib" + done +} +copy_libs "$USBIP" +copy_libs "$DEMO_BIN" + +# --- device dirs (devtmpfs populates /dev at runtime) --- +mkdir -p "$ROOT/dev" "$ROOT/proc" "$ROOT/sys" "$ROOT/tmp" +mknod -m 666 "$ROOT/dev/console" c 5 1 2>/dev/null || true + +# --- init script --- +cp "$(dirname "$0")/init.sh" "$ROOT/init" +chmod +x "$ROOT/init" + +# --- cpio archive --- +( cd "$ROOT" && find . -print0 | cpio --null -ov --format=newc 2>/dev/null | gzip -9 > "$OUT" ) +echo "wrote $OUT" diff --git a/scripts/qemu/init.sh b/scripts/qemu/init.sh new file mode 100755 index 0000000..017950d --- /dev/null +++ b/scripts/qemu/init.sh @@ -0,0 +1,142 @@ +#!/bin/busybox sh +# QEMU end-to-end usbip test init script. +# +# Runs *inside* the QEMU guest. It: +# 1. mounts proc/sys/devtmpfs +# 2. loads vhci-hcd + cdc_acm kernel modules +# 3. starts the compiled usbip demo server (our library under test) +# 4. uses the usbip client to attach the simulated HID keyboard + CDC ACM serial +# 5. verifies the serial device emits 'a' and the keyboard emits KEY_1 +# 6. prints "TEST_RESULT: PASS" or "TEST_RESULT: FAIL" and powers off +# +# The workflow greps the serial console output for TEST_RESULT. +set -x + +PATH=/sbin:/bin:/usr/sbin:/usr/bin +export PATH + +mount -t proc proc /proc +mount -t sysfs sysfs /sys +mount -t devtmpfs devtmpfs /dev 2>/dev/null || true +mkdir -p /dev/pts +mount -t devpts devpts /dev/pts 2>/dev/null || true +mount -t tmpfs tmpfs /tmp 2>/dev/null || true + +# Bring up loopback so the usbip client can reach the local server on 127.0.0.1 +ifconfig lo 127.0.0.1 netmask 255.0.0.0 up 2>/dev/null || echo "WARN: could not bring up lo" + +echo "=== init: mounting done ===" + +K=$(uname -r) +echo "=== kernel: $K ===" + +echo "=== loading modules ===" +for mod in usbip-core vhci-hcd cdc-acm; do + ko=$(find /lib/modules -name "$mod.ko" 2>/dev/null | head -1) + echo "- loading $ko" + insmod "$ko" || echo "WARN: failed to load $ko" +done +sleep 1 + +echo "=== starting usbip demo server ===" +RUST_LOG=info /demo_server > /tmp/server.log 2>&1 & +SERVER_PID=$! +sleep 2 +if ! kill -0 $SERVER_PID 2>/dev/null; then + echo "!!! demo server died, log:" + cat /tmp/server.log +fi + +echo "=== usbip list ===" +usbip list -r 127.0.0.1 + +# Extract busids (e.g. 1-1, 1-2) from "usbip list -r" output +BUSIDS=$(usbip list -r 127.0.0.1 2>/dev/null \ + | sed -n 's/^[[:space:]]*\([0-9][0-9-]*\):.*/\1/p') +echo "=== bus ids: [$BUSIDS] ===" + +PASS=1 + +if [ -z "$BUSIDS" ]; then + echo "TEST_RESULT: FAIL (no exportable devices)" + dmesg | tail -40 + poweroff -f + sleep 1 + exit 1 +fi + +for BUSID in $BUSIDS; do + echo "=== attaching $BUSID ===" + # Note: the usbip tool may report a spurious "record connection" error after a + # successful attach; the kernel still enumerates the device, so we don't abort here. + usbip attach -r 127.0.0.1 -b "$BUSID" + echo "=== attach exit: $? ===" + sleep 2 +done + +echo "=== waiting for devices ===" +for i in $(seq 1 30); do + ACM=$(ls /dev/ttyACM* 2>/dev/null | head -1) + EV=$(awk ' + /^S:.*vhci_hcd/ { f=1 } + f && /^H:/ { if (match($0, /event[0-9]+/)) { print substr($0, RSTART, RLENGTH); exit } } + ' /proc/bus/input/devices) + echo "iter $i: ACM=[$ACM] event=[$EV]" + if [ -n "$ACM" ] && [ -n "$EV" ]; then + break + fi + sleep 1 +done + +echo "=== /proc/bus/input/devices ===" +cat /proc/bus/input/devices 2>/dev/null +echo +echo "=== /dev listing ===" +ls -la /dev/ttyACM* /dev/input/ 2>/dev/null + +# ---- Serial test: read one raw byte from /dev/ttyACM0, expect 'a' (0x61) ---- +if [ -n "$ACM" ]; then + echo "=== serial test: reading $ACM (raw) ===" + stty -F "$ACM" raw -echo 2>/dev/null || true + DATA=$(timeout 8 dd if="$ACM" bs=1 count=1 2>/dev/null | od -An -tx1 -v | tr -d ' \n') + echo "serial byte: [$DATA]" + case "$DATA" in + 61) echo "SERIAL_TEST: PASS (got 'a' = 0x61)" ;; + *) echo "SERIAL_TEST: FAIL ([$DATA])"; PASS=0 ;; + esac +else + echo "SERIAL_TEST: FAIL (no /dev/ttyACM device)"; PASS=0 +fi + +# ---- Keyboard test: read the vhci HID event device, look for KEY_1 (code=2) ---- +if [ -n "$EV" ]; then + echo "=== keyboard test: reading /dev/input/$EV (timeout 6s) ===" + timeout 6 cat "/dev/input/$EV" > /tmp/kbd.bin 2>/dev/null + HEX=$(od -An -tx1 -v /tmp/kbd.bin | tr -d ' \n') + echo "kbd bytes: $HEX" + # EV_KEY(type=1) KEY_1(code=2) value=1(down) -> LE bytes "0100 0200 01000000" + case "$HEX" in + *0100020001000000*) echo "KEYBOARD_TEST: PASS (KEY_1 down event seen)" ;; + *) echo "KEYBOARD_TEST: FAIL"; PASS=0 ;; + esac +else + echo "KEYBOARD_TEST: FAIL (no vhci input event device)"; PASS=0 +fi + +echo "=== server.log (tail) ===" +tail -40 /tmp/server.log 2>/dev/null + +if [ "$PASS" = "1" ]; then + echo "TEST_RESULT: PASS" +else + echo "TEST_RESULT: FAIL" +fi + +echo "=== dmesg tail ===" +dmesg | tail -20 + +sync +sleep 1 +poweroff -f +sleep 1 +exit 0 diff --git a/scripts/qemu/run-qemu-test.sh b/scripts/qemu/run-qemu-test.sh new file mode 100755 index 0000000..7e9c55d --- /dev/null +++ b/scripts/qemu/run-qemu-test.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Run the QEMU-based usbip end-to-end test. +# +# This builds the `demo` example, assembles an initramfs from the running +# kernel + modules + busybox + usbip tool, boots it in QEMU, and verifies that +# the simulated serial port and keyboard actually work inside a real Linux. +# +# Usage: run-qemu-test.sh [--build] [--dump-log] +# --build build the demo example first (default: assume prebuilt) +# --dump-log print the full serial console log on stdout (for debugging) +# +# Exit: 0 if the test passed, non-zero otherwise. +set -uo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$REPO_ROOT" + +BUILD=0 +DUMP=0 +for arg in "$@"; do + case "$arg" in + --build) BUILD=1 ;; + --dump-log) DUMP=1 ;; + esac +done + +if [ "$BUILD" = "1" ]; then + echo "=== building demo example ===" + cargo build --example demo --release --all-features || exit 1 +fi + +DEMO_BIN="$REPO_ROOT/target/release/examples/demo" +[ -e "$DEMO_BIN" ] || { echo "ERROR: $DEMO_BIN not found; run with --build"; exit 1; } + +KERNEL=$(uname -r) +VMLINUZ=/boot/vmlinuz-$KERNEL +[ -e "$VMLINUZ" ] || { echo "ERROR: no vmlinuz for kernel $KERNEL at $VMLINUZ"; exit 1; } + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# Persist the serial console log for CI artefact upload / debugging. +SERIAL_LOG="${REPO_ROOT}/.qemu-serial.log" +rm -f "$SERIAL_LOG" + +echo "=== building initramfs ===" +DEMO_BIN="$DEMO_BIN" KERNEL="$KERNEL" "$SCRIPT_DIR/build-initramfs.sh" "$WORK/initramfs.cpio.gz" || exit 1 + +QEMU_CMD=(qemu-system-x86_64 -m 512 -smp 2) + +# Use hardware acceleration if /dev/kvm is present, otherwise fall back to TCG. +# FORCE_TCG=1 forces software emulation (useful for debugging / CI runners w/o KVM). +if [ "${FORCE_TCG:-0}" != "1" ] && [ -e /dev/kvm ] && [ -w /dev/kvm ]; then + QEMU_CMD+=(-enable-kvm -cpu host) + echo "=== using KVM acceleration ===" +else + QEMU_CMD+=(-cpu max) + echo "=== /dev/kvm not available, falling back to TCG (slow) ===" +fi + +QEMU_CMD+=( + -kernel "$VMLINUZ" + -initrd "$WORK/initramfs.cpio.gz" + -append "console=ttyS0 panic=-1" + -display none + -serial "file:$SERIAL_LOG" + -monitor none + -no-reboot +) + +KVM_FAIL=0 +echo "=== launching QEMU ===" +if [[ " ${QEMU_CMD[*]} " == *" -enable-kvm "* ]]; then + timeout 300 "${QEMU_CMD[@]}" 2>"$WORK/qemu.err" || { KVM_FAIL=$?; } + # If KVM failed (e.g. permission), retry with TCG once. + if [ "$KVM_FAIL" != "0" ]; then + echo "=== KVM run returned $KVM_FAIL, retrying with TCG ===" + rm -f "$SERIAL_LOG" + timeout 600 qemu-system-x86_64 -m 512 -smp 2 -cpu max \ + -kernel "$VMLINUZ" \ + -initrd "$WORK/initramfs.cpio.gz" \ + -append "console=ttyS0 panic=-1" \ + -display none -serial "file:$SERIAL_LOG" -monitor none -no-reboot \ + 2>"$WORK/qemu.err" || true + fi +else + timeout 600 "${QEMU_CMD[@]}" 2>"$WORK/qemu.err" || true +fi + +echo "=== serial log (tail) ===" +tail -80 "$SERIAL_LOG" || true + +if [ "$DUMP" = "1" ]; then + echo "=== full serial log ===" + cat "$SERIAL_LOG" 2>/dev/null || true + echo "=== qemu stderr ===" + cat "$WORK/qemu.err" 2>/dev/null || true +fi + +if grep -q "TEST_RESULT: PASS" "$SERIAL_LOG"; then + echo "SUCCESS: QEMU usbip test passed" + echo "VERIFY_SERIAL=$(grep -c 'SERIAL_TEST: PASS' "$SERIAL_LOG")" + echo "VERIFY_KEYBOARD=$(grep -c 'KEYBOARD_TEST: PASS' "$SERIAL_LOG")" + exit 0 +else + echo "FAILURE: TEST_RESULT: PASS not found in serial log" + exit 1 +fi