commit 09b054cc5c10c9249973b44ba8c6ac260095c1d8 Author: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Thu Apr 30 17:33:42 2026 +0200 Init Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7b8ae86 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,10 @@ +# Contributing + +Everybody is invited and welcome to contribute. There is a lot to do... Check the issues! + +The process is straight-forward. + +- Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0 and 1) +- Fork this git repository +- Write your changes (bug fixes, new features, ...). +- Create a Pull Request against the main branch. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ce7478 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hadi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..36981cd --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +``` +▖▖▄▖▄ ▄▖ ▌ ▄▖▖▖▄▖ +▌▌▚ ▙▘▌ ▌▌▀▌▛▘▛▌ ▐ ▌▌▐ +▙▌▄▌▙▘▙▌▙▌█▌▌ ▙▌ ▐ ▙▌▟▖ + +``` + +# USBGuard TUI + +A terminal UI for managing USB devices via [usbguard](https://usbguard.github.io/). +Built with [bubbletea](https://github.com/charmbracelet/bubbletea) & Goland! + +## Requirements + +- usbguard installed and the daemon running +- Sufficient privileges to communicate with the usbguard daemon socket + +## Installation + +
+Go install + +```sh +go install github.com/anotherhadi/usbguard-tui@latest +``` + +
+ +
+Build from source + +```sh +git clone https://github.com/anotherhadi/usbguard-tui +cd usbguard-tui +go build -o usbguard-tui . +``` + +
+ +
+Nix run + +```sh +nix run github:anotherhadi/usbguard-tui +``` + +
+ +
+NixOS: system installation + +Add the flake input and include the package in your configuration: + +```nix +# flake.nix +inputs.usbguard-tui.url = "github:anotherhadi/usbguard-tui"; + +# configuration.nix / home.nix +environment.systemPackages = [ inputs.usbguard-tui.packages.${system}.default ]; +``` + +
+ +## Usage + +``` +usbguard-tui +``` + +The device list refreshes automatically every 2 seconds. + +--- + +
+ github | + gitlab (mirror) | + gitea (mirror) +
"+a.label)) + } else { + fmt.Fprintf(w, " %s", a.label) + } +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go new file mode 100644 index 0000000..332e72b --- /dev/null +++ b/internal/ui/keys.go @@ -0,0 +1,45 @@ +package ui + +import "github.com/charmbracelet/bubbles/key" + +type listKeyMap struct { + Open key.Binding + Filter key.Binding + Refresh key.Binding + Quit key.Binding + Help key.Binding + // shown only in full help + Allow key.Binding + AllowPerm key.Binding + Block key.Binding + BlockPerm key.Binding + Reject key.Binding + RejectPerm key.Binding +} + +func (k listKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Open, k.Filter, k.Refresh, k.Quit, k.Help} +} + +func (k listKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Open, k.Filter, k.Refresh, k.Quit}, + {k.Allow, k.AllowPerm, k.Block, k.BlockPerm, k.Reject, k.RejectPerm}, + } +} + +var listKeys = listKeyMap{ + Open: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select action")), + Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "more")), + Allow: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "allow")), + AllowPerm: key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "allow (perm)")), + Block: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "block")), + BlockPerm: key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "block (perm)")), + Reject: key.NewBinding(key.WithKeys("j"), key.WithHelp("j", "reject")), + RejectPerm: key.NewBinding(key.WithKeys("J"), key.WithHelp("J", "reject (perm)")), +} + +var cancelKey = key.NewBinding(key.WithKeys("esc", "q", "ctrl+c"), key.WithHelp("esc/q", "cancel")) diff --git a/internal/ui/model.go b/internal/ui/model.go new file mode 100644 index 0000000..c081873 --- /dev/null +++ b/internal/ui/model.go @@ -0,0 +1,319 @@ +package ui + +import ( + "strings" + "time" + + "github.com/anotherhadi/usbguard-tui/internal/guard" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type state int + +const ( + stateList state = iota + statePopup +) + +type ( + tickMsg time.Time + devicesMsg []guard.Device + daemonStatusMsg string + actionMsg struct{ err error } +) + +type Model struct { + state state + list list.Model + actionList list.Model + help help.Model + daemonStatus string + width int + height int + notice string + selectedDev *guard.Device +} + +func New() Model { + l := list.New(nil, deviceDelegate{}, 0, 0) + l.SetShowHelp(false) + l.SetFilteringEnabled(true) + l.SetShowStatusBar(true) + l.SetShowTitle(false) + l.DisableQuitKeybindings() + // free j/k for our shortcuts + l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")) + l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")) + l.FilterInput.PromptStyle = lipgloss.NewStyle().Foreground(colorAccent) + l.FilterInput.Cursor.Style = lipgloss.NewStyle().Foreground(colorAccent) + + return Model{ + state: stateList, + list: l, + actionList: makeActionList(), + help: help.New(), + } +} + +func makeActionList() list.Model { + items := []list.Item{ + actionItem{"allow", guard.AllowDevice, false, guard.Allowed}, + actionItem{"allow (permanent)", guard.AllowDevice, true, guard.Allowed}, + actionItem{"block", guard.BlockDevice, false, guard.Blocked}, + actionItem{"block (permanent)", guard.BlockDevice, true, guard.Blocked}, + actionItem{"reject", guard.RejectDevice, false, guard.Rejected}, + actionItem{"reject (permanent)", guard.RejectDevice, true, guard.Rejected}, + } + l := list.New(items, actionDelegate{}, 24, 6) + l.SetShowHelp(false) + l.SetShowTitle(false) + l.SetShowStatusBar(false) + l.DisableQuitKeybindings() + l.SetFilteringEnabled(false) + return l +} + +func (m Model) Init() tea.Cmd { + return tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd()) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.help.Width = msg.Width + m.list.SetSize(msg.Width, m.listHeight()) + m.updateActionListSize() + return m, nil + + case tickMsg: + return m, tea.Batch(fetchDevices, fetchDaemonStatus, tickCmd()) + + case devicesMsg: + items := make([]list.Item, len(msg)) + for i, d := range msg { + items[i] = d + } + cmd := m.list.SetItems(items) + return m, cmd + + case daemonStatusMsg: + m.daemonStatus = string(msg) + return m, nil + + case actionMsg: + m.state = stateList + m.selectedDev = nil + if msg.err != nil { + switch msg.err { + case guard.ErrReadOnly: + m.notice = "Read-only rules: applied temporarily. Add the rule to your config for persistence." + case guard.ErrPermission: + m.notice = "Permission denied. Run with appropriate privileges." + default: + m.notice = msg.err.Error() + } + } else { + m.notice = "" + } + return m, fetchDevices + + case tea.KeyMsg: + if m.state == statePopup { + return m.updatePopup(msg) + } + return m.updateList(msg) + } + + if m.state == stateList { + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd + } + + return m, nil +} + +func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + if !m.list.SettingFilter() { + id := m.selectedDevID() + switch { + case key.Matches(msg, listKeys.Quit): + return m, tea.Quit + case key.Matches(msg, listKeys.Refresh): + m.notice = "" + return m, tea.Batch(fetchDevices, fetchDaemonStatus) + case key.Matches(msg, listKeys.Help): + m.help.ShowAll = !m.help.ShowAll + m.list.SetSize(m.width, m.listHeight()) + return m, nil + case key.Matches(msg, listKeys.Open): + if item := m.list.SelectedItem(); item != nil { + d := item.(guard.Device) + m.selectedDev = &d + m.updateActionListSize() + m.actionList.Select(0) + m.state = statePopup + return m, nil + } + case id >= 0 && key.Matches(msg, listKeys.Allow): + return m, doAction(id, guard.AllowDevice, false) + case id >= 0 && key.Matches(msg, listKeys.AllowPerm): + return m, doAction(id, guard.AllowDevice, true) + case id >= 0 && key.Matches(msg, listKeys.Block): + return m, doAction(id, guard.BlockDevice, false) + case id >= 0 && key.Matches(msg, listKeys.BlockPerm): + return m, doAction(id, guard.BlockDevice, true) + case id >= 0 && key.Matches(msg, listKeys.Reject): + return m, doAction(id, guard.RejectDevice, false) + case id >= 0 && key.Matches(msg, listKeys.RejectPerm): + return m, doAction(id, guard.RejectDevice, true) + } + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m Model) updatePopup(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, cancelKey): + m.state = stateList + m.selectedDev = nil + return m, nil + case key.Matches(msg, listKeys.Open): + if item := m.actionList.SelectedItem(); item != nil { + a := item.(actionItem) + return m, doAction(m.selectedDev.ID, a.fn, a.permanent) + } + } + var cmd tea.Cmd + m.actionList, cmd = m.actionList.Update(msg) + return m, cmd +} + +func (m Model) View() string { + header := m.renderHeader() + notice := m.renderNotice() + listView := strings.TrimRight(m.list.View(), "\n") + helpView := strings.TrimRight(m.help.View(listKeys), "\n") + bg := strings.Join([]string{header, listView, notice, helpView}, "\n") + + if m.state == statePopup && m.selectedDev != nil { + return placeOverlay(bg, m.renderActionSelect(), m.width, m.height) + } + return bg +} + +func (m Model) renderHeader() string { + title := headerStyle.Render("USBGuard-tui") + switch m.daemonStatus { + case "active": + return title + mutedStyle.Render(" - ") + daemonActiveStyle.Render("active") + case "": + return title + default: + return title + mutedStyle.Render(" - ") + daemonOtherStyle.Render(m.daemonStatus) + } +} + +func (m Model) renderNotice() string { + if m.notice == "" { + return "" + } + return warnStyle.Render(m.notice) +} + +func (m Model) renderActionSelect() string { + dev := m.selectedDev + color := statusColors[dev.Status] + innerW := m.actionListInnerWidth() + + title := popupTitleStyle.Copy().Foreground(color).Width(innerW).Render(dev.Name) + hint := lipgloss.NewStyle().Foreground(colorMuted).Width(innerW).Render("↑↓ navigate enter confirm esc cancel") + + content := strings.Join([]string{title, m.actionList.View(), "", hint}, "\n") + return popupStyle.Width(innerW).Render(content) +} + +func (m Model) popupOuterWidth() int { + w := m.width - 6 + if w > 60 { + w = 60 + } + if w < 32 { + w = 32 + } + return w +} + +func (m Model) actionListInnerWidth() int { + return m.popupOuterWidth() - 8 // border(2) + padding_h(6) +} + +// updateActionListSize sizes the action list and toggles pagination based on available space. +// When there is enough room for all items: pagination is hidden and height is set exactly, +// avoiding the phantom line that bubbles/list reserves when showPagination=true. +// When space is limited: pagination is shown naturally by bubbles/list. +func (m *Model) updateActionListSize() { + const items = 6 + innerW := m.actionListInnerWidth() + // popup overhead: border(2) + padding_v(2) + title(1) + blank(1) + hint(1) = 7 + available := m.height - 7 - 2 // 2 lines margin + if available >= items { + m.actionList.SetShowPagination(false) + m.actionList.SetSize(innerW, items) + } else { + m.actionList.SetShowPagination(true) + h := available + if h < 2 { + h = 2 + } + m.actionList.SetSize(innerW, h) + } +} + +func (m Model) listHeight() int { + helpH := lipgloss.Height(strings.TrimRight(m.help.View(listKeys), "\n")) + return m.height - 1 - helpH - 1 // header - help - notice +} + +func (m Model) selectedDevID() int { + if item := m.list.SelectedItem(); item != nil { + return item.(guard.Device).ID + } + return -1 +} + +func tickCmd() tea.Cmd { + return tea.Tick(2*time.Second, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func fetchDevices() tea.Msg { + devices, err := guard.ListDevices() + if err != nil { + return actionMsg{err: err} + } + return devicesMsg(devices) +} + +func fetchDaemonStatus() tea.Msg { + return daemonStatusMsg(guard.DaemonStatus()) +} + +func doAction(id int, fn func(int, bool) error, permanent bool) tea.Cmd { + return func() tea.Msg { + return actionMsg{err: fn(id, permanent)} + } +} diff --git a/internal/ui/overlay.go b/internal/ui/overlay.go new file mode 100644 index 0000000..eee1836 --- /dev/null +++ b/internal/ui/overlay.go @@ -0,0 +1,51 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +var dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + +// placeOverlay renders fg centered over bg, with bg stripped and rendered dim gray. +func placeOverlay(bg, fg string, width, height int) string { + fgLines := strings.Split(fg, "\n") + fgH := len(fgLines) + fgW := 0 + for _, l := range fgLines { + if w := lipgloss.Width(l); w > fgW { + fgW = w + } + } + + bgLines := strings.Split(bg, "\n") + + x0 := (width - fgW) / 2 + y0 := (height - fgH) / 2 + + result := make([]string, height) + for i := 0; i < height; i++ { + raw := "" + if i < len(bgLines) { + raw = ansi.Strip(bgLines[i]) + } + if w := lipgloss.Width(raw); w < width { + raw += strings.Repeat(" ", width-w) + } + + fgIdx := i - y0 + if fgIdx < 0 || fgIdx >= fgH { + result[i] = dimStyle.Render(raw) + } else { + fgLine := fgLines[fgIdx] + fgLineW := lipgloss.Width(fgLine) + left := ansi.Truncate(raw, x0, "") + right := ansi.Cut(raw, x0+fgLineW, width) + result[i] = dimStyle.Render(left) + fgLine + dimStyle.Render(right) + } + } + + return strings.Join(result, "\n") +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go new file mode 100644 index 0000000..6781373 --- /dev/null +++ b/internal/ui/styles.go @@ -0,0 +1,52 @@ +package ui + +import ( + "github.com/anotherhadi/usbguard-tui/internal/guard" + "github.com/charmbracelet/lipgloss" +) + +var ( + colorAllowed = lipgloss.Color("28") + colorAllowedSelected = lipgloss.Color("42") + colorBlocked = lipgloss.Color("124") + colorBlockedSelected = lipgloss.Color("196") + colorRejected = lipgloss.Color("130") + colorRejectedSelected = lipgloss.Color("214") + colorMuted = lipgloss.Color("240") + colorAccent = lipgloss.Color("99") +) + +var statusColors = map[guard.Status]lipgloss.Color{ + guard.Allowed: colorAllowed, + guard.Blocked: colorBlocked, + guard.Rejected: colorRejected, +} + +var statusColorsSelected = map[guard.Status]lipgloss.Color{ + guard.Allowed: colorAllowedSelected, + guard.Blocked: colorBlockedSelected, + guard.Rejected: colorRejectedSelected, +} + +var ( + headerStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(colorAccent). + PaddingLeft(1) + + daemonActiveStyle = lipgloss.NewStyle().Foreground(colorAllowedSelected) + daemonOtherStyle = lipgloss.NewStyle().Foreground(colorMuted) + + mutedStyle = lipgloss.NewStyle().Foreground(colorMuted) + + popupStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorAccent). + Padding(1, 3) + + popupTitleStyle = lipgloss.NewStyle().Bold(true).MarginBottom(1) + + keyHintStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + warnStyle = lipgloss.NewStyle().Foreground(colorRejected) + errStyle = lipgloss.NewStyle().Foreground(colorBlocked).Bold(true) +) diff --git a/main.go b/main.go new file mode 100644 index 0000000..83a5942 --- /dev/null +++ b/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/anotherhadi/usbguard-tui/internal/guard" + "github.com/anotherhadi/usbguard-tui/internal/ui" +) + +func main() { + if err := guard.Check(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + p := tea.NewProgram(ui.New(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +}