Skip to content

usb: a writable FAT32 user disk as a third LUN - #92

Merged
dormancygrace merged 3 commits into
mainfrom
claude/user-disk
Oct 11, 2026
Merged

dormancygrace merged 3 commits into
mainfrom
claude/user-disk

Conversation

@dormancygrace

Copy link
Copy Markdown
Owner

Upstream asks for a small writable drive next to the virtual CD, for BIOS update files and profiles (sipeed/NanoKVM#426, #477). The virtual storage had one LUN for images and, with USB serial, the read-only helper LUN. Stacked on #82 (claude/usb-identity): both edit S03usbdev, usbCompositionFlags and the USB settings page.

What the user gets

Settings → USB, a User disk card: create the image at 64 MiB … 4 GiB (FAT32, label NKVMDATA), turn it on, and the computer sees a third removable drive "NanoKVM User Disk" next to the virtual CD/disk. Format anew or delete while it is off. The card says when the drive needs the virtual disk function, and that turning it on or off reconnects the USB devices.

How

  • server/service/storage/userdisk.go: the image lives in /data/.nanokvm/user-disk.img. Create = a temporary file sized with Truncate, BusyBox mkfs.vfat -F 32 -s 1|8 -n NKVMDATA (one 512-byte sector per cluster up to 256 MiB, so FAT32 keeps its 65525 clusters; dosfstools on the build hosts needs the same flags), rename into place; a failed format leaves the old image. GET /api/storage/user-disk, POST …/create {sizeMiB}, POST …/delete; create and delete are refused while the disk is on or attached, because replacing a file under a live LUN leaves the host with a stale view.
  • The Images menu skips hidden directories under /data, and imageFile refuses paths through them, so the image is never offered for lun.0 nor deletable from there.
  • Turning it on is a composition change: PUT /api/vm/device/virtual takes an optional userDisk (omitted keeps it; on needs the disk function and an existing image). /boot/usb.userdisk names the image and joins the apply transaction next to the identity files (written by content, absent when off, restored on failure); it is in the revision; verifyUSBCompositionAt wants lun.2 to exist exactly when the user disk is on.
  • S03usbdev: while the marker names an existing file, lun.2 is created before the function is linked (the kernel refuses a LUN change on a linked function) and set up after: removable, writable, "NanoKVM User Disk". A LUN kept from the previous start still has its file set, and ro/cdrom are refused (EBUSY) while it does, so both the helper LUN and the user disk eject first; this also removes the "write error: Resource busy" line the helper LUN printed on every restart.
  • A LUN costs no endpoints; the budget is per function.

Checked

  • Go: creating under a temporary name and the size bounds, the old image kept when formatting fails, no temporary files left, the state from the marker and the LUN (another image on lun.2 is not "attached"), a real mkfs.vfat + mdir listing the label where available (CI installs dosfstools and mtools); the marker written and removed by the transaction, the validation (user disk without the disk function), the revision, the lun.2 check both ways. go vet, go test -tags teststub ./service/vm/ ./service/storage/, go build ./....
  • Shell: tools/test-usb-profiles.sh, tools/test-usb-rebind.sh, shellcheck.
  • Web: tsc --noEmit, eslint, prettier, the composition and i18n tests.
  • On the stand (script and formatter only, no server build): truncate -s 64M on the exFAT data partition allocates at once; BusyBox mkfs.vfat -F 32 -s 1 lays out FAT32 with 131072 clusters; marker written, new S03usbdev stop + start → lun.0 lun.1 lun.2, lun.2 ro=0 removable=1 inquiry "NanoKVM User Disk", helper LUN untouched, no "Resource busy"; on the Windows host Get-Disk shows "NanoKVM User Disk" 64 MiB online and Get-Volume the NKVMDATA FAT32 volume as E:; a file written from Windows, the disk turned off, the image loop-mounted on the NanoKVM: the file is there with its content. The stand was left as it was (console composition, helper LUN, no image).

Notes for deployment and review

  • S03usbdev accepts only /data/.nanokvm/user-disk.img in the marker (literal compare), so an edited /boot/usb.userdisk cannot put any other file on the card on a writable USB drive; the Go side fences the same path.
  • The first apply after upgrading runs the new script; a device still on the old script has no path to a lun.2, and the old script does not remove one either (seen on the stand: turning the disk off with main's script left lun.2 behind until the gadget was rebuilt). On a device running this PR the off path removes it.
  • SetUSBComposition checks that the image exists before taking the composition lock, without the storage service's mutex: a delete racing an enable can leave a marker that names a missing file. The script then skips the LUN at boot and the verification fails the apply and rolls it back; not engineered around.
  • The whole image is allocated on the exFAT data partition at creation (no sparse files there), so a 4 GiB image takes as long as the card writes it; the card notice says so.
  • BusyBox mkfs.vfat -F 32 -s N on the device: 64 and 256 MiB → 1 sector per cluster (131072 / 524288 clusters), 512 MiB and 1 GiB → 8 (131072 / 262144), all FAT32; the same flags give dosfstools on the build hosts the same layout.

Not covered

  • Legacy BIOS boot order with three LUNs: not checked on a BIOS; the user disk is removable like the others.
  • 2 and 4 GiB on the stand (the geometry is the one from 512 MiB up).

🤖 Generated with Claude Code

dormancygrace and others added 2 commits October 12, 2026 00:53
Upstream asks for this five times (sipeed/NanoKVM#829, #745, #574, #356,
#104): corporate USB policies and endpoint security refuse a device called
"sipeed NanoKVM", and the identity was hard-coded in S03usbdev and
S03usbhid. The USB settings page gets a USB identity section: the factory
identity, a "generic composite device" preset (the factory IDs with the
names Generic / USB Composite Device), or the four fields by hand. No
preset carries another vendor's ID; a custom ID is the user's own, and the
page says what it breaks: the serial console helper's udev rule on Linux
hosts names the IDs. The serial number stays the chip UID (that rule is
bound to it), bcdDevice stays the mode flag.

The identity is part of the composition. The scripts read /boot/usb.vid,
usb.pid (as before), usb.manufacturer and usb.product (new) at every start.
The recovery init reads the manufacturer and product files already; it takes
the IDs from its own usb.idVendor/usb.idProduct, and it lives in the
checksummed initramfs, so aligning it waits for a platform build (the docs
say so). The four files join the apply transaction: snapshotted, written by content
(absent for the factory value), restored on failure, and the identity the
gadget presents is read back from configfs in the verification. It is in
the revision, so a stale tab cannot change it unseen. PUT takes an optional
identity object; a client that sends none keeps the one in place.

Tests: ID and string validation, defaults and the files an identity needs,
reading /boot (a damaged file is the factory value), verification against a
fake configfs, the transaction writing, keeping on rollback and removing the
files; the web presets, matching and validation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Upstream asks for a small writable drive next to the virtual CD for BIOS
update files and profiles (sipeed/NanoKVM#426, #477); the virtual storage
had one LUN for images and, with USB serial, the read-only helper LUN.

The user disk is a FAT32 image on the data partition
(/data/.nanokvm/user-disk.img, label NKVMDATA), created from the USB
settings page at 64 MiB to 4 GiB: a temporary file sized with truncate,
BusyBox mkfs.vfat (-F 32, one sector per cluster up to 256 MiB so FAT32
keeps its 65525 clusters), then renamed into place, so a failed format
leaves the old image. Turning it on is a composition change:
/boot/usb.userdisk names the image, S03usbdev adds lun.2 before linking the
function (removable, writable, "NanoKVM User Disk"), the marker joins the
apply transaction with the identity files, the verification wants lun.2 to
match, and it is in the revision. It needs the virtual disk function and
costs no endpoints. Formatting anew and deleting are refused while it is on.
The Images menu skips hidden directories under /data, and imageFile refuses
paths through them, so the image cannot be mounted on lun.0 or deleted
from there.

Tests: creating under a temporary name and the size bounds, the old image
kept when formatting fails, the state from the marker and the LUN, a real
mkfs.vfat and mdir where available (CI has dosfstools and mtools); the
marker written and removed by the transaction, the validation, the
revision, the lun.2 check in verification; the web composition helpers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@dormancygrace dormancygrace left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of fffdc00: found the issue below. Please address it before merging. Reviewed source changes and available CI; no device changes were made.

}
s.mu.Lock()
defer s.mu.Unlock()
if userDiskEnabled() || userDiskAttached() {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Serialize image replacement/removal with USB composition changes

This check is protected only by storage.Service.mu, while SetUSBComposition uses virtualDeviceMutex. With an existing disk turned off, start reformatting it in tab A and enable it in tab B while createUserDisk is building the temporary image. B attaches the existing inode and succeeds; A then renames the new image over it. The host keeps writing to the now-unlinked old image, so its writes disappear on detach even though both requests can report success. The same check/use race allows deletion after a concurrent attach. This is worse than the missing-file/rollback case mentioned in the PR description. I reproduced the format case with a temporary-directory Go test: the open LUN handle remained the old 64 MiB inode while the canonical image became 128 MiB and enabled/attached both remained true. Use shared exclusion for create/delete and composition attach/detach across the full operation; a second unlocked existence check is insufficient.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and the delete path had the same gap. Fixed in d6db8aa.

A dedicated lock (storage.userDiskMu) now spans the whole operation on both sides:

  • CreateUserDisk and DeleteUserDisk hold it while they check "enabled or attached" and replace or remove the file.
  • applyLiveUSBComposition takes it whenever the current or the new composition has the user disk, for the whole apply, and re-checks that the image exists under it. It uses TryLock because it runs under the HID lock, which must not stall the keyboard and mouse for a multi-GiB format. A refused apply answers -6 ("being created or removed"), a vanished image -4, and the USB page says so.

Tests, under -race: your interleaving (a format in progress refuses the composition lock; a re-format started while the composition holds it waits, then refuses with the disk enabled and the attached inode unchanged), and the apply refusing both attach and detach while the image is being made.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked d6db8aa: fixed. Create/delete and live USB composition changes now share exclusion over the full operation, including the file-existence check and apply/rollback. Ran go test -race -tags teststub,nomsgpack ./service/storage ./service/vm successfully, including the format-vs-attach regressions. This finding is resolved.

Review: create/delete checked "enabled or attached" under the storage
service's mutex only, while a composition change runs under the HID lock.
Re-formatting with the disk off in one tab and turning it on in another
could attach the old inode and then rename the new image over it: the
host kept writing to an unlinked file, both requests reported success.
Deletion had the same check/use gap.

A dedicated lock (storage.userDiskMu) now covers the whole operation on
both sides. Create and delete hold it while they check the state and
replace or remove the file. applyLiveUSBComposition takes it with TryLock
whenever the current or the new composition has the user disk, for the
whole apply, and re-checks that the image exists under it; it does not
wait because it runs under the HID lock, which must not stall keyboard
and mouse for a multi-GiB format. A refused apply answers -6 ("being
created or removed"), a vanished image -4; the USB page says so.

Tests: the review's interleaving (a format in progress refuses the
composition lock; a re-format started while the composition holds it
waits, then refuses with the disk enabled and the attached inode
unchanged) and the apply refusing attach and detach while the image is
being made, under -race.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dormancygrace
dormancygrace changed the base branch from claude/usb-identity to main October 11, 2026 23:59
@dormancygrace
dormancygrace merged commit 9017b6d into main Oct 11, 2026
13 checks passed
@dormancygrace
dormancygrace deleted the claude/user-disk branch October 11, 2026 23:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant