package main import ( "bytes" "encoding/json" "flag" "fmt" "log" "net/http" "os" "os/exec" "runtime" "sort" "time" "github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/disk" "github.com/shirou/gopsutil/v3/host" "github.com/shirou/gopsutil/v3/load" "github.com/shirou/gopsutil/v3/mem" pnet "github.com/shirou/gopsutil/v3/net" "github.com/shirou/gopsutil/v3/process" "gopkg.in/yaml.v3" ) // ── Config ──────────────────────────────────────────────────────────────────── type Config struct { APIURL string `yaml:"api_url"` Token string `yaml:"token"` Interval int `yaml:"interval"` Debug bool `yaml:"debug"` CPUThreshold float64 `yaml:"cpu_threshold"` // % para activar renice (default 90) ReniceEnabled bool `yaml:"renice_enabled"` // activar renice automático ReniceConsecutive int `yaml:"renice_consecutive"` // reportes consecutivos requeridos (default 2) } func loadConfig(path string) (*Config, error) { f, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("no se pudo leer %s: %w", path, err) } var cfg Config if err := yaml.Unmarshal(f, &cfg); err != nil { return nil, fmt.Errorf("config YAML inválido: %w", err) } if cfg.Interval <= 0 { cfg.Interval = 30 } if cfg.CPUThreshold <= 0 { cfg.CPUThreshold = 90 } if cfg.ReniceConsecutive <= 0 { cfg.ReniceConsecutive = 2 } return &cfg, nil } // ── Métricas ────────────────────────────────────────────────────────────────── type Metricas struct { Hostname string `json:"hostname"` OS string `json:"os"` Arch string `json:"arch"` Uptime uint64 `json:"uptime_seg"` UptimeStr string `json:"uptime"` RAM RAMInfo `json:"ram"` Swap SwapInfo `json:"swap"` CPU CPUInfo `json:"cpu"` Discos []DiscoInfo `json:"discos"` LoadAvg LoadInfo `json:"load_avg"` Red []RedInfo `json:"red"` TCPConns int `json:"tcp_conns"` TopProcs []ProcesoInfo `json:"top_procs"` ReportadoEn string `json:"reportado_en"` } type RAMInfo struct { TotalGB float64 `json:"total_gb"` UsadoGB float64 `json:"usado_gb"` LibreGB float64 `json:"libre_gb"` Porcentaje float64 `json:"porcentaje"` } type SwapInfo struct { TotalGB float64 `json:"total_gb"` UsadoGB float64 `json:"usado_gb"` Porcentaje float64 `json:"porcentaje"` } type CPUInfo struct { Nucleos int `json:"nucleos"` Porcentaje float64 `json:"porcentaje"` } type DiscoInfo struct { TotalGB float64 `json:"total_gb"` UsadoGB float64 `json:"usado_gb"` LibreGB float64 `json:"libre_gb"` Porcentaje float64 `json:"porcentaje"` Ruta string `json:"ruta"` } type LoadInfo struct { Load1 float64 `json:"load1"` Load5 float64 `json:"load5"` Load15 float64 `json:"load15"` } type RedInfo struct { Interface string `json:"interface"` BytesEnvMB float64 `json:"bytes_env_mb"` BytesRecMB float64 `json:"bytes_rec_mb"` PktEnv uint64 `json:"pkt_env"` PktRec uint64 `json:"pkt_rec"` } type ProcesoInfo struct { PID int32 `json:"pid"` Nombre string `json:"nombre"` CPU float64 `json:"cpu"` RAMMB float64 `json:"ram_mb"` } func round2(v float64) float64 { return float64(int(v*100)) / 100 } func uptimeStr(secs uint64) string { d := secs / 86400 h := (secs % 86400) / 3600 m := (secs % 3600) / 60 if d > 0 { return fmt.Sprintf("%dd %dh %dm", d, h, m) } return fmt.Sprintf("%dh %dm", h, m) } func collectMetrics() (*Metricas, error) { m := &Metricas{ OS: runtime.GOOS, Arch: runtime.GOARCH, ReportadoEn: time.Now().UTC().Format(time.RFC3339), } // Hostname if hn, err := os.Hostname(); err == nil { m.Hostname = hn } // Uptime + OS if info, err := host.Info(); err == nil { m.Uptime = info.Uptime m.UptimeStr = uptimeStr(info.Uptime) if info.Platform != "" { m.OS = info.Platform + " " + info.PlatformVersion } } // RAM if v, err := mem.VirtualMemory(); err == nil { gb := 1024.0 * 1024 * 1024 m.RAM = RAMInfo{ TotalGB: round2(float64(v.Total) / gb), UsadoGB: round2(float64(v.Used) / gb), LibreGB: round2(float64(v.Free) / gb), Porcentaje: round2(v.UsedPercent), } } // Swap if sv, err := mem.SwapMemory(); err == nil && sv.Total > 0 { gb := 1024.0 * 1024 * 1024 m.Swap = SwapInfo{ TotalGB: round2(float64(sv.Total) / gb), UsadoGB: round2(float64(sv.Used) / gb), Porcentaje: round2(sv.UsedPercent), } } // CPU m.CPU.Nucleos = runtime.NumCPU() if pcts, err := cpu.Percent(1*time.Second, false); err == nil && len(pcts) > 0 { m.CPU.Porcentaje = round2(pcts[0]) } // Discos — incluir todo filesystem con espacio real (>100 MB), sin duplicar mountpoints { gb := 1024.0 * 1024 * 1024 skipFS := map[string]bool{ "proc": true, "sysfs": true, "devtmpfs": true, "devpts": true, "cgroup": true, "cgroup2": true, "hugetlbfs": true, "mqueue": true, "pstore": true, "securityfs": true, "debugfs": true, "tracefs": true, "autofs": true, "fusectl": true, "configfs": true, "efivarfs": true, "bpf": true, "rpc_pipefs": true, "nsfs": true, "ramfs": true, } seen := map[string]bool{} parts, _ := disk.Partitions(true) // Asegurar que siempre se incluye la raíz type partEntry struct{ mountpoint, fstype string } entries := []partEntry{{"/", ""}} if runtime.GOOS == "windows" { entries = []partEntry{{"C:\\", ""}} } for _, p := range parts { entries = append(entries, partEntry{p.Mountpoint, p.Fstype}) } for _, e := range entries { if seen[e.mountpoint] || skipFS[e.fstype] { continue } d, err := disk.Usage(e.mountpoint) if err != nil || d.Total == 0 { continue } totalGB := float64(d.Total) / gb if totalGB < 0.1 { // ignorar < 100 MB (tmpfs pequeños, etc.) continue } seen[e.mountpoint] = true m.Discos = append(m.Discos, DiscoInfo{ TotalGB: round2(totalGB), UsadoGB: round2(float64(d.Used) / gb), LibreGB: round2(float64(d.Free) / gb), Porcentaje: round2(d.UsedPercent), Ruta: e.mountpoint, }) } } // Load average if la, err := load.Avg(); err == nil { m.LoadAvg = LoadInfo{Load1: round2(la.Load1), Load5: round2(la.Load5), Load15: round2(la.Load15)} } // Red (interfaces físicas, excluir loopback) if counters, err := pnet.IOCounters(true); err == nil { mb := 1024.0 * 1024 for _, c := range counters { if c.Name == "lo" || c.Name == "lo0" { continue } m.Red = append(m.Red, RedInfo{ Interface: c.Name, BytesEnvMB: round2(float64(c.BytesSent) / mb), BytesRecMB: round2(float64(c.BytesRecv) / mb), PktEnv: c.PacketsSent, PktRec: c.PacketsRecv, }) } } // TCP conexiones activas if conns, err := pnet.Connections("tcp"); err == nil { m.TCPConns = len(conns) } // Top 10 procesos: filtrar por RAM primero (barato), luego leer CPU solo en esos 10 if procs, err := process.Processes(); err == nil { type pd struct { proc *process.Process name string ramMB float64 } var lista []pd for _, p := range procs { name, _ := p.Name() if name == "" { continue } ramMB := 0.0 if mi, err := p.MemoryInfo(); err == nil && mi != nil { ramMB = round2(float64(mi.RSS) / (1024 * 1024)) } if ramMB < 1 { continue } lista = append(lista, pd{p, name, ramMB}) } sort.Slice(lista, func(i, j int) bool { return lista[i].ramMB > lista[j].ramMB }) limit := 10 if len(lista) < limit { limit = len(lista) } for _, d := range lista[:limit] { cpuPct, _ := d.proc.CPUPercent() // solo 10 lecturas, no cientos m.TopProcs = append(m.TopProcs, ProcesoInfo{d.proc.Pid, d.name, round2(cpuPct), d.ramMB}) } } return m, nil } // ── Reporte ─────────────────────────────────────────────────────────────────── type HeartbeatRequest struct { Token string `json:"token"` Metricas string `json:"metricas"` ReniceAction string `json:"renice_action,omitempty"` // descripción si se reniceó algo } func sendHeartbeat(cfg *Config, metricas *Metricas, reniceAction string) error { metJSON, err := json.Marshal(metricas) if err != nil { return err } payload := HeartbeatRequest{ Token: cfg.Token, Metricas: string(metJSON), ReniceAction: reniceAction, } body, _ := json.Marshal(payload) url := cfg.APIURL + "/agent/heartbeat" req, err := http.NewRequest("POST", url, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "usite-agent/1.0") client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("API respondió %d", resp.StatusCode) } return nil } // ── Main ────────────────────────────────────────────────────────────────────── func main() { configPath := flag.String("config", "agent.yml", "ruta al archivo de configuración") apiURL := flag.String("api-url", "", "URL del API (sobreescribe config)") token := flag.String("token", "", "token del servidor (sobreescribe config)") flag.Parse() cfg, err := loadConfig(*configPath) if err != nil { if *apiURL == "" || *token == "" { log.Fatalf("Error cargando config: %v\nUso: usite-agent --api-url=https://... --token=...", err) } cfg = &Config{APIURL: *apiURL, Token: *token, Interval: 30} } if *apiURL != "" { cfg.APIURL = *apiURL } if *token != "" { cfg.Token = *token } if cfg.APIURL == "" || cfg.Token == "" { log.Fatal("api_url y token son requeridos") } log.Printf("🚀 usite-agent iniciado | API: %s | Intervalo: %ds | Renice: %v (umbral %.0f%%, %d reportes)", cfg.APIURL, cfg.Interval, cfg.ReniceEnabled, cfg.CPUThreshold, cfg.ReniceConsecutive) ticker := time.NewTicker(time.Duration(cfg.Interval) * time.Second) defer ticker.Stop() consecAlto := 0 // contador de reportes consecutivos con CPU alta reportar := func() { m, err := collectMetrics() if err != nil { log.Printf("⚠️ Error recolectando métricas: %v", err) return } if cfg.Debug { b, _ := json.MarshalIndent(m, "", " ") log.Printf("📊 Métricas:\n%s", string(b)) } // ── Lógica de renice ────────────────────────────────────────────────── var reniceAction string if cfg.ReniceEnabled && m.CPU.Porcentaje >= cfg.CPUThreshold { consecAlto++ log.Printf("⚠️ CPU alta: %.1f%% (reporte %d/%d)", m.CPU.Porcentaje, consecAlto, cfg.ReniceConsecutive) if consecAlto >= cfg.ReniceConsecutive && len(m.TopProcs) > 0 { // Ordenar top_procs por CPU para encontrar el culpable real procs := make([]ProcesoInfo, len(m.TopProcs)) copy(procs, m.TopProcs) sort.Slice(procs, func(i, j int) bool { return procs[i].CPU > procs[j].CPU }) culpable := procs[0] if culpable.CPU > 0 && culpable.PID > 0 { cmd := exec.Command("renice", "-n", "19", "-p", fmt.Sprintf("%d", culpable.PID)) if rerr := cmd.Run(); rerr != nil { log.Printf("❌ renice falló para PID %d (%s): %v", culpable.PID, culpable.Nombre, rerr) } else { reniceAction = fmt.Sprintf("renice +19 aplicado a %s (PID %d, CPU %.1f%%)", culpable.Nombre, culpable.PID, culpable.CPU) log.Printf("🔧 %s", reniceAction) consecAlto = 0 // reiniciar tras actuar } } } } else { consecAlto = 0 } if err := sendHeartbeat(cfg, m, reniceAction); err != nil { log.Printf("⚠️ Error enviando heartbeat: %v", err) } else { log.Printf("✅ Heartbeat | RAM: %.1f%% | CPU: %.1f%% | TCP: %d | Procs: %d", m.RAM.Porcentaje, m.CPU.Porcentaje, m.TCPConns, len(m.TopProcs)) } } reportar() for range ticker.C { reportar() } }