diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..8caa231 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,14 @@ +name: Security audit +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + +jobs: + security_audit: + runs-on: ubuntu-latest + steps: + - uses: golang/govulncheck-action@v1 + with: + go-version-input: 'stable' + check-latest: true diff --git a/.mikrus.yaml b/.mikrus.yaml new file mode 100644 index 0000000..e69de29 diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..b4477f0 --- /dev/null +++ b/cmd/version.go @@ -0,0 +1,23 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var version = "0.0.0" + +// versionCmd represents the logs command +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Show version", + Long: `Show mikrus client version.`, + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("mikctl version", version) + }, +} + +func init() { + rootCmd.AddCommand(versionCmd) +} diff --git a/stats.go b/stats.go new file mode 100644 index 0000000..5af4b05 --- /dev/null +++ b/stats.go @@ -0,0 +1,240 @@ +package mikrus + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +type Stats struct { + Memory Memory `json:"memory"` + DiskSpace DiskSpace `json:"disk_space"` + Uptime Uptime `json:"uptime"` + Processes []ProcessInfo `json:"processes"` +} + +type Memory struct { + Total int `json:"total"` + Used int `json:"used"` + Free int `json:"free"` + Shared int `json:"shared"` + Cache int `json:"cache"` + Available int `json:"available"` + SwapTotal int `json:"swap_total"` + SwapUsed int `json:"swap_used"` + SwapFree int `json:"swap_free"` +} + +func ParseMemoryUsage(s string) (Memory, error) { + var ( + total, used, free, shared, cache, available int + swapTotal, swapUsed, swapFree int + ) + + table := strings.Split(s, "\n") + if len(table) < 3 { + return Memory{}, errors.New("parsing `free` command output") + } + memory := strings.TrimSpace(table[1]) + swap := strings.TrimSpace(table[2]) + + _, err := fmt.Sscanf(memory, "Mem: %d %d %d %d %d %d", &total, &used, &free, &shared, &cache, &available) + if err != nil { + return Memory{}, fmt.Errorf("incorrect input data for `free` command: %w", err) + } + _, err = fmt.Sscanf(swap, "Swap: %d %d %d", &swapTotal, &swapUsed, &swapFree) + if err != nil { + return Memory{}, fmt.Errorf("incorrect input data for `free` command: %w", err) + } + return Memory{ + Total: total, + Used: used, + Free: free, + Shared: shared, + Cache: cache, + Available: available, + SwapFree: swapFree, + SwapUsed: swapUsed, + SwapTotal: swapTotal, + }, nil +} + +type DiskSpace struct { + Filesystem string `json:"filesystem"` + Size string `json:"size"` + Used string `json:"used"` + Available string `json:"available"` + Usage string `json:"usage"` + MountedOn string `json:"mounted_on"` +} + +type ProcessInfo struct { + User string + PID uint64 + CPUPercent float64 + MemoryPercent float64 + VirtualMemorySize uint64 + ResidentSetSize uint64 + TTY string + State string + Start string + CPUTime string + Command string +} + +// Format: USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND +var psRE = regexp.MustCompile(`^(\w+) +(\d+) +(\d+\.\d+) +(\d+\.\d+) +(\d+) +(\d+) +(.) +(\w+) +([\d:]+) +([\d:]+) +(.+)$`) + +func ParsePS(s string) ([]ProcessInfo, error) { + lines := strings.Split(s, "\n") + if len(lines) < 2 { + return nil, fmt.Errorf("invalid input line: %s", s) + } + list := make([]ProcessInfo, 0, len(lines)-1) + const ( + USER = iota + 1 + PID + CPUPERCENT + MEMPERCENT + VSZ + RSS + TTY + STAT + START + TIME + COMMAND + ) + for _, line := range lines[1 : len(lines)-1] { + matches := psRE.FindStringSubmatch(line) + if len(matches) != 12 { + return nil, fmt.Errorf("parsing %q", line) + } + pid, err := strconv.ParseUint(matches[PID], 10, 64) + if err != nil { + return nil, err + } + cpuPercent, err := strconv.ParseFloat(matches[CPUPERCENT], 64) + if err != nil { + return nil, err + } + memPercent, err := strconv.ParseFloat(matches[MEMPERCENT], 64) + if err != nil { + return nil, err + } + vsz, err := strconv.ParseUint(matches[VSZ], 10, 64) + if err != nil { + return nil, err + } + rss, err := strconv.ParseUint(matches[RSS], 10, 64) + if err != nil { + return nil, err + } + + list = append(list, ProcessInfo{ + User: matches[USER], + PID: pid, + CPUPercent: cpuPercent, + MemoryPercent: memPercent, + VirtualMemorySize: vsz, + ResidentSetSize: rss, + TTY: matches[TTY], + State: matches[STAT], + Start: matches[START], + CPUTime: matches[TIME], + Command: matches[COMMAND], + }) + } + return list, nil +} + +func ParseDiskSpace(s string) (DiskSpace, error) { + var fileSystem, size, used, avail, usage, mountedOn string + + table := strings.Split(s, "\n") + if len(table) < 2 { + return DiskSpace{}, errors.New("parsing `df` command output") + } + values := strings.TrimSpace(table[1]) + + _, err := fmt.Sscanf(values, "%s %s %s %s %s %s", &fileSystem, &size, &used, &avail, &usage, &mountedOn) + if err != nil { + return DiskSpace{}, fmt.Errorf("incorrect input data for `df` command: %w", err) + } + + return DiskSpace{ + Filesystem: fileSystem, + Size: size, + Used: used, + Available: avail, + Usage: usage, + MountedOn: mountedOn, + }, nil +} + +type Uptime struct { + Time string `json:"time"` + Uptime time.Duration `json:"days_up"` + Users int `json:"users_logged_in"` + CPUload1min float64 `json:"load_average_1_min"` + CPUload5min float64 `json:"load_average_5_min"` + CPUload15min float64 `json:"load_average_15_min"` +} + +var uptimeRE = regexp.MustCompile(`up (\d+) days, +(\d+):(\d\d), +(\d+) users, +load average: (\d+\.\d\d), (\d+\.\d\d), (\d+\.\d\d)`) + +func ParseUptime(s string) (Uptime, error) { + matches := uptimeRE.FindStringSubmatch(s) + if len(matches) != 8 { + return Uptime{}, fmt.Errorf("parsing input %q", s) + } + const ( + UPDAYS = iota + 1 + UPHOURS + UPMINUTES + USERS + LOAD1MIN + LOAD5MIN + LOAD15MIN + ) + upDays, err := strconv.Atoi(matches[UPDAYS]) + if err != nil { + return Uptime{}, err + } + upHours, err := strconv.Atoi(matches[UPHOURS]) + if err != nil { + return Uptime{}, err + } + upMinutes, err := strconv.Atoi(matches[UPMINUTES]) + if err != nil { + return Uptime{}, err + } + up := 24 * time.Hour * time.Duration(upDays) + up += time.Duration(upHours) * time.Hour + up += time.Duration(upMinutes) * time.Minute + users, err := strconv.Atoi(matches[USERS]) + if err != nil { + return Uptime{}, err + } + load1min, err := strconv.ParseFloat(matches[LOAD1MIN], 64) + if err != nil { + return Uptime{}, err + } + load5min, err := strconv.ParseFloat(matches[LOAD5MIN], 64) + if err != nil { + return Uptime{}, err + } + load15min, err := strconv.ParseFloat(matches[LOAD15MIN], 64) + if err != nil { + return Uptime{}, err + } + return Uptime{ + Uptime: up, + Users: users, + CPUload1min: load1min, + CPUload5min: load5min, + CPUload15min: load15min, + }, nil +} diff --git a/stats_test.go b/stats_test.go new file mode 100644 index 0000000..faabfcc --- /dev/null +++ b/stats_test.go @@ -0,0 +1,151 @@ +package mikrus_test + +import ( + "slices" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/qba73/mikrus" +) + +func TestRenderMemoryStats(t *testing.T) { + t.Parallel() + ts := newTestServer("/stats", []byte(statResponse), t) + defer ts.Close() + + client := mikrus.New("dummyAPIKey", "dummySrv") + client.HTTPClient = ts.Client() + client.URL = ts.URL +} + +func TestParseMemoryUsage_ParsesCommandOutputOnValidInput(t *testing.T) { + t.Parallel() + freeCmdOutput := "total used free shared buff/cache available\nMem: 1024 43 816 0 164 980\nSwap: 0 0 0" + got, err := mikrus.ParseMemoryUsage(freeCmdOutput) + if err != nil { + t.Fatal(err) + } + + want := mikrus.Memory{ + Total: 1024, + Used: 43, + Free: 816, + Shared: 0, + Cache: 164, + Available: 980, + SwapTotal: 0, + SwapUsed: 0, + SwapFree: 0, + } + + if !cmp.Equal(want, got) { + t.Error(cmp.Diff(want, got)) + } +} + +func TestParseDiskSpace_ParsesCommandOutputOnValidInput(t *testing.T) { + t.Parallel() + dfCmdOutput := "Filesystem Size Used Avail Use% Mounted on\n/dev/mapper/pve-vm--230--disk--0 9.8G 2.7G 6.7G 29% /\nudev 63G 0 63G 0% /dev/net" + got, err := mikrus.ParseDiskSpace(dfCmdOutput) + if err != nil { + t.Fatal(err) + } + want := mikrus.DiskSpace{ + Filesystem: "/dev/mapper/pve-vm--230--disk--0", + Size: "9.8G", + Used: "2.7G", + Available: "6.7G", + Usage: "29%", + MountedOn: "/", + } + if !cmp.Equal(want, got) { + t.Error(cmp.Diff(want, got)) + } +} + +func TestParseUptime_ParsesUptimeCommandOutput(t *testing.T) { + t.Parallel() + uptimeCmdOutput := "16:32:02 up 6 days, 8:33, 0 users, load average: 0.10, 1.00, 0.50" + wantUptime, err := time.ParseDuration("152h33m0s") + if err != nil { + t.Fatal(err) + } + want := mikrus.Uptime{ + Uptime: wantUptime, + Users: 0, + CPUload1min: 0.1, + CPUload5min: 1.0, + CPUload15min: 0.5, + } + got, err := mikrus.ParseUptime(uptimeCmdOutput) + if err != nil { + t.Fatal(err) + } + if want != got { + t.Error(cmp.Diff(want, got)) + } +} + +func TestParseUptime_ErrorsForInvalidInput(t *testing.T) { + t.Parallel() + _, err := mikrus.ParseUptime("bogus") + if err == nil { + t.Fatal("want error for invalid input, got nil") + } +} + +func TestParsePS_ParsesPSCommandOutput(t *testing.T) { + t.Parallel() + psCmdOutput := "USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nroot 21605 0.0 0.3 9504 3368 ? S 16:32 0:00 bash -c cat | sh\nroot 21607 0.0 0.0 2608 596 ? S 16:32 0:00 \\_ sh\n" + want := []mikrus.ProcessInfo{ + { + User: "root", + PID: 21605, + CPUPercent: 0.0, + MemoryPercent: 0.3, + VirtualMemorySize: 9504, + ResidentSetSize: 3368, + TTY: "?", + State: "S", + Start: "16:32", + CPUTime: "0:00", + Command: "bash -c cat | sh", + }, + { + User: "root", + PID: 21607, + CPUPercent: 0.0, + MemoryPercent: 0.0, + VirtualMemorySize: 2608, + ResidentSetSize: 596, + TTY: "?", + State: "S", + Start: "16:32", + CPUTime: "0:00", + Command: "\\_ sh", + }, + } + got, err := mikrus.ParsePS(psCmdOutput) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(want, got) { + t.Error(cmp.Diff(want, got)) + } +} + +func TestParsePS_ErrorsForInvalidInput(t *testing.T) { + t.Parallel() + _, err := mikrus.ParsePS("bogus") + if err == nil { + t.Fatal("want error for invalid input, got nil") + } +} + +var statResponse = `{ + "free": "total used free shared buff/cache available\nMem: 1024 43 816 0 164 980\nSwap: 0 0 0", + "df": "Filesystem Size Used Avail Use% Mounted on\n/dev/mapper/pve-vm--230--disk--0 9.8G 2.7G 6.7G 29% /\nudev 63G 0 63G 0% /dev/net", + "uptime": "16:32:02 up 6 days, 8:33, 0 users, load average: 0.00, 0.00, 0.00\nsh: 1: echo", + "ps": ": not found\nUSER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nroot 21605 0.0 0.3 9504 3368 ? S 16:32 0:00 bash -c cat | sh\nroot 21607 0.0 0.0 2608 596 ? S 16:32 0:00 \\_ sh\nroot 21612 0.0 0.3 11420 3264 ? R 16:32 0:00 \\_ ps auxf\nroot 1 0.0 1.0 169412 10748 ? Ss Jun05 0:04 /sbin/init\nroot 48 0.0 6.0 141148 63436 ? Ss Jun05 0:20 /lib/systemd/systemd-journald\nsystemd+ 73 0.0 0.7 18376 7616 ? Ss Jun05 0:00 /lib/systemd/systemd-networkd\nsystemd+ 89 0.0 1.1 23924 12128 ? Ss Jun05 0:14 /lib/systemd/systemd-resolved\nroot 103 0.0 0.6 238088 7264 ? Ssl Jun05 0:06 /usr/lib/accountsservice/accounts-daemon\nroot 104 0.0 0.2 9344 2796 ? Ss Jun05 0:00 /usr/sbin/cron -f\nmessage+ 105 0.0 0.3 7404 4100 ? Ss Jun05 0:00 /usr/bin/dbus-daemon --system --address=systemd: --nofork --nopidfile --systemd-activation --syslog-only\nroot 108 0.0 1.7 31804 18404 ? Ss Jun05 0:00 /usr/bin/python3 /usr/bin/networkd-dispatcher --run-startup-triggers\nsyslog 110 0.0 0.4 154708 4248 ? Ssl Jun05 0:01 /usr/sbin/rsyslogd -n -iNONE\nroot 113 0.0 0.5 16440 6124 ? Ss Jun05 0:00 /lib/systemd/systemd-logind\nroot 122 0.0 0.2 8132 2144 console Ss+ Jun05 0:00 /sbin/agetty -o -p -- \\u --noclear --keep-baud console 115200,38400,9600 linux\nroot 123 0.0 0.2 8132 2248 pts/0 Ss+ Jun05 0:00 /sbin/agetty -o -p -- \\u --noclear --keep-baud tty1 115200,38400,9600 linux\nroot 124 0.0 0.2 8132 2132 pts/1 Ss+ Jun05 0:00 /sbin/agetty -o -p -- \\u --noclear --keep-baud tty2 115200,38400,9600 linux\nroot 126 0.0 0.6 12172 7124 ? Ss Jun05 0:03 sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups" +}`