Files
soft_usite/agent/main.go
T
Lizandro GuarnizoandCopilot d9d3f8a5eb feat: sistema de agente ligero para monitoreo de servidores
Backend:
- Servidor model: AgentToken, AgentLastSeen, MetricasJson nuevos campos
- AgentHeartbeat: endpoint público POST /agent/heartbeat (auth por token)
- GenerateAgentToken: endpoint protegido POST /app/servidor/:id/agent-token
- Ruta pública /agent/install.sh sirve el script de instalación

Agente Go (agent/):
- agent/main.go: binario independiente con gopsutil
  - Recolecta RAM, CPU, Disco, Uptime, OS, Load average
  - Lee agent.yml o flags --api-url / --token
  - Envía POST a /agent/heartbeat cada N segundos
  - Instala como servicio systemd via install.sh
- agent/go.mod: módulo independiente (usite-agent)
- agent/agent.yml.sample: configuración de ejemplo
- agent/install.sh: instalador en 1 curl para Linux (systemd)

Frontend servidor_dashboard.html:
- Cards: badge ● En línea / ● Fuera / ○ Sin agente
- Barras de progreso live: RAM%, CPU%, Disco%
- Valores: GB usado/total, núcleos, uptime, OS real
- Tiempo desde último reporte
- Botón 'Agente' en cada card → modal de configuración
- Modal agente: estado, token con copy, comando curl 1 línea,
  instalación manual/avanzada con collapse, métricas actuales

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-21 11:29:24 -05:00

261 lines
6.9 KiB
Go

package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"os"
"runtime"
"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"
"gopkg.in/yaml.v3"
)
// ── Config ────────────────────────────────────────────────────────────────────
type Config struct {
APIURL string `yaml:"api_url"` // https://admin.u-site.app
Token string `yaml:"token"` // token generado desde el panel
Interval int `yaml:"interval"` // segundos entre reportes (default: 30)
Debug bool `yaml:"debug"`
}
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
}
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"`
CPU CPUInfo `json:"cpu"`
Disco DiscoInfo `json:"disco"`
LoadAvg LoadInfo `json:"load_avg"`
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 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"`
}
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
if info, err := host.Info(); err == nil {
m.Uptime = info.Uptime
m.UptimeStr = uptimeStr(info.Uptime)
if info.OS != "" {
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),
}
}
// 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])
}
// Disco (raíz /)
root := "/"
if runtime.GOOS == "windows" {
root = "C:\\"
}
if d, err := disk.Usage(root); err == nil {
gb := 1024.0 * 1024 * 1024
m.Disco = DiscoInfo{
TotalGB: round2(float64(d.Total) / gb),
UsadoGB: round2(float64(d.Used) / gb),
LibreGB: round2(float64(d.Free) / gb),
Porcentaje: round2(d.UsedPercent),
Ruta: root,
}
}
// Load average (Linux/macOS)
if la, err := load.Avg(); err == nil {
m.LoadAvg = LoadInfo{Load1: round2(la.Load1), Load5: round2(la.Load5), Load15: round2(la.Load15)}
}
return m, nil
}
// ── Reporte ───────────────────────────────────────────────────────────────────
type HeartbeatRequest struct {
Token string `json:"token"`
Metricas string `json:"metricas"`
}
func sendHeartbeat(cfg *Config, metricas *Metricas) error {
metJSON, err := json.Marshal(metricas)
if err != nil {
return err
}
payload := HeartbeatRequest{
Token: cfg.Token,
Metricas: string(metJSON),
}
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 {
// Si no hay config pero se pasaron flags, usarlos directamente
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}
}
// Flags sobreescriben config
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", cfg.APIURL, cfg.Interval)
ticker := time.NewTicker(time.Duration(cfg.Interval) * time.Second)
defer ticker.Stop()
// Enviar inmediatamente al iniciar
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))
}
if err := sendHeartbeat(cfg, m); err != nil {
log.Printf("⚠️ Error enviando heartbeat: %v", err)
} else {
log.Printf("✅ Heartbeat enviado | RAM: %.1f%% | CPU: %.1f%% | Disco: %.1f%%",
m.RAM.Porcentaje, m.CPU.Porcentaje, m.Disco.Porcentaje)
}
}
reportar()
for range ticker.C {
reportar()
}
}