USBAtlas is a greenfield Go CLI for inspecting USB device topology and provenance on Linux.
Current builds read USB topology from sysfs, enrich device names from usb.ids when available, optionally enrich USB/IP provenance from usbip port, and render the normalized graph as a tree, table, detail view, or JSON.
Status
USBAtlas is early development software. It currently supports local sysfs discovery, usb.ids name enrichment, and opt-in USB/IP provenance enrichment.
Not implemented yet:
- live monitoring
- TUI or GUI
- Graphviz or Mermaid output
- VM or container detection
- USB authorization or device control
Requirements
- Linux
- Go 1.22 or newer
- readable
/sys/bus/usb/devices - readable
/procfor opener discovery (partial results without root)
usb.ids is optional. If it is missing, USBAtlas still runs and includes numeric vendor/product IDs.
USB/IP enrichment is optional and opt-in with --usbip. If enabled, USBAtlas runs usbip port; missing or unsupported usbip output is non-fatal and is reported in JSON warnings.
Usage
Run from the repository checkout:
Default tree view:
List devices with origin and driver summaries:
go run ./cmd/usbatlas list
List with process attribution for devices that have an open file descriptor on a /dev node (requires --blame, list-only):
go run ./cmd/usbatlas list --blame
Show details for a node ID:
go run ./cmd/usbatlas show 1-2.3
Emit the normalized graph as JSON:
go run ./cmd/usbatlas json
Use a fixture or alternate sysfs root:
go run ./cmd/usbatlas --sysfs ./internal/app/testdata/sysfs/basic json
Use a specific usb.ids file:
go run ./cmd/usbatlas --usbids /usr/share/hwdata/usb.ids list
Override the /proc root for opener discovery (useful with fixtures):
go run ./cmd/usbatlas --proc ./internal/app/testdata/proc/empty list
Attempt USB/IP provenance enrichment:
go run ./cmd/usbatlas --usbip json
Flags may be combined:
go run ./cmd/usbatlas --sysfs ./internal/app/testdata/sysfs/basic --usbids ./internal/app/testdata/usb.ids --usbip json
Show command help:
go run ./cmd/usbatlas --help
Help output does not require sysfs discovery. Invalid commands, unknown flags, and missing required flag values print a specific error followed by usage text and exit non-zero.
Print non-fatal warnings to stderr for human commands (tree, list, show):
go run ./cmd/usbatlas --verbose
--verbose has no effect on json, which already includes warnings in its output. Default human output stays quiet.
Build & Install
Build a local binary with the standard Go toolchain:
go build -o usbatlas ./cmd/usbatlas
or with the provided Makefile target:
This produces a usbatlas binary in the repository root. Run it from there:
./usbatlas ./usbatlas list ./usbatlas --help
Install to your GOBIN (or $GOPATH/bin) with:
go install ./cmd/usbatlas
USBAtlas is Linux-only and reads /sys/bus/usb/devices at runtime, so a built binary must be run on a Linux host. Tagged releases are cross-compiled for linux/amd64, linux/arm64, and linux/arm.
Output Model
USBAtlas builds a normalized graph with:
usb_devicenodes for root hubs, hubs, and leaf devicesusb_interfacenodes for USB interfacesconnected_toedges for topologyhas_interfaceedges for device-to-interface relationships
Human renderers hide interface nodes in the default tree. JSON output includes the full graph.
The default tree annotates USB/IP-origin devices inline: [usbip] when the remote host is unknown, [usbip -> host] for an authoritative remote host, and [usbip -> host (inferred)] when the remote host is inferred. Use show <id> for the full remote metadata block.
For USB-to-serial adapters (FTDI, CP210x, CH340, PL2303, CDC-ACM, etc.), USBAtlas maps each interface to its host /dev/ttyUSB* or /dev/ttyACM* node via /sys/class/tty. More generally, USBAtlas enumerates host /dev character nodes per USB interface by walking /sys/class/tty, /sys/class/hidraw, and /sys/class/input, requiring a dev major/minor attribute, and finding the USB interface in each entry's resolved device ancestry.
- Tree: full
/dev/...paths appear as child leaves beneath the owning device, ordered as TTY, hidraw, then input. Raw/dev/bus/usb/...nodes are omitted to keep the topology readable. list: conditionalTTY,Hidraw, andInputcolumns shown only when at least one device in the graph has that kind of node.show <id>:TTY,Hidraw, andInputlines when present, followed by aRaw Deviceline giving the device's raw/dev/bus/usb/<bus>/<dev>path (zero-padded to 3 digits each, derived from bus/device numbers).json: nodes carrydev_nodeson each interface andraw_dev_nodeon each USB device.
USB device serial numbers (from sysfs) appear in the default views when present: the tree appends [serial: …] to a device line, and the list table includes a Serial column (empty when a device exposes none). show <id> and json already include the serial.
USBAtlas walks /proc/*/fd/* to detect which processes hold open file descriptors on a device's /dev nodes (raw /dev/bus/usb/..., TTY, hidraw, or input). When at least one opener is found:
- Tree: the device line gets a yellow
[in use]mark before any USB/IP annotation. show <id>: anIn Use: yesline appears afterRaw Device.list --blame: adds anOpenerscolumn showingpid:comm,...for matched devices,-when a device has a/devnode but no openers, orn/awhen the device has no/devnode to match.
Devices without a /dev node (e.g. hubs without a usable raw device path) cannot be matched and show n/a under --blame. Unreadable /proc/<pid>/fd directories are skipped; with --verbose a count-based summary warning is printed to stderr. Run as root for full coverage.
Color
Tree, list, and detail output use ANSI color when writing to a terminal, to make hubs, USB/IP-origin devices, and serial/device-node values easier to pick out:
- Hubs are bold.
- USB/IP-origin devices and their
[usbip …]annotations are cyan when the remote metadata is authoritative, and dim cyan when inferred. - Serial, TTY, hidraw, input, and raw-device values are magenta.
- In-use marks and opener values are yellow.
- Warnings printed with
--verboseuse a redwarning:prefix.
Color is automatically disabled when output is piped or redirected (not a TTY), when the NO_COLOR environment variable is set (https://no-color.org), or when --no-color is passed. JSON output is never colored. Override color detection with:
go run ./cmd/usbatlas --no-color list NO_COLOR=1 go run ./cmd/usbatlas list
USB/IP Permissions
Authoritative USB/IP remote metadata comes from usbip port, which reads per-port records under /run/vhci_hcd (or /var/run/vhci_hcd). On common hosts this directory is 0700 root:root, so unprivileged users cannot read it and usbip port emits libusbip: error: fopen / libusbip: error: read_record. When that happens USBAtlas falls back to established connections to port 3240 from /proc/net/tcp. A single candidate server is applied directly. With multiple candidate servers, USBAtlas queries usbip list -r <host> under a shared short timeout and matches exports by exact serial and VID/PID. Inferred results are marked (inferred).
Authoritative vs inferred:
- Authoritative — read from
usbip portrecords. Includes host, port, and remote bus ID. - Inferred — derived from TCP state and, when multiple servers are connected, remote export listings. Every candidate server must return a supported listing and each device must match exactly one host; otherwise that device's host remains unknown. Inferred data is marked in tree, detail, and JSON (
confidence: "inferred").
USB/IP identity sources
USBAtlas keeps local filesystem identity separate from remote-host identity:
- Sysfs identity comes from
/sys/bus/usb/devices. It identifies the locally enumerated device by its current topology ID, such as3-1, and supplies VID/PID, serial, product strings, interfaces, drivers, and/devmappings. An imported USB/IP device is still a real sysfs device under the VHCI controller, so its node keepssource: "sysfs"even when its provenance isorigin: "usbip". - VHCI filesystem identity comes from the runtime attachment records under
/run/vhci_hcd/port*(or/var/run/vhci_hcd/port*) as exposed byusbip port. These records directly associate a local VHCI port/device with the remote host, port, and bus ID. USBAtlas marks that remote metadataauthoritativebecause it describes the active local attachment. - Inferred identity is used when VHCI runtime records are unreadable. USBAtlas first finds established TCP peers on port 3240. One unique peer can identify all unresolved imports. With multiple peers, USBAtlas queries every candidate using
usbip list -r, then correlates each local sysfs device with a remote export using exact serial plus VID/PID. A host is assigned only when exactly one candidate matches and every candidate returned a complete supported listing.
Inference deliberately leaves the host unknown when a serial is absent, duplicated, conflicting, or hidden; when any candidate server cannot be queried; or when a listing is incomplete or unsupported. Remote bus/device numbers are not compared with remote sysfs bus IDs because those values use different Linux identity namespaces. authoritative and inferred describe evidence quality, not cryptographic authentication: USB descriptors and remote export listings can be duplicated or spoofed.
Practical options for authoritative metadata:
-
sudo— the simplest check:sudo usbatlas --usbip json
This runs the whole tool privileged; only needed when
--usbipis requested. -
Group / tmpfiles / service overrides — admins can grant a non-root group read access to
/run/vhci_hcdvia asystemd-tmpfilessnippet or avhci_hcdservice unit override, similar to howcdromordialoutgroups expose device nodes. USBAtlas does not perform or require any privilege escalation itself. Do not make the directory world-readable or blanket-chmodthe runtime state.A copy-pasteable group-based recipe:
# 1. Create a dedicated group for unprivileged USB/IP readers. sudo groupadd -r usbip # 2. Add the user that will run USBAtlas. sudo usermod -aG usbip "$USER" # Re-login or `newgrp usbip` for the new membership to take effect.
Then grant the group read+execute on the runtime directory using
systemd-tmpfilesso the rule persists across reboots and module reloads:# /etc/tmpfiles.d/vhci_hcd.conf # Type Path Mode User Group Age Argument d /run/vhci_hcd 0750 root usbip - -
Apply immediately without rebooting:
sudo systemd-tmpfiles --create /etc/tmpfiles.d/vhci_hcd.conf
On distributions where
vhci_hcdis loaded by a service unit rather than tmpfiles, apply the same group to that unit instead:sudo systemctl edit vhci-hcd.service
[Service] Group=usbip # Keep the runtime directory group-readable after the module creates it. ExecStartPost=/bin/chmod 0750 /run/vhci_hcd ExecStartPost=/bin/chgrp usbip /run/vhci_hcd
After this, an unprivileged member of
usbipcan run:and receive authoritative remote metadata instead of the inferred TCP fallback.
Caveats:
/run/vhci_hcdis runtime state and may not persist across reboots or kernel module reloads; any permission setup must be applied by a persistent mechanism (tmpfiles, service override), not a one-offchmod.- Group/tmpfiles rules only take effect after the
vhci_hcdmodule creates/run/vhci_hcd; on hosts where the module is loaded on demand, triggersystemd-tmpfiles --createafter the first USB/IP attach or reloadvhci_hcd. - Granting group read access exposes remote-attach metadata (host, port, bus ID) to non-root users; only add trusted users to the
usbipgroup. - Inference requires an established connection to port 3240. Multi-server inference also requires each candidate to permit
usbip list -rand expose VID/PID plus a serial for a unique match.
Development
Run the full local verification suite with:
This runs go test ./... and fixture-backed CLI smoke checks for tree, list, detail, and JSON output. The smoke checks use checked-in synthetic fixtures and do not depend on host USB hardware.
Run only Go tests with:
CI checks
The same checks that run in CI can be run locally:
This runs gofmt, go vet, golangci-lint, and make test. golangci-lint is auto-installed via go install if missing. The CI workflow (.github/workflows/ci.yml) does nothing beyond setting up Go and calling this script, so local and CI results match.
Tagged releases are built by:
This cross-compiles linux/amd64, linux/arm64, and linux/arm binaries, packages them as tarballs, and generates a SHA256 checksums file. Pushing a v* tag triggers the release workflow, which builds artifacts and creates a GitHub release.
Relevant project docs live under docs/:
docs/mvp.mddocs/architecture.mddocs/data-model.mddocs/roadmap.md
Work is tracked with Markdown tickets:
docs/backlog/for proposed or future workdocs/todo/for scoped work ready to pick updocs/completed/for completed work records