chore: publish pxmon v0.2.0

This commit is contained in:
2026-06-16 21:52:10 +04:00
commit 6b703db02b
69 changed files with 25886 additions and 0 deletions
+763
View File
@@ -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")
}