├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ └── lint.yml ├── LICENSE ├── README.md ├── _examples ├── actions.go └── serial.go ├── filter.go ├── filter_test.go ├── go.mod ├── go.sum ├── monitor.go └── usbmon.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | labels: 8 | - "dependencies" 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | labels: 14 | - "dependencies" 15 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | matrix: 13 | go-version: [~1.22] 14 | os: [ubuntu-latest] 15 | runs-on: ${{ matrix.os }} 16 | env: 17 | GO111MODULE: "on" 18 | steps: 19 | - name: Install Go 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | 27 | - name: Install deps 28 | run: sudo apt update && sudo apt install build-essential libudev-dev 29 | 30 | - name: Test 31 | run: go test 32 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | 8 | permissions: 9 | contents: read 10 | pull-requests: read 11 | 12 | jobs: 13 | golangci: 14 | name: lint 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Install deps 18 | run: sudo apt update && sudo apt install build-essential libudev-dev 19 | 20 | - uses: actions/setup-go@v5 21 | with: 22 | go-version: 1.22 23 | 24 | - uses: actions/checkout@v4 25 | 26 | - name: golangci-lint 27 | uses: golangci/golangci-lint-action@v8 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2022 Sergio Rubio 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Go Reference](https://pkg.go.dev/badge/github.com/rubiojr/go-usbmon.svg)](https://pkg.go.dev/github.com/rubiojr/go-usbmon) 2 | 3 | # USBMon 4 | 5 | A lightweight Go wrapper around libudev to simplify monitoring USB device add/remove events. This package abstracts away the complexities of the low-level udev API, providing a simple interface for detecting and responding to USB devices being connected or disconnected. 6 | 7 | ```Go 8 | // monitor USB hotplug events 9 | package main 10 | 11 | import ( 12 | "context" 13 | "fmt" 14 | 15 | "github.com/rubiojr/go-usbmon" 16 | ) 17 | 18 | func main() { 19 | // Print device properties when plugged in or unplugged 20 | filter := &usbmon.ActionFilter{Action: usbmon.ActionAll} 21 | devs, err := usbmon.ListenFiltered(context.Background(), filter) 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | for dev := range devs { 27 | fmt.Printf("-- Device %s\n", dev.Action()) 28 | fmt.Println("Serial: " + dev.Serial()) 29 | fmt.Println("Path: " + dev.Path()) 30 | fmt.Println("Vendor: " + dev.Vendor()) 31 | } 32 | } 33 | ``` 34 | 35 | ## Building 36 | 37 | ### Requirements 38 | 39 | * libudev 40 | 41 | Ubuntu/Debian: `apt install libudev-dev` 42 | 43 | Fedora/RHEL: `dnf install systemd-devel` 44 | 45 | Arch Linux: `pacman -S systemd-libs` 46 | 47 | ## Additional examples 48 | 49 | See [examples](_examples) for more usage examples. 50 | 51 | ## License 52 | 53 | MIT - See [LICENSE](LICENSE) for details. 54 | -------------------------------------------------------------------------------- /_examples/actions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/rubiojr/go-usbmon" 8 | ) 9 | 10 | func main() { 11 | // Print device properties when plugged in or unplugged 12 | filter := &usbmon.ActionFilter{Action: usbmon.ActionAll} 13 | devs, err := usbmon.ListenFiltered(context.Background(), filter) 14 | if err != nil { 15 | panic(err) 16 | } 17 | 18 | for dev := range devs { 19 | fmt.Printf("-- Device %s\n", dev.Action()) 20 | fmt.Println("Serial: " + dev.Serial()) 21 | fmt.Println("Path: " + dev.Path()) 22 | fmt.Println("Vendor: " + dev.Vendor()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /_examples/serial.go: -------------------------------------------------------------------------------- 1 | // Example that filters USB devices by serial number 2 | package main 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | 8 | "github.com/rubiojr/go-usbmon" 9 | ) 10 | 11 | func main() { 12 | filter := &usbmon.SerialFilter{Serial: "S6TWNS0T214043V"} 13 | devs, err := usbmon.ListenFiltered(context.Background(), filter) 14 | if err != nil { 15 | panic(err) 16 | } 17 | 18 | for dev := range devs { 19 | fmt.Println("Device detected!") 20 | fmt.Println("Action: " + dev.Action()) 21 | fmt.Println("Serial: " + dev.Serial()) 22 | fmt.Println("Path: " + dev.Path()) 23 | fmt.Println("Vendor: " + dev.Vendor()) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /filter.go: -------------------------------------------------------------------------------- 1 | package usbmon 2 | 3 | type Filter interface { 4 | Matches(*Device) bool 5 | } 6 | 7 | type ActionEvent string 8 | 9 | const ( 10 | ActionAdd ActionEvent = "add" 11 | ActionRemove ActionEvent = "remove" 12 | ActionAll ActionEvent = "all" 13 | ) 14 | 15 | type ActionFilter struct { 16 | Action ActionEvent 17 | } 18 | 19 | func (f *ActionFilter) Matches(dev *Device) bool { 20 | action := ActionEvent(dev.Properties()["ACTION"]) 21 | 22 | if f.Action == ActionAll && (action == ActionAdd || action == ActionRemove) { 23 | return true 24 | } 25 | 26 | return action == f.Action 27 | } 28 | 29 | type SerialFilter struct { 30 | Serial string 31 | } 32 | 33 | func (f *SerialFilter) Matches(dev *Device) bool { 34 | action := ActionEvent(dev.Action()) 35 | 36 | return f.Serial == dev.Serial() && (action == ActionAdd || action == ActionRemove) 37 | } 38 | 39 | type PartitionFilter struct { 40 | Serial string 41 | } 42 | 43 | func (f *PartitionFilter) Matches(dev *Device) bool { 44 | return dev.Properties()["DEVICETYPE"] == "partition" 45 | } 46 | -------------------------------------------------------------------------------- /filter_test.go: -------------------------------------------------------------------------------- 1 | package usbmon 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestActionFilter(t *testing.T) { 10 | d := Device{ 11 | properties: map[string]string{"ACTION": "add"}, 12 | } 13 | 14 | f := ActionFilter{Action: ActionAdd} 15 | assert.True(t, f.Matches(&d)) 16 | d.properties["ACTION"] = "all" 17 | assert.False(t, f.Matches(&d)) 18 | d.properties["ACTION"] = "remove" 19 | assert.False(t, f.Matches(&d)) 20 | 21 | f = ActionFilter{Action: ActionAll} 22 | d.properties["ACTION"] = "all" 23 | assert.True(t, f.Matches(&d)) 24 | d.properties["ACTION"] = "add" 25 | assert.True(t, f.Matches(&d)) 26 | d.properties["ACTION"] = "remove" 27 | assert.True(t, f.Matches(&d)) 28 | d.properties["ACTION"] = "unbind" 29 | assert.False(t, f.Matches(&d)) 30 | d.properties["ACTION"] = "bind" 31 | assert.False(t, f.Matches(&d)) 32 | 33 | f = ActionFilter{Action: ActionRemove} 34 | d.properties["ACTION"] = "all" 35 | assert.False(t, f.Matches(&d)) 36 | d.properties["ACTION"] = "add" 37 | assert.False(t, f.Matches(&d)) 38 | d.properties["ACTION"] = "remove" 39 | assert.True(t, f.Matches(&d)) 40 | } 41 | 42 | func TestSerialFilter(t *testing.T) { 43 | d := Device{ 44 | properties: map[string]string{"ACTION": "add", "ID_SERIAL_SHORT": "1234"}, 45 | } 46 | 47 | f := SerialFilter{Serial: "1234"} 48 | assert.True(t, f.Matches(&d)) 49 | d.properties["ACTION"] = "bind" 50 | assert.False(t, f.Matches(&d)) 51 | d.properties["ACTION"] = "remove" 52 | assert.True(t, f.Matches(&d)) 53 | d.properties["ACTION"] = "unbind" 54 | assert.False(t, f.Matches(&d)) 55 | f.Serial = "" 56 | assert.False(t, f.Matches(&d)) 57 | f.Serial = "123" 58 | assert.False(t, f.Matches(&d)) 59 | } 60 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rubiojr/go-usbmon 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/jochenvg/go-udev v0.0.0-20240801134859-b65ed646224b 7 | github.com/stretchr/testify v1.10.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/jkeiser/iter v0.0.0-20200628201005-c8aa0ae784d1 // indirect 13 | github.com/kr/pretty v0.3.0 // indirect 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect 16 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect 17 | gopkg.in/yaml.v3 v3.0.1 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/jkeiser/iter v0.0.0-20200628201005-c8aa0ae784d1 h1:smvLGU3obGU5kny71BtE/ibR0wIXRUiRFDmSn0Nxz1E= 5 | github.com/jkeiser/iter v0.0.0-20200628201005-c8aa0ae784d1/go.mod h1:fP/NdyhRVOv09PLRbVXrSqHhrfQypdZwgE2L4h2U5C8= 6 | github.com/jochenvg/go-udev v0.0.0-20240801134859-b65ed646224b h1:Pzf7tldbCVqwl3NnOnTamEWdh/rL41fsoYCn2HdHgRA= 7 | github.com/jochenvg/go-udev v0.0.0-20240801134859-b65ed646224b/go.mod h1:IBDUGq30U56w969YNPomhMbRje1GrhUsCh7tHdwgLXA= 8 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 9 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 10 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 11 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 12 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 13 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 14 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 15 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 17 | github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= 18 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 19 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 20 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 21 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= 22 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 24 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 25 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 26 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 27 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 28 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 29 | -------------------------------------------------------------------------------- /monitor.go: -------------------------------------------------------------------------------- 1 | package usbmon 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/jochenvg/go-udev" 7 | ) 8 | 9 | type Monitor interface { 10 | DeviceChan(context.Context) (<-chan *udev.Device, error) 11 | } 12 | 13 | type UdevMonitor struct { 14 | monitor *udev.Monitor 15 | } 16 | 17 | func (m *UdevMonitor) DeviceChan(ctx context.Context) (<-chan *udev.Device, <-chan error, error) { 18 | return m.monitor.DeviceChan(ctx) 19 | } 20 | 21 | func NewUdevMonitor() *UdevMonitor { 22 | u := udev.Udev{} 23 | m := u.NewMonitorFromNetlink("udev") 24 | _ = m.FilterAddMatchTag("seat") 25 | _ = m.FilterAddMatchSubsystem("usb") 26 | 27 | return &UdevMonitor{monitor: m} 28 | } 29 | -------------------------------------------------------------------------------- /usbmon.go: -------------------------------------------------------------------------------- 1 | package usbmon 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type Device struct { 8 | properties map[string]string 9 | } 10 | 11 | func (d *Device) Serial() string { 12 | return d.properties["ID_SERIAL_SHORT"] 13 | } 14 | 15 | func (d *Device) Properties() map[string]string { 16 | return d.properties 17 | } 18 | 19 | func (d *Device) Vendor() string { 20 | return d.properties["ID_VENDOR"] 21 | } 22 | 23 | func (d *Device) Action() string { 24 | return d.properties["ACTION"] 25 | } 26 | 27 | func (d *Device) Major() string { 28 | return d.properties["MAJOR"] 29 | } 30 | 31 | func (d *Device) Minor() string { 32 | return d.properties["MINOR"] 33 | } 34 | 35 | func (d *Device) Path() string { 36 | return d.properties["DEVPATH"] 37 | } 38 | 39 | func Listen(ctx context.Context) (chan *Device, error) { 40 | return ListenFiltered(ctx) 41 | } 42 | 43 | // ListenFiltered returns the usb storage devices that match all the filters passed 44 | // as arguments. 45 | // 46 | // Filters are additive, meaning every device needs to match all the filter arguments. 47 | // 48 | // Example: 49 | func ListenFiltered(ctx context.Context, filters ...Filter) (chan *Device, error) { 50 | m := NewUdevMonitor() 51 | devchan := make(chan *Device) 52 | ch, ech, err := m.DeviceChan(ctx) 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | var lerr error 58 | go func() { 59 | Loop: 60 | for { 61 | select { 62 | case <-ctx.Done(): 63 | close(devchan) 64 | return 65 | case lerr = <-ech: 66 | break Loop 67 | case d := <-ch: 68 | dev := &Device{ 69 | properties: d.Properties(), 70 | } 71 | 72 | if filters == nil { 73 | devchan <- dev 74 | continue 75 | } 76 | 77 | match := true 78 | for _, f := range filters { 79 | if !f.Matches(dev) { 80 | match = false 81 | break 82 | } 83 | } 84 | 85 | if match { 86 | devchan <- dev 87 | } 88 | } 89 | } 90 | }() 91 | 92 | return devchan, lerr 93 | } 94 | --------------------------------------------------------------------------------