mirror of
https://github.com/vee1e/kubearmor-client.git
synced 2026-09-02 18:58:45 +00:00
535 lines
15 KiB
Go
535 lines
15 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
// Copyright 2022 Authors of KubeArmor
|
|
|
|
// Package profileclient to handle profiling of kubearmor telemetry events
|
|
package profileclient
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/help"
|
|
"github.com/charmbracelet/bubbles/key"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/evertras/bubble-table/table"
|
|
pb "github.com/kubearmor/KubeArmor/protobuf"
|
|
klog "github.com/kubearmor/kubearmor-client/log"
|
|
profile "github.com/kubearmor/kubearmor-client/profile"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
// Column keys
|
|
const (
|
|
ColumnLogSource = "LogSource"
|
|
ColumnNamespace = "Namespace"
|
|
ColumnContainerName = "ContainerName"
|
|
ColumnProcessName = "ProcName"
|
|
ColumnResource = "Resource"
|
|
ColumnResult = "Result"
|
|
ColumnCount = "Count"
|
|
ColumnTimestamp = "Timestamp"
|
|
)
|
|
|
|
var errbuf bytes.Buffer
|
|
|
|
// session state for switching views
|
|
type sessionState uint
|
|
|
|
// Manage Bubble Tea display state
|
|
const (
|
|
processview sessionState = iota
|
|
fileview
|
|
syscallview
|
|
networkview
|
|
)
|
|
|
|
var (
|
|
styleBase = lipgloss.NewStyle().
|
|
BorderForeground(lipgloss.Color("12")).
|
|
Align(lipgloss.Right)
|
|
//ColumnStyle for column color
|
|
ColumnStyle = lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#00af00")).Align(lipgloss.Center).Bold(true)
|
|
|
|
helptheme = lipgloss.AdaptiveColor{
|
|
Light: "#000000",
|
|
Dark: "#ffffff",
|
|
}
|
|
)
|
|
|
|
// Options for filter
|
|
type Options struct {
|
|
Namespace string
|
|
Pod string
|
|
GRPC string
|
|
Container string
|
|
Save bool
|
|
}
|
|
|
|
// Model for main Bubble Tea
|
|
type Model struct {
|
|
File table.Model
|
|
Process table.Model
|
|
Network table.Model
|
|
Syscall table.Model
|
|
tabs tea.Model
|
|
keys keyMap
|
|
quitting bool
|
|
help help.Model
|
|
|
|
height int
|
|
width int
|
|
|
|
state sessionState
|
|
}
|
|
|
|
// SomeData stores incoming row data
|
|
type SomeData struct {
|
|
rows []table.Row
|
|
}
|
|
|
|
func waitForActivity() tea.Cmd {
|
|
return func() tea.Msg {
|
|
time.Sleep(2 * time.Second)
|
|
return klog.EventInfo{}
|
|
}
|
|
}
|
|
|
|
var o1 Options
|
|
|
|
func generateColumns(Operation string) []table.Column {
|
|
LogSource := table.NewFlexColumn(ColumnLogSource, "LogSource", 1).WithStyle(ColumnStyle).WithFiltered(true)
|
|
|
|
CountCol := table.NewFlexColumn(ColumnCount, "Count", 1).WithStyle(ColumnStyle).WithFiltered(true)
|
|
|
|
Namespace := table.NewFlexColumn(ColumnNamespace, "Namespace", 2).WithStyle(ColumnStyle).WithFiltered(true)
|
|
|
|
ContainerName := table.NewFlexColumn(ColumnContainerName, "ContainerName", 4).WithStyle(ColumnStyle).WithFiltered(true)
|
|
|
|
ProcName := table.NewFlexColumn(ColumnProcessName, "ProcessName", 3).WithStyle(ColumnStyle).WithFiltered(true)
|
|
|
|
Resource := table.NewFlexColumn(ColumnResource, Operation, 6).WithStyle(
|
|
lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("202")).
|
|
Align(lipgloss.Center)).WithFiltered(true)
|
|
|
|
Result := table.NewFlexColumn(ColumnResult, "Result", 1).WithStyle(ColumnStyle).WithFiltered(true)
|
|
|
|
Timestamp := table.NewFlexColumn(ColumnTimestamp, "TimeStamp", 3).WithStyle(ColumnStyle)
|
|
|
|
return []table.Column{
|
|
LogSource,
|
|
Namespace,
|
|
ContainerName,
|
|
ProcName,
|
|
Resource,
|
|
Result,
|
|
CountCol,
|
|
Timestamp,
|
|
}
|
|
}
|
|
|
|
// Init calls initial functions if needed
|
|
func (m Model) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
waitForActivity(),
|
|
)
|
|
}
|
|
|
|
// NewModel initializates new bubbletea model
|
|
func NewModel() Model {
|
|
model := Model{
|
|
File: table.New(generateColumns("File")).WithBaseStyle(styleBase).WithPageSize(30).Filtered(true),
|
|
Process: table.New(generateColumns("Process")).WithBaseStyle(styleBase).WithPageSize(30).Filtered(true),
|
|
Network: table.New(generateColumns("Network")).WithBaseStyle(styleBase).WithPageSize(30).Filtered(true),
|
|
Syscall: table.New(generateColumns("Syscall")).WithBaseStyle(styleBase).WithPageSize(30).Filtered(true),
|
|
tabs: &tabs{
|
|
active: "Lip Gloss",
|
|
items: []string{"Process", "File", "Network", "Syscall"},
|
|
},
|
|
keys: keys,
|
|
help: help.New(),
|
|
state: processview,
|
|
}
|
|
|
|
return model
|
|
}
|
|
|
|
// Update Bubble Tea function to Update with incoming events
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var (
|
|
cmd tea.Cmd
|
|
cmds []tea.Cmd
|
|
)
|
|
m.tabs, _ = m.tabs.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.height = msg.Height
|
|
m.width = msg.Width
|
|
m.help.Width = msg.Width
|
|
m.recalculateTable()
|
|
case tea.KeyMsg:
|
|
switch {
|
|
case key.Matches(msg, m.keys.Quit):
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
|
|
}
|
|
|
|
switch msg.String() {
|
|
|
|
case "tab":
|
|
switch m.state {
|
|
case processview:
|
|
m.state = fileview
|
|
case fileview:
|
|
m.state = networkview
|
|
case networkview:
|
|
m.state = syscallview
|
|
case syscallview:
|
|
m.state = processview
|
|
}
|
|
|
|
case "u":
|
|
m.File = m.File.WithPageSize(m.File.PageSize() - 1)
|
|
m.Network = m.Network.WithPageSize(m.Network.PageSize() - 1)
|
|
m.Process = m.Process.WithPageSize(m.Process.PageSize() - 1)
|
|
m.Syscall = m.Syscall.WithPageSize(m.Syscall.PageSize() - 1)
|
|
|
|
case "i":
|
|
m.File = m.File.WithPageSize(m.File.PageSize() + 1)
|
|
m.Network = m.Network.WithPageSize(m.Network.PageSize() + 1)
|
|
m.Process = m.Process.WithPageSize(m.Process.PageSize() + 1)
|
|
m.Syscall = m.Syscall.WithPageSize(m.Syscall.PageSize() + 1)
|
|
|
|
}
|
|
|
|
switch m.state {
|
|
case processview:
|
|
m.Process = m.Process.Focused(true)
|
|
m.Process, cmd = m.Process.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
case fileview:
|
|
m.File = m.File.Focused(true)
|
|
m.File, cmd = m.File.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
case networkview:
|
|
m.Network = m.Network.Focused(true)
|
|
m.Network, cmd = m.Network.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
|
|
case syscallview:
|
|
m.Syscall = m.Syscall.Focused(true)
|
|
m.Syscall, cmd = m.Syscall.Update(msg)
|
|
cmds = append(cmds, cmd)
|
|
}
|
|
case klog.EventInfo:
|
|
profile.TelMutex.RLock()
|
|
m.File = m.File.WithRows(generateRowsFromData(profile.Telemetry, "File")).WithColumns(generateColumns("File"))
|
|
m.File = m.File.SortByAsc(ColumnNamespace).ThenSortByAsc(ColumnContainerName).ThenSortByAsc(ColumnProcessName).ThenSortByAsc(ColumnCount).ThenSortByAsc(ColumnResource)
|
|
m.Process = m.Process.WithRows(generateRowsFromData(profile.Telemetry, "Process")).WithColumns(generateColumns("Process"))
|
|
m.Process = m.Process.SortByAsc(ColumnNamespace).ThenSortByAsc(ColumnContainerName).ThenSortByAsc(ColumnProcessName).ThenSortByAsc(ColumnCount).ThenSortByAsc(ColumnResource)
|
|
m.Network = m.Network.WithRows(generateRowsFromData(profile.Telemetry, "Network")).WithColumns(generateColumns("Network"))
|
|
m.Network = m.Network.SortByAsc(ColumnNamespace).ThenSortByAsc(ColumnContainerName).ThenSortByAsc(ColumnProcessName).ThenSortByAsc(ColumnCount).ThenSortByAsc(ColumnResource)
|
|
m.Syscall = m.Syscall.WithRows(generateRowsFromData(profile.Telemetry, "Syscall")).WithColumns(generateColumns("Syscall"))
|
|
m.Syscall = m.Syscall.SortByAsc(ColumnNamespace).ThenSortByAsc(ColumnContainerName).ThenSortByAsc(ColumnProcessName).ThenSortByAsc(ColumnCount).ThenSortByAsc(ColumnResource)
|
|
profile.TelMutex.RUnlock()
|
|
|
|
return m, waitForActivity()
|
|
|
|
}
|
|
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m *Model) recalculateTable() {
|
|
m.File = m.File.WithTargetWidth(m.width)
|
|
m.Network = m.Network.WithTargetWidth(m.width)
|
|
m.Process = m.Process.WithTargetWidth(m.width)
|
|
m.Syscall = m.Syscall.WithTargetWidth(m.width)
|
|
}
|
|
|
|
// View Renders Bubble Tea UI
|
|
func (m Model) View() string {
|
|
pad := lipgloss.NewStyle().PaddingRight(1)
|
|
RowCount := lipgloss.JoinHorizontal(lipgloss.Left, lipgloss.NewStyle().Foreground(helptheme).Render(fmt.Sprintf("Max Rows: %d", m.Process.PageSize())))
|
|
helpKey := m.help.Styles.FullDesc.Foreground(helptheme).Padding(0, 0, 1)
|
|
help := lipgloss.JoinHorizontal(lipgloss.Left, helpKey.Render(m.help.FullHelpView(m.keys.FullHelp())))
|
|
var total string
|
|
s := lipgloss.NewStyle().Height(m.height).MaxHeight(m.height)
|
|
switch m.state {
|
|
|
|
case processview:
|
|
|
|
total = s.Render(lipgloss.JoinVertical(lipgloss.Top, lipgloss.JoinVertical(lipgloss.Top,
|
|
help,
|
|
RowCount,
|
|
m.tabs.View(),
|
|
lipgloss.JoinVertical(lipgloss.Center, pad.Render(m.Process.View()))),
|
|
))
|
|
case fileview:
|
|
// s := lipgloss.NewStyle().MaxHeight(m.height).MaxWidth(m.width)
|
|
total = s.Render(lipgloss.JoinVertical(lipgloss.Top, lipgloss.JoinVertical(lipgloss.Top,
|
|
help,
|
|
RowCount,
|
|
m.tabs.View(),
|
|
lipgloss.JoinVertical(lipgloss.Center, pad.Render(m.File.View()))),
|
|
))
|
|
case networkview:
|
|
// s := lipgloss.NewStyle().MaxHeight(m.height).MaxWidth(m.width)
|
|
total = s.Render(lipgloss.JoinVertical(lipgloss.Top, lipgloss.JoinVertical(lipgloss.Top,
|
|
help,
|
|
RowCount,
|
|
m.tabs.View(),
|
|
lipgloss.JoinVertical(lipgloss.Center, pad.Render(m.Network.View()))),
|
|
))
|
|
case syscallview:
|
|
// s := lipgloss.NewStyle().MaxHeight(m.height).MaxWidth(m.width)
|
|
total = s.Render(lipgloss.JoinVertical(lipgloss.Top, lipgloss.JoinVertical(lipgloss.Top,
|
|
help,
|
|
RowCount,
|
|
m.tabs.View(),
|
|
lipgloss.JoinVertical(lipgloss.Center, pad.Render(m.Syscall.View()))),
|
|
))
|
|
}
|
|
return total
|
|
|
|
}
|
|
|
|
// Profile Row Data to display
|
|
type Profile struct {
|
|
LogSource string `json:"log-source"`
|
|
Namespace string `json:"namespace"`
|
|
ContainerName string `json:"container-name"`
|
|
Process string `json:"process"`
|
|
Resource string `json:"resource"`
|
|
Result string `json:"result"`
|
|
Data string `json:"data"`
|
|
Count int `json:"count"`
|
|
Time string `json:"time"`
|
|
}
|
|
|
|
// Frequency and Timestamp data for another map
|
|
type Frequency struct {
|
|
freq int
|
|
time string
|
|
}
|
|
|
|
func isLaterTimestamp(timestamp1, timestamp2 string) bool {
|
|
t1, err := time.Parse(time.RFC3339, timestamp1)
|
|
if err != nil {
|
|
// Handle error, use some default value, or return false if you prefer
|
|
return false
|
|
}
|
|
|
|
t2, err := time.Parse(time.RFC3339, timestamp2)
|
|
if err != nil {
|
|
// Handle error, use some default value, or return false if you prefer
|
|
return false
|
|
}
|
|
|
|
return t1.After(t2)
|
|
}
|
|
|
|
// AggregateSummary used to aggregate summary data for a less cluttered view of file and process data
|
|
func AggregateSummary(inputMap map[Profile]*Frequency, Operation string) map[Profile]*Frequency {
|
|
outputMap := make(map[Profile]*Frequency)
|
|
var fileArr []string
|
|
fileSumMap := make(map[Profile]*Frequency)
|
|
updatedSumMap := make(map[Profile]*Frequency)
|
|
if Operation == "Network" || Operation == "Syscall" {
|
|
return inputMap
|
|
}
|
|
for prof, count := range inputMap {
|
|
if Operation == "File" || Operation == "Process" {
|
|
fileArr = append(fileArr, prof.Resource)
|
|
fileSumMap[prof] = count
|
|
} else {
|
|
updatedSumMap[prof] = count
|
|
}
|
|
}
|
|
inputMap = updatedSumMap
|
|
aggregatedPaths := profile.AggregatePaths(fileArr)
|
|
for summary, countTime := range fileSumMap {
|
|
for _, path := range aggregatedPaths {
|
|
if strings.HasPrefix(summary.Resource, path.Path) && (len(summary.Resource) == len(path.Path) || summary.Resource[len(strings.TrimSuffix(path.Path, "/"))] == '/') {
|
|
summary.Resource = path.Path
|
|
break
|
|
}
|
|
}
|
|
if existingFreq, ok := outputMap[summary]; ok {
|
|
// If the prof already exists, update the frequency and timestamp if needed
|
|
existingFreq.freq += countTime.freq
|
|
|
|
if isLaterTimestamp(countTime.time, existingFreq.time) {
|
|
existingFreq.time = countTime.time
|
|
}
|
|
outputMap[summary] = existingFreq
|
|
} else {
|
|
outputMap[summary] = countTime
|
|
}
|
|
}
|
|
|
|
return outputMap
|
|
}
|
|
|
|
func convertToJSON(Operation string, data []Profile) {
|
|
var jsonArray []string
|
|
jsonByte, _ := json.MarshalIndent(data, " ", " ")
|
|
//unmarshalling here because it is marshalled two times for some reason
|
|
if err := json.Unmarshal(jsonByte, &jsonArray); err != nil {
|
|
fmt.Println("Error parsing JSON array:", err)
|
|
}
|
|
if len(jsonArray) > 0 {
|
|
filepath := "Profile_Summary/"
|
|
err := os.MkdirAll(filepath, 0600)
|
|
err = os.WriteFile(filepath+Operation+".json", []byte(jsonArray[0]), 0600)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p Profile) MarshalText() (text []byte, err error) {
|
|
type x Profile
|
|
return json.Marshal(x(p))
|
|
}
|
|
|
|
func generateRowsFromData(data []pb.Log, operation string) []table.Row {
|
|
var s SomeData
|
|
var jsondata []Profile
|
|
m := make(map[Profile]int)
|
|
w := make(map[Profile]*Frequency)
|
|
for _, entry := range data {
|
|
|
|
if entry.Operation != operation {
|
|
continue
|
|
}
|
|
|
|
if (o1.Namespace != "") && (entry.NamespaceName != o1.Namespace) {
|
|
continue
|
|
}
|
|
if (o1.Pod != "") && (entry.PodName != o1.Pod) {
|
|
continue
|
|
}
|
|
if (o1.Container != "") && (entry.ContainerName != o1.Container) {
|
|
continue
|
|
}
|
|
|
|
var p Profile
|
|
var logType string
|
|
if entry.Type == "HostLog" {
|
|
logType = "Host"
|
|
entry.NamespaceName = "--"
|
|
entry.ContainerName = "--"
|
|
} else {
|
|
logType = "Container"
|
|
}
|
|
|
|
if entry.Operation == "Syscall" {
|
|
p = Profile{
|
|
LogSource: logType,
|
|
Namespace: entry.NamespaceName,
|
|
ContainerName: entry.ContainerName,
|
|
Process: entry.ProcessName,
|
|
Resource: entry.Data,
|
|
Result: entry.Result,
|
|
}
|
|
} else {
|
|
p = Profile{
|
|
LogSource: logType,
|
|
Namespace: entry.NamespaceName,
|
|
ContainerName: entry.ContainerName,
|
|
Process: entry.ProcessName,
|
|
Resource: entry.Resource,
|
|
Result: entry.Result,
|
|
}
|
|
}
|
|
|
|
f := &Frequency{
|
|
time: entry.UpdatedTime,
|
|
}
|
|
w[p] = f
|
|
m[p]++
|
|
w[p].freq = m[p]
|
|
|
|
}
|
|
|
|
finalmap := AggregateSummary(w, operation)
|
|
for r, frequency := range finalmap {
|
|
row := table.NewRow(table.RowData{
|
|
ColumnLogSource: r.LogSource,
|
|
ColumnNamespace: r.Namespace,
|
|
ColumnContainerName: r.ContainerName,
|
|
ColumnProcessName: r.Process,
|
|
ColumnResource: r.Resource,
|
|
ColumnResult: r.Result,
|
|
ColumnCount: frequency.freq,
|
|
ColumnTimestamp: frequency.time,
|
|
})
|
|
jsondata = append(jsondata, Profile{
|
|
LogSource: r.LogSource,
|
|
Namespace: r.Namespace,
|
|
ContainerName: r.ContainerName,
|
|
Process: r.Process,
|
|
Resource: r.Resource,
|
|
Result: r.Result,
|
|
Count: frequency.freq,
|
|
Time: frequency.time,
|
|
})
|
|
s.rows = append(s.rows, row)
|
|
}
|
|
|
|
if o1.Save {
|
|
if operation == "File" {
|
|
convertToJSON("File", jsondata)
|
|
} else if operation == "Process" {
|
|
convertToJSON("Process", jsondata)
|
|
} else if operation == "Network" {
|
|
convertToJSON("Network", jsondata)
|
|
} else if operation == "Syscall" {
|
|
convertToJSON("Syscall", jsondata)
|
|
}
|
|
}
|
|
|
|
return s.rows
|
|
}
|
|
|
|
// Start entire TUI
|
|
func Start(o Options) {
|
|
o1 = Options{
|
|
Namespace: o.Namespace,
|
|
Pod: o.Pod,
|
|
GRPC: o.GRPC,
|
|
Container: o.Container,
|
|
Save: o.Save,
|
|
}
|
|
p := tea.NewProgram(NewModel(), tea.WithAltScreen())
|
|
go func() {
|
|
err := profile.GetLogs(o1.GRPC)
|
|
if err != nil {
|
|
p.Quit()
|
|
profile.ErrChan <- err
|
|
}
|
|
}()
|
|
|
|
os.Stderr = nil
|
|
if _, err := p.Run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
select {
|
|
case err := <-profile.ErrChan:
|
|
log.Errorf("failed to start observer. Error=%s", err.Error())
|
|
default:
|
|
break
|
|
}
|
|
|
|
}
|