chore: publish pxmon v0.2.0
This commit is contained in:
@@ -0,0 +1,763 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Version is set via ldflags at build time.
|
||||
var Version = "v0.2.0"
|
||||
|
||||
// Config controls agent runtime.
|
||||
type Config struct {
|
||||
ListenAddr string `json:"listen_addr"`
|
||||
Token string `json:"token"`
|
||||
RequestSecret string `json:"request_secret,omitempty"`
|
||||
TLSEnabled bool `json:"tls_enabled,omitempty"`
|
||||
TLSCertPath string `json:"tls_cert_path,omitempty"`
|
||||
TLSKeyPath string `json:"tls_key_path,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
cpuSampleMu sync.Mutex
|
||||
prevCPUTotal uint64
|
||||
prevCPUIdle uint64
|
||||
prevCPUSet bool
|
||||
)
|
||||
|
||||
const signedRequestMaxSkewSeconds = 300
|
||||
|
||||
type nonceState struct {
|
||||
mu sync.Mutex
|
||||
seen map[string]int64
|
||||
}
|
||||
|
||||
// StatsResponse contains basic node metrics.
|
||||
type StatsResponse struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Host HostStats `json:"host"`
|
||||
CPU CPUStats `json:"cpu"`
|
||||
Memory MemoryStats `json:"memory"`
|
||||
Disk []DiskStats `json:"disk"`
|
||||
Network []NetworkStat `json:"network"`
|
||||
}
|
||||
|
||||
// HostStats contains host-level facts.
|
||||
type HostStats struct {
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
UptimeSeconds float64 `json:"uptime_seconds"`
|
||||
}
|
||||
|
||||
// CPUStats contains CPU/load values.
|
||||
type CPUStats struct {
|
||||
LogicalCores int `json:"logical_cores"`
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
Load1 float64 `json:"load_1"`
|
||||
Load5 float64 `json:"load_5"`
|
||||
Load15 float64 `json:"load_15"`
|
||||
}
|
||||
|
||||
// MemoryStats contains RAM usage values in bytes.
|
||||
type MemoryStats struct {
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
AvailableBytes uint64 `json:"available_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
SwapTotalBytes uint64 `json:"swap_total_bytes"`
|
||||
SwapFreeBytes uint64 `json:"swap_free_bytes"`
|
||||
SwapUsedBytes uint64 `json:"swap_used_bytes"`
|
||||
SwapUsedPct float64 `json:"swap_used_percent"`
|
||||
}
|
||||
|
||||
// DiskStats contains one mount usage entry.
|
||||
type DiskStats struct {
|
||||
Source string `json:"source"`
|
||||
MountPoint string `json:"mount_point"`
|
||||
FSType string `json:"fs_type"`
|
||||
Device string `json:"device"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
FreeBytes uint64 `json:"free_bytes"`
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
UsedPercent float64 `json:"used_percent"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
DeviceState string `json:"device_state,omitempty"`
|
||||
ErrorsCount uint64 `json:"errors_count"`
|
||||
Health string `json:"health"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// NetworkStat contains one interface counters snapshot.
|
||||
type NetworkStat struct {
|
||||
Interface string `json:"interface"`
|
||||
RxBytes uint64 `json:"rx_bytes"`
|
||||
TxBytes uint64 `json:"tx_bytes"`
|
||||
RxPackets uint64 `json:"rx_packets"`
|
||||
TxPackets uint64 `json:"tx_packets"`
|
||||
RxDrops uint64 `json:"rx_drops"`
|
||||
TxDrops uint64 `json:"tx_drops"`
|
||||
}
|
||||
|
||||
// LoadConfig reads JSON config from disk.
|
||||
func LoadConfig(path string) (Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.ListenAddr = strings.TrimSpace(cfg.ListenAddr)
|
||||
cfg.Token = strings.TrimSpace(cfg.Token)
|
||||
cfg.RequestSecret = strings.TrimSpace(cfg.RequestSecret)
|
||||
cfg.TLSCertPath = strings.TrimSpace(cfg.TLSCertPath)
|
||||
cfg.TLSKeyPath = strings.TrimSpace(cfg.TLSKeyPath)
|
||||
if cfg.ListenAddr == "" {
|
||||
cfg.ListenAddr = "0.0.0.0:19090"
|
||||
}
|
||||
if cfg.Token == "" {
|
||||
return Config{}, errors.New("token is required")
|
||||
}
|
||||
if cfg.TLSEnabled {
|
||||
if cfg.TLSCertPath == "" || cfg.TLSKeyPath == "" {
|
||||
return Config{}, errors.New("tls_enabled=true requires tls_cert_path and tls_key_path")
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Run starts the HTTP API server.
|
||||
func Run(cfg Config) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
|
||||
mux.Handle("/api/v1/ping", authMiddleware(cfg.Token, cfg.RequestSecret, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"version": Version,
|
||||
"time": time.Now().UTC(),
|
||||
})
|
||||
})))
|
||||
|
||||
mux.Handle("/api/v1/stats", authMiddleware(cfg.Token, cfg.RequestSecret, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := CollectStats()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, stats)
|
||||
})))
|
||||
|
||||
mux.Handle("/api/v1/top", authMiddleware(cfg.Token, cfg.RequestSecret, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
sampleMs, _ := strconv.Atoi(q.Get("sample_ms"))
|
||||
window := time.Duration(sampleMs) * time.Millisecond
|
||||
top, err := CollectTopProcesses(window, limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, top)
|
||||
})))
|
||||
|
||||
mux.Handle("/api/v1/du", authMiddleware(cfg.Token, cfg.RequestSecret, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
root := q.Get("path")
|
||||
if strings.TrimSpace(root) == "" {
|
||||
root = "/"
|
||||
}
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
timeoutMs, _ := strconv.Atoi(q.Get("timeout_ms"))
|
||||
if timeoutMs <= 0 || timeoutMs > 45000 {
|
||||
timeoutMs = 15000
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
resp, err := CollectDirSizes(ctx, root, limit)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
})))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
if cfg.TLSEnabled {
|
||||
return srv.ListenAndServeTLS(cfg.TLSCertPath, cfg.TLSKeyPath)
|
||||
}
|
||||
return srv.ListenAndServe()
|
||||
}
|
||||
|
||||
// CollectStats gathers a metrics snapshot.
|
||||
func CollectStats() (StatsResponse, error) {
|
||||
hostname, _ := os.Hostname()
|
||||
|
||||
uptime := readUptimeSeconds()
|
||||
load1, load5, load15 := readLoadAvg()
|
||||
usage := readCPUUsagePercent()
|
||||
mem := readMemory()
|
||||
disk := readDiskStats()
|
||||
netStats := readNetworkStats()
|
||||
|
||||
stats := StatsResponse{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Host: HostStats{
|
||||
Hostname: hostname,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
UptimeSeconds: uptime,
|
||||
},
|
||||
CPU: CPUStats{
|
||||
LogicalCores: runtime.NumCPU(),
|
||||
UsagePercent: usage,
|
||||
Load1: round2(load1),
|
||||
Load5: round2(load5),
|
||||
Load15: round2(load15),
|
||||
},
|
||||
Memory: mem,
|
||||
Disk: disk,
|
||||
Network: netStats,
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func authMiddleware(token, requestSecret string, next http.Handler) http.Handler {
|
||||
ns := &nonceState{seen: map[string]int64{}}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
xToken := strings.TrimSpace(r.Header.Get("X-Agent-Token"))
|
||||
|
||||
ok := false
|
||||
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
|
||||
provided := strings.TrimSpace(auth[7:])
|
||||
if secureTokenEqual(provided, token) {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if secureTokenEqual(xToken, token) {
|
||||
ok = true
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !verifySignedRequest(r, requestSecret, ns) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func secureTokenEqual(provided, expected string) bool {
|
||||
if len(provided) == 0 || len(expected) == 0 {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
|
||||
}
|
||||
|
||||
func verifySignedRequest(r *http.Request, secret string, ns *nonceState) bool {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return true
|
||||
}
|
||||
tsRaw := strings.TrimSpace(r.Header.Get("X-Observer-Ts"))
|
||||
nonce := strings.TrimSpace(r.Header.Get("X-Observer-Nonce"))
|
||||
sigRaw := strings.TrimSpace(strings.ToLower(r.Header.Get("X-Observer-Signature")))
|
||||
if tsRaw == "" || nonce == "" || sigRaw == "" {
|
||||
return false
|
||||
}
|
||||
ts, err := strconv.ParseInt(tsRaw, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC().Unix()
|
||||
if ts < now-signedRequestMaxSkewSeconds || ts > now+signedRequestMaxSkewSeconds {
|
||||
return false
|
||||
}
|
||||
payload := r.Method + "\n" + r.URL.RequestURI() + "\n" + tsRaw + "\n" + nonce
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
wantHex := hex.EncodeToString(mac.Sum(nil))
|
||||
if subtle.ConstantTimeCompare([]byte(sigRaw), []byte(strings.ToLower(wantHex))) != 1 {
|
||||
return false
|
||||
}
|
||||
|
||||
key := tsRaw + ":" + nonce
|
||||
ns.lock()
|
||||
defer ns.unlock()
|
||||
if expiry, ok := ns.get(key); ok && expiry >= now {
|
||||
return false
|
||||
}
|
||||
ns.set(key, now+signedRequestMaxSkewSeconds+60)
|
||||
ns.prune(now)
|
||||
return true
|
||||
}
|
||||
|
||||
func (n *nonceState) lock() { n.mu.Lock() }
|
||||
func (n *nonceState) unlock() { n.mu.Unlock() }
|
||||
func (n *nonceState) get(k string) (int64, bool) {
|
||||
v, ok := n.seen[k]
|
||||
return v, ok
|
||||
}
|
||||
func (n *nonceState) set(k string, exp int64) {
|
||||
n.seen[k] = exp
|
||||
}
|
||||
func (n *nonceState) prune(now int64) {
|
||||
if len(n.seen) < 4096 {
|
||||
return
|
||||
}
|
||||
for k, exp := range n.seen {
|
||||
if exp < now {
|
||||
delete(n.seen, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
func readUptimeSeconds() float64 {
|
||||
if runtime.GOOS == "linux" {
|
||||
data, err := os.ReadFile("/proc/uptime")
|
||||
if err == nil {
|
||||
parts := strings.Fields(string(data))
|
||||
if len(parts) > 0 {
|
||||
if v, err := strconv.ParseFloat(parts[0], 64); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func readLoadAvg() (float64, float64, float64) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return 0, 0, 0
|
||||
}
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return 0, 0, 0
|
||||
}
|
||||
parts := strings.Fields(string(data))
|
||||
if len(parts) < 3 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
l1, _ := strconv.ParseFloat(parts[0], 64)
|
||||
l5, _ := strconv.ParseFloat(parts[1], 64)
|
||||
l15, _ := strconv.ParseFloat(parts[2], 64)
|
||||
return l1, l5, l15
|
||||
}
|
||||
|
||||
func readCPUUsagePercent() float64 {
|
||||
if runtime.GOOS != "linux" {
|
||||
return 0
|
||||
}
|
||||
|
||||
total, idle, err := readCPUCounters()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
cpuSampleMu.Lock()
|
||||
defer cpuSampleMu.Unlock()
|
||||
|
||||
if !prevCPUSet {
|
||||
prevCPUTotal = total
|
||||
prevCPUIdle = idle
|
||||
prevCPUSet = true
|
||||
return 0
|
||||
}
|
||||
|
||||
var usage float64
|
||||
if total > prevCPUTotal {
|
||||
dTotal := total - prevCPUTotal
|
||||
dIdle := uint64(0)
|
||||
if idle > prevCPUIdle {
|
||||
dIdle = idle - prevCPUIdle
|
||||
}
|
||||
if dTotal > 0 {
|
||||
if dIdle > dTotal {
|
||||
dIdle = dTotal
|
||||
}
|
||||
usage = (float64(dTotal-dIdle) / float64(dTotal)) * 100
|
||||
}
|
||||
}
|
||||
|
||||
prevCPUTotal = total
|
||||
prevCPUIdle = idle
|
||||
|
||||
if usage < 0 {
|
||||
usage = 0
|
||||
}
|
||||
if usage > 100 {
|
||||
usage = 100
|
||||
}
|
||||
return round2(usage)
|
||||
}
|
||||
|
||||
func readCPUCounters() (uint64, uint64, error) {
|
||||
data, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if len(lines) == 0 {
|
||||
return 0, 0, errors.New("empty /proc/stat")
|
||||
}
|
||||
|
||||
fields := strings.Fields(lines[0])
|
||||
if len(fields) < 5 || fields[0] != "cpu" {
|
||||
return 0, 0, errors.New("invalid cpu line in /proc/stat")
|
||||
}
|
||||
|
||||
var values []uint64
|
||||
for i := 1; i < len(fields); i++ {
|
||||
v, convErr := strconv.ParseUint(fields[i], 10, 64)
|
||||
if convErr != nil {
|
||||
return 0, 0, convErr
|
||||
}
|
||||
values = append(values, v)
|
||||
}
|
||||
|
||||
total := uint64(0)
|
||||
for _, v := range values {
|
||||
total += v
|
||||
}
|
||||
idle := values[3]
|
||||
if len(values) > 4 {
|
||||
idle += values[4] // iowait
|
||||
}
|
||||
return total, idle, nil
|
||||
}
|
||||
|
||||
func readMemory() MemoryStats {
|
||||
if runtime.GOOS != "linux" {
|
||||
return MemoryStats{}
|
||||
}
|
||||
|
||||
f, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return MemoryStats{}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
values := map[string]uint64{}
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
fields := strings.Fields(strings.TrimSpace(parts[1]))
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseUint(fields[0], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
values[key] = v * 1024 // kB -> bytes
|
||||
}
|
||||
|
||||
total := values["MemTotal"]
|
||||
available := values["MemAvailable"]
|
||||
if available == 0 {
|
||||
available = values["MemFree"] + values["Buffers"] + values["Cached"]
|
||||
}
|
||||
used := uint64(0)
|
||||
if total > available {
|
||||
used = total - available
|
||||
}
|
||||
usedPercent := 0.0
|
||||
if total > 0 {
|
||||
usedPercent = (float64(used) / float64(total)) * 100
|
||||
}
|
||||
|
||||
swapTotal := values["SwapTotal"]
|
||||
swapFree := values["SwapFree"]
|
||||
swapUsed := uint64(0)
|
||||
if swapTotal > swapFree {
|
||||
swapUsed = swapTotal - swapFree
|
||||
}
|
||||
swapUsedPct := 0.0
|
||||
if swapTotal > 0 {
|
||||
swapUsedPct = (float64(swapUsed) / float64(swapTotal)) * 100
|
||||
}
|
||||
|
||||
return MemoryStats{
|
||||
TotalBytes: total,
|
||||
AvailableBytes: available,
|
||||
UsedBytes: used,
|
||||
UsedPercent: round2(usedPercent),
|
||||
SwapTotalBytes: swapTotal,
|
||||
SwapFreeBytes: swapFree,
|
||||
SwapUsedBytes: swapUsed,
|
||||
SwapUsedPct: round2(swapUsedPct),
|
||||
}
|
||||
}
|
||||
|
||||
type mountInfo struct {
|
||||
Source string
|
||||
MountPoint string
|
||||
FSType string
|
||||
}
|
||||
|
||||
func readDiskStats() []DiskStats {
|
||||
mounts := discoverMounts()
|
||||
seen := make(map[string]struct{}, len(mounts))
|
||||
out := make([]DiskStats, 0, len(mounts))
|
||||
|
||||
for _, m := range mounts {
|
||||
if _, ok := seen[m.MountPoint]; ok {
|
||||
continue
|
||||
}
|
||||
seen[m.MountPoint] = struct{}{}
|
||||
|
||||
total, free, err := statfsUsage(m.MountPoint)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
used := uint64(0)
|
||||
if total > free {
|
||||
used = total - free
|
||||
}
|
||||
usedPercent := 0.0
|
||||
if total > 0 {
|
||||
usedPercent = (float64(used) / float64(total)) * 100
|
||||
}
|
||||
|
||||
device, readOnly, state, errorsCount, health, warnings := readDiskHealth(m.Source, m.FSType)
|
||||
|
||||
out = append(out, DiskStats{
|
||||
Source: m.Source,
|
||||
MountPoint: m.MountPoint,
|
||||
FSType: m.FSType,
|
||||
Device: device,
|
||||
TotalBytes: total,
|
||||
FreeBytes: free,
|
||||
UsedBytes: used,
|
||||
UsedPercent: round2(usedPercent),
|
||||
ReadOnly: readOnly,
|
||||
DeviceState: state,
|
||||
ErrorsCount: errorsCount,
|
||||
Health: health,
|
||||
Warnings: warnings,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].MountPoint < out[j].MountPoint })
|
||||
return out
|
||||
}
|
||||
|
||||
func discoverMounts() []mountInfo {
|
||||
if runtime.GOOS != "linux" {
|
||||
return []mountInfo{{Source: "/", MountPoint: "/", FSType: "unknown"}}
|
||||
}
|
||||
|
||||
f, err := os.Open("/proc/mounts")
|
||||
if err != nil {
|
||||
return []mountInfo{{Source: "/", MountPoint: "/", FSType: "unknown"}}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
skipFS := map[string]struct{}{
|
||||
"proc": {}, "sysfs": {}, "tmpfs": {}, "devtmpfs": {}, "devpts": {},
|
||||
"overlay": {}, "squashfs": {}, "cgroup": {}, "cgroup2": {}, "autofs": {},
|
||||
"securityfs": {}, "pstore": {}, "debugfs": {}, "tracefs": {}, "fusectl": {},
|
||||
}
|
||||
|
||||
out := []mountInfo{}
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
fsType := fields[2]
|
||||
if _, skip := skipFS[fsType]; skip {
|
||||
continue
|
||||
}
|
||||
out = append(out, mountInfo{
|
||||
Source: fields[0],
|
||||
MountPoint: fields[1],
|
||||
FSType: fsType,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = []mountInfo{{Source: "/", MountPoint: "/", FSType: "unknown"}}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readDiskHealth(source, fsType string) (device string, readOnly bool, state string, errorsCount uint64, health string, warnings []string) {
|
||||
health = "ok"
|
||||
if !strings.HasPrefix(source, "/dev/") {
|
||||
return "", false, "", 0, health, nil
|
||||
}
|
||||
|
||||
device = filepath.Base(source)
|
||||
if strings.HasPrefix(device, "mapper/") {
|
||||
device = strings.TrimPrefix(device, "mapper/")
|
||||
}
|
||||
device = strings.TrimPrefix(device, "/")
|
||||
|
||||
roPath := filepath.Join("/sys/class/block", device, "ro")
|
||||
if v, err := os.ReadFile(roPath); err == nil {
|
||||
readOnly = strings.TrimSpace(string(v)) == "1"
|
||||
if readOnly {
|
||||
health = "warning"
|
||||
warnings = append(warnings, "device is read-only")
|
||||
}
|
||||
}
|
||||
|
||||
statePath := filepath.Join("/sys/class/block", device, "device", "state")
|
||||
if v, err := os.ReadFile(statePath); err == nil {
|
||||
state = strings.TrimSpace(string(v))
|
||||
if state != "" && state != "running" {
|
||||
if health == "ok" {
|
||||
health = "warning"
|
||||
}
|
||||
warnings = append(warnings, "device state is "+state)
|
||||
}
|
||||
}
|
||||
|
||||
if fsType == "ext4" {
|
||||
ext4ErrPath := filepath.Join("/sys/fs/ext4", device, "errors_count")
|
||||
if v, err := os.ReadFile(ext4ErrPath); err == nil {
|
||||
if parsed, convErr := strconv.ParseUint(strings.TrimSpace(string(v)), 10, 64); convErr == nil {
|
||||
errorsCount = parsed
|
||||
if errorsCount > 0 {
|
||||
health = "critical"
|
||||
warnings = append(warnings, fmt.Sprintf("ext4 errors_count=%d", errorsCount))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return device, readOnly, state, errorsCount, health, warnings
|
||||
}
|
||||
|
||||
func readNetworkStats() []NetworkStat {
|
||||
if runtime.GOOS != "linux" {
|
||||
return []NetworkStat{}
|
||||
}
|
||||
|
||||
f, err := os.Open("/proc/net/dev")
|
||||
if err != nil {
|
||||
return []NetworkStat{}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
out := []NetworkStat{}
|
||||
s := bufio.NewScanner(f)
|
||||
lineNo := 0
|
||||
for s.Scan() {
|
||||
lineNo++
|
||||
if lineNo <= 2 {
|
||||
continue
|
||||
}
|
||||
line := strings.TrimSpace(s.Text())
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
iface := strings.TrimSpace(parts[0])
|
||||
fields := strings.Fields(parts[1])
|
||||
if len(fields) < 16 {
|
||||
continue
|
||||
}
|
||||
rx, err1 := strconv.ParseUint(fields[0], 10, 64)
|
||||
rxPackets, errP1 := strconv.ParseUint(fields[1], 10, 64)
|
||||
rxDrops, errD1 := strconv.ParseUint(fields[3], 10, 64)
|
||||
tx, err2 := strconv.ParseUint(fields[8], 10, 64)
|
||||
txPackets, errP2 := strconv.ParseUint(fields[9], 10, 64)
|
||||
txDrops, errD2 := strconv.ParseUint(fields[11], 10, 64)
|
||||
if err1 != nil || err2 != nil || errP1 != nil || errP2 != nil || errD1 != nil || errD2 != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, NetworkStat{
|
||||
Interface: iface,
|
||||
RxBytes: rx,
|
||||
TxBytes: tx,
|
||||
RxPackets: rxPackets,
|
||||
TxPackets: txPackets,
|
||||
RxDrops: rxDrops,
|
||||
TxDrops: txDrops,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Interface < out[j].Interface })
|
||||
return out
|
||||
}
|
||||
|
||||
func round2(v float64) float64 {
|
||||
return math.Round(v*100) / 100
|
||||
}
|
||||
|
||||
// DefaultConfigPath returns default location used by agent process.
|
||||
func DefaultConfigPath() string {
|
||||
return filepath.Join(".", "agent.json")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package agent
|
||||
|
||||
import "syscall"
|
||||
|
||||
func statfsUsage(path string) (total, free uint64, err error) {
|
||||
var fs syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &fs); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return fs.Blocks * uint64(fs.Bsize), fs.Bavail * uint64(fs.Bsize), nil
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build windows
|
||||
|
||||
package agent
|
||||
|
||||
// statfsUsage is a compatibility fallback for Windows builds where
|
||||
// syscall.Statfs is unavailable. We return zero-sized stats instead of
|
||||
// failing compilation; disk health/usage can be extended with native APIs.
|
||||
func statfsUsage(path string) (total, free uint64, err error) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProcessStat is one process summary for /api/v1/top.
|
||||
type ProcessStat struct {
|
||||
PID int `json:"pid"`
|
||||
User string `json:"user"`
|
||||
Command string `json:"command"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
RSSBytes uint64 `json:"rss_bytes"`
|
||||
VSZBytes uint64 `json:"vsz_bytes"`
|
||||
IOReadTot uint64 `json:"io_read_bytes"`
|
||||
IOWriteTot uint64 `json:"io_write_bytes"`
|
||||
}
|
||||
|
||||
// TopResponse is the payload returned by /api/v1/top.
|
||||
type TopResponse struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
SampleMs int64 `json:"sample_ms"`
|
||||
TotalProcs int `json:"total_procs"`
|
||||
TopByCPU []ProcessStat `json:"top_by_cpu"`
|
||||
TopByMemory []ProcessStat `json:"top_by_memory"`
|
||||
TopByIO []ProcessStat `json:"top_by_io"`
|
||||
}
|
||||
|
||||
// CollectTopProcesses takes two samples of /proc/[pid]/stat separated by
|
||||
// sampleWindow to compute CPU%, then returns the top N processes by CPU, by
|
||||
// RSS, and by IO total. Linux-only; other OSes return an empty response.
|
||||
func CollectTopProcesses(sampleWindow time.Duration, limit int) (TopResponse, error) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return TopResponse{Timestamp: time.Now().UTC()}, nil
|
||||
}
|
||||
if sampleWindow <= 0 {
|
||||
sampleWindow = 250 * time.Millisecond
|
||||
}
|
||||
if sampleWindow > 2*time.Second {
|
||||
sampleWindow = 2 * time.Second
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 20
|
||||
}
|
||||
|
||||
hz := clockTicksPerSecond()
|
||||
pageSize := uint64(os.Getpagesize())
|
||||
|
||||
first, err := snapshotProcesses()
|
||||
if err != nil {
|
||||
return TopResponse{}, err
|
||||
}
|
||||
time.Sleep(sampleWindow)
|
||||
second, err := snapshotProcesses()
|
||||
if err != nil {
|
||||
return TopResponse{}, err
|
||||
}
|
||||
|
||||
usernameCache := newUsernameCache()
|
||||
elapsedTicks := float64(sampleWindow.Seconds()) * hz
|
||||
if elapsedTicks <= 0 {
|
||||
elapsedTicks = 1
|
||||
}
|
||||
|
||||
merged := make([]ProcessStat, 0, len(second))
|
||||
for pid, s2 := range second {
|
||||
s1, ok := first[pid]
|
||||
cpu := 0.0
|
||||
if ok {
|
||||
dTicks := float64((s2.utime + s2.stime) - (s1.utime + s1.stime))
|
||||
if dTicks > 0 {
|
||||
cpu = (dTicks / elapsedTicks) * 100.0
|
||||
}
|
||||
}
|
||||
if cpu < 0 {
|
||||
cpu = 0
|
||||
}
|
||||
|
||||
merged = append(merged, ProcessStat{
|
||||
PID: pid,
|
||||
User: usernameCache.lookup(s2.uid),
|
||||
Command: s2.command,
|
||||
CPUPercent: round2(cpu),
|
||||
RSSBytes: s2.rssPages * pageSize,
|
||||
VSZBytes: s2.vsize,
|
||||
IOReadTot: s2.ioRead,
|
||||
IOWriteTot: s2.ioWrite,
|
||||
})
|
||||
}
|
||||
|
||||
byCPU := topN(merged, limit, func(a, b ProcessStat) bool { return a.CPUPercent > b.CPUPercent })
|
||||
byMem := topN(merged, limit, func(a, b ProcessStat) bool { return a.RSSBytes > b.RSSBytes })
|
||||
byIO := topN(merged, limit, func(a, b ProcessStat) bool {
|
||||
return (a.IOReadTot + a.IOWriteTot) > (b.IOReadTot + b.IOWriteTot)
|
||||
})
|
||||
|
||||
return TopResponse{
|
||||
Timestamp: time.Now().UTC(),
|
||||
SampleMs: sampleWindow.Milliseconds(),
|
||||
TotalProcs: len(merged),
|
||||
TopByCPU: byCPU,
|
||||
TopByMemory: byMem,
|
||||
TopByIO: byIO,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type procSample struct {
|
||||
pid int
|
||||
command string
|
||||
utime uint64
|
||||
stime uint64
|
||||
vsize uint64
|
||||
rssPages uint64
|
||||
uid int
|
||||
ioRead uint64
|
||||
ioWrite uint64
|
||||
}
|
||||
|
||||
func snapshotProcesses() (map[int]procSample, error) {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[int]procSample, 256)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil || pid <= 0 {
|
||||
continue
|
||||
}
|
||||
s, ok := readProcSample(pid)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[pid] = s
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readProcSample(pid int) (procSample, bool) {
|
||||
pidStr := strconv.Itoa(pid)
|
||||
statData, err := os.ReadFile("/proc/" + pidStr + "/stat")
|
||||
if err != nil {
|
||||
return procSample{}, false
|
||||
}
|
||||
|
||||
// comm is in parens and may contain spaces; parse after the last ')'.
|
||||
line := string(statData)
|
||||
rp := strings.LastIndexByte(line, ')')
|
||||
if rp <= 0 {
|
||||
return procSample{}, false
|
||||
}
|
||||
lp := strings.IndexByte(line, '(')
|
||||
if lp < 0 || lp >= rp {
|
||||
return procSample{}, false
|
||||
}
|
||||
comm := line[lp+1 : rp]
|
||||
rest := strings.Fields(line[rp+2:])
|
||||
// After comm and the state char, indices inside `rest`:
|
||||
// 0: state ... but we split from after ') '. fields[0]=state, fields[1]=ppid,
|
||||
// fields[2]=pgrp, ..., fields[11]=utime, fields[12]=stime, fields[19]=vsize, fields[20]=rss.
|
||||
if len(rest) < 22 {
|
||||
return procSample{}, false
|
||||
}
|
||||
utime, _ := strconv.ParseUint(rest[11], 10, 64)
|
||||
stime, _ := strconv.ParseUint(rest[12], 10, 64)
|
||||
vsize, _ := strconv.ParseUint(rest[20], 10, 64)
|
||||
rssPages, _ := strconv.ParseUint(rest[21], 10, 64)
|
||||
|
||||
uid := readProcUID(pidStr)
|
||||
ioRead, ioWrite := readProcIO(pidStr)
|
||||
|
||||
return procSample{
|
||||
pid: pid,
|
||||
command: comm,
|
||||
utime: utime,
|
||||
stime: stime,
|
||||
vsize: vsize,
|
||||
rssPages: rssPages,
|
||||
uid: uid,
|
||||
ioRead: ioRead,
|
||||
ioWrite: ioWrite,
|
||||
}, true
|
||||
}
|
||||
|
||||
func readProcUID(pidStr string) int {
|
||||
data, err := os.ReadFile("/proc/" + pidStr + "/status")
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "Uid:") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
if v, err := strconv.Atoi(fields[1]); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func readProcIO(pidStr string) (uint64, uint64) {
|
||||
data, err := os.ReadFile("/proc/" + pidStr + "/io")
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
var rb, wb uint64
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
val := strings.TrimSpace(parts[1])
|
||||
v, _ := strconv.ParseUint(val, 10, 64)
|
||||
switch key {
|
||||
case "read_bytes":
|
||||
rb = v
|
||||
case "write_bytes":
|
||||
wb = v
|
||||
}
|
||||
}
|
||||
return rb, wb
|
||||
}
|
||||
|
||||
var cachedClockTicks float64
|
||||
var cachedClockOnce sync.Once
|
||||
|
||||
func clockTicksPerSecond() float64 {
|
||||
cachedClockOnce.Do(func() {
|
||||
// SC_CLK_TCK is almost always 100 on Linux; read from /proc/self/stat
|
||||
// + uptime as a sanity check would be nicer but we accept the default.
|
||||
cachedClockTicks = 100.0
|
||||
})
|
||||
return cachedClockTicks
|
||||
}
|
||||
|
||||
type usernameCache struct {
|
||||
cache map[int]string
|
||||
}
|
||||
|
||||
func newUsernameCache() *usernameCache {
|
||||
return &usernameCache{cache: make(map[int]string, 16)}
|
||||
}
|
||||
|
||||
func (c *usernameCache) lookup(uid int) string {
|
||||
if uid < 0 {
|
||||
return "-"
|
||||
}
|
||||
if v, ok := c.cache[uid]; ok {
|
||||
return v
|
||||
}
|
||||
u, err := user.LookupId(strconv.Itoa(uid))
|
||||
if err != nil || u == nil {
|
||||
name := strconv.Itoa(uid)
|
||||
c.cache[uid] = name
|
||||
return name
|
||||
}
|
||||
c.cache[uid] = u.Username
|
||||
return u.Username
|
||||
}
|
||||
|
||||
func topN(in []ProcessStat, n int, less func(a, b ProcessStat) bool) []ProcessStat {
|
||||
cp := make([]ProcessStat, len(in))
|
||||
copy(cp, in)
|
||||
sort.Slice(cp, func(i, j int) bool { return less(cp[i], cp[j]) })
|
||||
if len(cp) > n {
|
||||
cp = cp[:n]
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
// DirStat is one directory entry in a du-style listing.
|
||||
type DirStat struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
Bytes uint64 `json:"bytes"`
|
||||
Files uint64 `json:"files"`
|
||||
}
|
||||
|
||||
// DUResponse is returned by /api/v1/du.
|
||||
type DUResponse struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Root string `json:"root"`
|
||||
Entries []DirStat `json:"entries"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// CollectDirSizes lists immediate children of root (only directories) and
|
||||
// sums file sizes under each child recursively, respecting ctx deadline.
|
||||
// Returned entries are sorted by Bytes descending. Truncated is true if the
|
||||
// walker stopped early due to timeout.
|
||||
func CollectDirSizes(ctx context.Context, root string, limit int) (DUResponse, error) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root == "" {
|
||||
root = "/"
|
||||
}
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return DUResponse{}, err
|
||||
}
|
||||
info, err := os.Stat(absRoot)
|
||||
if err != nil {
|
||||
return DUResponse{}, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return DUResponse{}, errors.New("root is not a directory")
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 15
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(absRoot)
|
||||
if err != nil {
|
||||
return DUResponse{}, err
|
||||
}
|
||||
|
||||
truncated := false
|
||||
out := make([]DirStat, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if ctx.Err() != nil {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
// Skip virtual filesystems when rooted at /.
|
||||
if absRoot == "/" && isSkippedSystemDir(name) {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(absRoot, name)
|
||||
size, files, stopped := sumDirectory(ctx, full)
|
||||
if stopped {
|
||||
truncated = true
|
||||
}
|
||||
out = append(out, DirStat{
|
||||
Path: full,
|
||||
Name: name,
|
||||
Bytes: size,
|
||||
Files: files,
|
||||
})
|
||||
if stopped {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Bytes > out[j].Bytes })
|
||||
if len(out) > limit {
|
||||
out = out[:limit]
|
||||
}
|
||||
|
||||
return DUResponse{
|
||||
Timestamp: time.Now().UTC(),
|
||||
Root: absRoot,
|
||||
Entries: out,
|
||||
Truncated: truncated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isSkippedSystemDir(name string) bool {
|
||||
switch name {
|
||||
case "proc", "sys", "dev", "run", "tmp":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sumDirectory(ctx context.Context, path string) (uint64, uint64, bool) {
|
||||
var total uint64
|
||||
var files uint64
|
||||
stopped := false
|
||||
|
||||
walkFn := func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if d != nil && d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
stopped = true
|
||||
return filepath.SkipAll
|
||||
}
|
||||
if d.IsDir() {
|
||||
// Skip known pseudo filesystems we may cross into.
|
||||
name := d.Name()
|
||||
if p != path && (name == "proc" || name == "sys" || name == "dev") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
total += uint64(info.Size())
|
||||
files++
|
||||
return nil
|
||||
}
|
||||
_ = filepath.WalkDir(path, walkFn)
|
||||
return total, files, stopped
|
||||
}
|
||||
|
||||
// scanLinesToFields is a tiny helper for tests and future use.
|
||||
func scanLinesToFields(data []byte) [][]string {
|
||||
var out [][]string
|
||||
sc := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
for sc.Scan() {
|
||||
out = append(out, strings.Fields(sc.Text()))
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user