diff --git a/README.md b/README.md index 42ff255..58dfab2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ ![Simple CI](https://github.com/NamelessOne91/bisturi/actions/workflows/simple_ci.yml/badge.svg) # bisturi -A toy project network packet analyzer +A TUI network packet analyzer toy project The compiled binary executable for bisturi will attempt to create a new raw socket and bind it to a network interface using syscalls. This will fail unless the program is run with root privileges, which is not advisable. @@ -12,16 +12,6 @@ This is the default behaviour of the included Makefile's **build** command. ## Usage -You can build the binary executable with the `make build` command. - -The following flags are available to customize bisturi's behaviour: - -| Flag | Type | Default | Meaning -| :---:|:--:|:--:|:--| -| i | string | eth0 | network interface for which the packets will be analyzed | -| p | string | all | protocl filter - 'all' equals to no filter | - -Running bisturi with the provided `make run` command is functionally equivalent to running bisturi with the following flags, which are its defaults: - -`bisturi -i eth0 -p all` +You can build the binary executable with the `make build` command or build & run it with `make run`. +A [Bubbletea](https://github.com/charmbracelet/bubbletea) based TUI will ask you to select a network interface and a protocol to filter for - selecting 'all' equals to having no filter. diff --git a/cmd/bisturi/main.go b/cmd/bisturi/main.go index 4998b14..9d1e067 100644 --- a/cmd/bisturi/main.go +++ b/cmd/bisturi/main.go @@ -1,39 +1,15 @@ package main import ( - "flag" "log" - "net" - "github.com/NamelessOne91/bisturi/sockets" + models "github.com/NamelessOne91/bisturi/tui/models" + tea "github.com/charmbracelet/bubbletea" ) -var iface = flag.String("i", "eth0", "The network interface to listen to") -var protocol = flag.String("p", "all", "Consider only packets for this protocol") - func main() { - flag.Parse() - - // retrieve the network interface - networkInterface, err := net.InterfaceByName(*iface) - if err != nil { - log.Fatalf("Failed to get interface by name: %v", err) - } - - // SYS_SOCKET syscall - rs, err := sockets.NewRawSocket(*protocol) - if err != nil { - log.Fatalf("Failed to open raw socket: %v", err) + p := tea.NewProgram(models.NewBisturiModel(), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + log.Fatal("Error running program:", err) } - defer rs.Close() - - // bind the socket to the network interface - rs.Bind(*networkInterface) - if err != nil { - log.Fatalf("Failed to bind socket: %v", err) - } - log.Printf("listening for %s packets on interface: %s\n", *protocol, networkInterface.Name) - - // SYS_RECVFROM syscall - rs.ReadPackets() } diff --git a/go.mod b/go.mod index b9a3ded..1038c6b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,34 @@ module github.com/NamelessOne91/bisturi go 1.21.10 + +require ( + github.com/charmbracelet/bubbles v0.18.0 + github.com/charmbracelet/bubbletea v0.26.6 + github.com/charmbracelet/lipgloss v0.11.0 +) + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.1.2 // indirect + github.com/charmbracelet/x/input v0.1.2 // indirect + github.com/charmbracelet/x/term v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.1.2 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/evertras/bubble-table v0.16.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fc1abbf --- /dev/null +++ b/go.sum @@ -0,0 +1,61 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= +github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= +github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s= +github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk= +github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g= +github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8= +github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY= +github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/input v0.1.2 h1:QJAZr33eOhDowkkEQ24rsJy4Llxlm+fRDf/cQrmqJa0= +github.com/charmbracelet/x/input v0.1.2/go.mod h1:LGBim0maUY4Pitjn/4fHnuXb4KirU3DODsyuHuXdOyA= +github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= +github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw= +github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg= +github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/evertras/bubble-table v0.16.1 h1:RKkOD+6LUoA3SifWceTSE7zchKyhBZy0f4B/K1/XN0o= +github.com/evertras/bubble-table v0.16.1/go.mod h1:SPOZKbIpyYWPHBNki3fyNpiPBQkvkULAtOT7NTD5fKY= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= diff --git a/protocols/eth.go b/protocols/eth.go index f48766d..0e6fc05 100644 --- a/protocols/eth.go +++ b/protocols/eth.go @@ -43,5 +43,5 @@ func EthFrameFromBytes(raw []byte) (*EthernetFrame, error) { func (f *EthernetFrame) Info() string { etv := etherTypesValues[f.etherType] - return fmt.Sprintf("%s Ethernet Frame from MAC %s to MAC %s", f.sourceMAC, f.destinationMAC, etv) + return fmt.Sprintf("%s Ethernet Frame from MAC %s to MAC %s", etv, f.sourceMAC, f.destinationMAC) } diff --git a/sockets/raw_socket.go b/sockets/raw_socket.go index 68e20f1..a70d57c 100644 --- a/sockets/raw_socket.go +++ b/sockets/raw_socket.go @@ -1,7 +1,6 @@ package sockets import ( - "errors" "log" "net" "os" @@ -13,20 +12,6 @@ import ( const mask = 0xff00 -// maps the protocol names to Ethernet protocol types values -var protocolEthernetType = map[string]uint16{ - "all": syscall.ETH_P_ALL, - "arp": syscall.ETH_P_ARP, - "ip": syscall.ETH_P_IP, - "ipv6": syscall.ETH_P_IPV6, - "udp": syscall.ETH_P_IP, // UDP and TCP are part of IP, need special handling if filtered specifically - "udp6": syscall.ETH_P_IPV6, - "tcp": syscall.ETH_P_IP, - "tcp6": syscall.ETH_P_IPV6, -} - -var errUnsupportedProtocol = errors.New("unsupported protocol - must be one of: all, arp, ip, ipv6, udp, udp6, tcp, tcp6") - // hostToNetworkShort converts a short (uint16) from host (usually Little Endian) // to network (Big Endian) byte order func hostToNetworkShort(i uint16) uint16 { @@ -45,11 +30,7 @@ type RawSocket struct { // NewRawSocket opens a raw socket for the specified protocol by calling SYS_SOCKET // and returns the struct representing it, or eventual errors -func NewRawSocket(protocol string) (*RawSocket, error) { - ethType, ok := protocolEthernetType[protocol] - if !ok { - return nil, errUnsupportedProtocol - } +func NewRawSocket(protocol string, ethType uint16) (*RawSocket, error) { rawSocket := &RawSocket{ shutdownChan: make(chan os.Signal, 1), diff --git a/tui/models/bisturi_model.go b/tui/models/bisturi_model.go new file mode 100644 index 0000000..85e9b70 --- /dev/null +++ b/tui/models/bisturi_model.go @@ -0,0 +1,156 @@ +package tui + +import ( + "fmt" + "net" + "strings" + + "github.com/NamelessOne91/bisturi/sockets" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type step uint8 + +const ( + retrieveIfaces step = iota + selectIface + selectProtocol + receivePackets +) + +type errMsg error + +type bisturiModel struct { + step step + spinner spinner.Model + startMenu startMenuModel + packetsTable packetsTablemodel + selectedInterface net.Interface + selectedProtocol string + selectedEthType uint16 + rawSocket *sockets.RawSocket + err error +} + +func NewBisturiModel() *bisturiModel { + s := spinner.New(spinner.WithSpinner(spinner.Meter)) + s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("#00cc99")) + + return &bisturiModel{ + step: retrieveIfaces, + spinner: s, + } +} + +func (m bisturiModel) Init() tea.Cmd { + return tea.Batch(m.spinner.Tick, fetchInterfaces()) +} + +func (m bisturiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m.step { + case retrieveIfaces: + return m.updateLoading(msg) + case selectIface, selectProtocol: + return m.updateStartMenuSelection(msg) + case receivePackets: + return m.updateReceivingPacket(msg) + default: + return m, nil + } +} + +func (m *bisturiModel) updateLoading(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case errMsg: + m.err = msg + return m, tea.Quit + + case networkInterfacesMsg: + m.startMenu = newStartMenuModel(msg) + m.step = selectIface + + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + return m, tea.Quit + } + } + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd +} + +func (m *bisturiModel) updateStartMenuSelection(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + m.startMenu, cmd = m.startMenu.Update(msg) + + switch msg := msg.(type) { + case selectedIfaceItemMsg: + iface, err := net.InterfaceByName(msg.name) + if err != nil { + m.err = err + return m, tea.Quit + } + m.selectedInterface = *iface + m.step = selectProtocol + + return m, nil + + case selectedProtocolItemMsg: + // SYS_SOCKET syscall + rs, err := sockets.NewRawSocket(msg.name, msg.ethType) + if err != nil { + return m, tea.Quit + } + // bind the socket to the network interface + err = rs.Bind(m.selectedInterface) + if err != nil { + m.err = err + return m, tea.Quit + } + m.selectedProtocol = msg.name + m.selectedEthType = msg.ethType + m.rawSocket = rs + m.step = receivePackets + m.packetsTable = newPacketsTable() + + return m, nil + } + return m, cmd +} + +func (m *bisturiModel) updateReceivingPacket(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + m.packetsTable, cmd = m.packetsTable.Update(msg) + + return m, cmd +} + +func (m bisturiModel) View() string { + if m.err != nil { + if m.rawSocket != nil { + m.rawSocket.Close() + } + return fmt.Sprintf("Error: %s\n", m.err) + } + + sb := strings.Builder{} + switch m.step { + case retrieveIfaces: + sb.WriteString(fmt.Sprintf("\n\n %s Retrieving network interfaces...\n\n", m.spinner.View())) + case selectIface, selectProtocol: + sb.WriteString(m.startMenu.View()) + case receivePackets: + sb.WriteString(fmt.Sprintf("Receiving %s packets on %s ...\n", m.selectedProtocol, m.selectedInterface.Name)) + sb.WriteString(m.packetsTable.View()) + default: + sb.WriteString("The program is in an unkowqn state\n") + } + return sb.String() +} diff --git a/tui/models/interfaces_list.go b/tui/models/interfaces_list.go new file mode 100644 index 0000000..d7e000a --- /dev/null +++ b/tui/models/interfaces_list.go @@ -0,0 +1,90 @@ +package tui + +import ( + "net" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// ifaceItem represents a network interface in a list +type ifaceItem struct { + name string + flags string +} + +func (i ifaceItem) Title() string { return i.name } + +func (i ifaceItem) Description() string { return i.flags } + +func (i ifaceItem) FilterValue() string { return i.name } + +type selectedIfaceItemMsg ifaceItem + +type networkInterfacesMsg []net.Interface + +type interfacesListModel struct { + l list.Model +} + +func (m interfacesListModel) Init() tea.Cmd { + return nil +} + +func (m interfacesListModel) Update(msg tea.Msg) (interfacesListModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + i, ok := m.l.SelectedItem().(ifaceItem) + if ok { + return m, func() tea.Msg { + return selectedIfaceItemMsg(i) + } + } + case "q", "ctrl+c": + return m, tea.Quit + } + } + var cmd tea.Cmd + m.l, cmd = m.l.Update(msg) + return m, cmd +} + +func (m interfacesListModel) View() string { + return m.l.View() +} + +func newInterfacesListModel(width, height int, interfaces []net.Interface) interfacesListModel { + items := make([]list.Item, len(interfaces)) + for i, iface := range interfaces { + items[i] = ifaceItem{ + name: iface.Name, + flags: iface.Flags.String(), + } + } + + titlesStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#00cc99")).Blink(true).Bold(true) + ifaceDelegate := list.NewDefaultDelegate() + ifaceDelegate.Styles.SelectedTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00cc99")) + + ifaceList := list.New(items, ifaceDelegate, width, height) + ifaceList.Title = "Select a Network Interface" + ifaceList.Styles.Title = titlesStyle + ifaceList.SetShowStatusBar(true) + ifaceList.SetFilteringEnabled(false) + ifaceList.SetShowHelp(true) + + return interfacesListModel{l: ifaceList} +} + +func fetchInterfaces() tea.Cmd { + return func() tea.Msg { + ifaces, err := net.Interfaces() + if err != nil { + return errMsg(err) + } + return networkInterfacesMsg(ifaces) + } +} diff --git a/tui/models/packets_table.go b/tui/models/packets_table.go new file mode 100644 index 0000000..effc8a5 --- /dev/null +++ b/tui/models/packets_table.go @@ -0,0 +1,73 @@ +package tui + +import ( + "strings" + + "github.com/NamelessOne91/bisturi/protocols" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/evertras/bubble-table/table" +) + +const ( + columnKeyProtocol = "protocol" + columnKeyInterface = "interface" + columnKeyInfo = "info" +) + +type packetsTablemodel struct { + table table.Model + cachedRows []table.Row + packetsChan <-chan protocols.IPPacket +} + +func newPacketsTable() packetsTablemodel { + rows := make([]table.Row, 0, 20) + + return packetsTablemodel{ + cachedRows: rows, + table: table.New([]table.Column{ + table.NewColumn(columnKeyInterface, "Interface", 20), + table.NewColumn(columnKeyProtocol, "Protocol", 20), + table.NewColumn(columnKeyProtocol, "Info", 50), + }). + WithRows(rows). + WithBaseStyle(lipgloss.NewStyle(). + BorderForeground(lipgloss.Color("#00cc99")). + Foreground(lipgloss.Color("#00cc99")). + Align(lipgloss.Center), + ), + } +} + +func (m packetsTablemodel) Init() tea.Cmd { + return nil +} + +func (m packetsTablemodel) Update(msg tea.Msg) (packetsTablemodel, tea.Cmd) { + var ( + cmd tea.Cmd + cmds []tea.Cmd + ) + + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc", "q": + cmds = append(cmds, tea.Quit) + } + } + + return m, tea.Batch(cmds...) +} + +func (m packetsTablemodel) View() string { + sb := strings.Builder{} + + sb.WriteString(m.table.View()) + + return sb.String() +} diff --git a/tui/models/protocols_list.go b/tui/models/protocols_list.go new file mode 100644 index 0000000..20427b0 --- /dev/null +++ b/tui/models/protocols_list.go @@ -0,0 +1,81 @@ +package tui + +import ( + "fmt" + "syscall" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// protoItem represents a network protocol in a list +type protoItem struct { + name string + ethType uint16 +} + +func (p protoItem) Title() string { return p.name } + +func (p protoItem) Description() string { return fmt.Sprintf("Eth type 0x%X", p.ethType) } + +func (p protoItem) FilterValue() string { return p.name } + +type selectedProtocolItemMsg protoItem + +type protocolsListModel struct { + l list.Model +} + +func (m protocolsListModel) Init() tea.Cmd { + return nil +} + +func (m protocolsListModel) Update(msg tea.Msg) (protocolsListModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "enter": + p, ok := m.l.SelectedItem().(protoItem) + if ok { + return m, func() tea.Msg { + return selectedProtocolItemMsg(p) + } + } + case "q", "ctrl+c": + return m, tea.Quit + } + } + var cmd tea.Cmd + m.l, cmd = m.l.Update(msg) + return m, cmd +} + +func (m protocolsListModel) View() string { + return m.l.View() +} + +func newProtocolsListModel(width, height int) protocolsListModel { + protoDelegate := list.NewDefaultDelegate() + protoDelegate.Styles.SelectedTitle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00cc99")) + + items := []list.Item{ + protoItem{name: "all", ethType: syscall.ETH_P_ALL}, + protoItem{name: "arp", ethType: syscall.ETH_P_ARP}, + protoItem{name: "ip", ethType: syscall.ETH_P_IP}, + protoItem{name: "ipv6", ethType: syscall.ETH_P_IPV6}, + // UDP and TCP are part of IP, need special handling if filtered specifically + protoItem{name: "udp", ethType: syscall.ETH_P_IP}, + protoItem{name: "udp6", ethType: syscall.ETH_P_IPV6}, + protoItem{name: "tcp", ethType: syscall.ETH_P_IP}, + protoItem{name: "tcp6", ethType: syscall.ETH_P_IPV6}, + } + protoList := list.New(items, protoDelegate, width, height) + protoList.Title = "Select a Network Protocol" + protoList.Styles.Title = lipgloss.NewStyle().Foreground(lipgloss.Color("#00cc99")).Blink(true).Bold(true) + protoList.SetShowStatusBar(false) + protoList.SetFilteringEnabled(false) + protoList.SetShowHelp(true) + + return protocolsListModel{l: protoList} +} diff --git a/tui/models/start_menu.go b/tui/models/start_menu.go new file mode 100644 index 0000000..b6ff30f --- /dev/null +++ b/tui/models/start_menu.go @@ -0,0 +1,85 @@ +package tui + +import ( + "net" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type startMenuModel struct { + step step + ifaceList interfacesListModel + protoList protocolsListModel +} + +type selectedInterfaceMsg struct { + name string +} + +type selectedProtocolMsg struct { + protocol string + ethTytpe uint16 +} + +func newStartMenuModel(interfaces []net.Interface) startMenuModel { + const listHeight = 50 + const listWidth = 50 + + il := newInterfacesListModel(listWidth, listHeight, interfaces) + plm := newProtocolsListModel(listWidth, listHeight) + + return startMenuModel{ + step: selectIface, + ifaceList: il, + protoList: plm, + } +} + +func (m startMenuModel) Init() tea.Cmd { + return nil +} + +func (m startMenuModel) Update(msg tea.Msg) (startMenuModel, tea.Cmd) { + switch m.step { + case selectIface: + model, cmd := m.ifaceList.Update(msg) + if i, ok := msg.(selectedIfaceItemMsg); ok { + m.step = selectProtocol + return m, func() tea.Msg { + return selectedInterfaceMsg{ + name: i.name, + } + } + } + m.ifaceList = model + return m, cmd + + case selectProtocol: + model, cmd := m.protoList.Update(msg) + if p, ok := msg.(selectedProtocolItemMsg); ok { + return m, func() tea.Msg { + return selectedProtocolMsg{ + protocol: p.name, + ethTytpe: p.ethType, + } + } + } + m.protoList = model + return m, cmd + } + return m, nil +} + +func (m startMenuModel) View() string { + var s string + switch m.step { + case selectIface: + s = m.ifaceList.l.View() + case selectProtocol: + s = m.protoList.l.View() + default: + return "Unkown step" + } + return lipgloss.NewStyle().Padding(1).Render(s) +}