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") }