SQLite durch PostgreSQL + TimescaleDB ersetzt
- Backend DB komplett auf PostgreSQL 17 + TimescaleDB 2.25.1 umgestellt - pgx pure-Go Driver (kein CGO), modernc/sqlite entfernt - Hypertable 'metrics' fuer Time-Series Daten - Automatische Metrik-Extraktion bei jedem Heartbeat: CPU, RAM, Uptime, Disk, Network-Traffic, Gateway RTT/Loss - Retention Policy: 90 Tage, Compression nach 7 Tagen - Neue API-Endpoints: GET /metrics (raw) + GET /metrics/summary (aggregiert) - time_bucket() Aggregation (5min, 1h, etc.) - config.yaml: database-Block statt db_path - docs/POSTGRES_SETUP.md: Installationsanleitung - README aktualisiert
This commit is contained in:
parent
27d6492258
commit
5d7a3cbcc1
44
README.md
44
README.md
@ -177,6 +177,20 @@ daemon -f -p /var/run/rmm-agent.pid -o /usr/local/rmm/rmm-agent.log \
|
||||
| POST | `/api/v1/agents/{id}/major-update` | Major-Upgrade auf Zielversion |
|
||||
| POST | `/api/v1/agents/{id}/reboot` | Reboot ausfuehren |
|
||||
|
||||
### Metriken (TimescaleDB)
|
||||
|
||||
| Methode | Endpoint | Beschreibung |
|
||||
|---------|----------|-------------|
|
||||
| GET | `/api/v1/agents/{id}/metrics` | Rohe Metriken abfragen (`?metric=X&from=&to=&limit=N`) |
|
||||
| GET | `/api/v1/agents/{id}/metrics/summary` | Aggregierte Metriken (`?metric=X&bucket=5+minutes&from=&to=`) |
|
||||
|
||||
Metriken werden automatisch bei jedem Heartbeat geschrieben (alle 60s):
|
||||
`cpu_usage`, `memory_used_percent`, `memory_used_bytes`, `memory_total_bytes`,
|
||||
`uptime_seconds`, `disk_used_percent`, `disk_used_bytes`, `interface_rx_bytes`,
|
||||
`interface_tx_bytes`, `gateway_rtt_ms`, `gateway_loss_percent`
|
||||
|
||||
Retention: 90 Tage (Raw), Compression nach 7 Tagen.
|
||||
|
||||
### Config-Backup
|
||||
|
||||
| Methode | Endpoint | Beschreibung |
|
||||
@ -337,6 +351,30 @@ curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" -X DELETE \
|
||||
https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/wireguard/peers/89c10e2d-148e-11f1-a7af-7c5a1c6c7b73
|
||||
```
|
||||
|
||||
### Metriken-Beispiele
|
||||
|
||||
```bash
|
||||
# CPU-Auslastung der letzten 24h
|
||||
curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \
|
||||
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/metrics?metric=cpu_usage"
|
||||
|
||||
# RAM-Nutzung mit Zeitraum
|
||||
curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \
|
||||
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/metrics?metric=memory_used_percent&from=2026-02-28T00:00:00Z&to=2026-02-28T23:59:59Z"
|
||||
|
||||
# 5-Minuten-Durchschnitte der CPU
|
||||
curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \
|
||||
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/metrics/summary?metric=cpu_usage&bucket=5+minutes"
|
||||
|
||||
# Stuendliche RAM-Zusammenfassung
|
||||
curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \
|
||||
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/metrics/summary?metric=memory_used_percent&bucket=1+hour"
|
||||
|
||||
# Alle Metriken eines Agents (alle Typen, letzte 100)
|
||||
curl -sk -H "X-API-Key: 01532e5a7c9e70bf2757df77a2f5b9b9" \
|
||||
"https://192.168.85.13:8443/api/v1/agents/6beb8dfd38ecf03eab6c0f21b4ac7bee/metrics?limit=100"
|
||||
```
|
||||
|
||||
### Backup-Beispiele
|
||||
|
||||
```bash
|
||||
@ -476,7 +514,7 @@ rmm/
|
||||
│ ├── config/
|
||||
│ │ └── config.go # YAML Config Loader
|
||||
│ ├── db/
|
||||
│ │ └── sqlite.go # SQLite (Agent + Systemdaten)
|
||||
│ │ └── postgres.go # PostgreSQL + TimescaleDB (Agents, Backups, Metriken)
|
||||
│ ├── models/
|
||||
│ │ ├── agent.go # Agent, Register/Heartbeat Structs
|
||||
│ │ └── system.go # SystemData, alle Collector-Structs
|
||||
@ -485,6 +523,7 @@ rmm/
|
||||
│ │ ├── tunnel_proxy.go # Tunnel + Exec + Update REST Endpoints
|
||||
│ │ ├── backup.go # Config-Backup Endpoints (trigger, list, download, diff)
|
||||
│ │ ├── wireguard.go # WireGuard Peer API Endpoints
|
||||
│ │ ├── metrics.go # Metriken-Abfrage Endpoints (raw + summary)
|
||||
│ │ └── middleware.go # API-Key Auth, Logging
|
||||
│ └── ws/
|
||||
│ ├── handler.go # WebSocket Upgrade, Connection R/W Pumps
|
||||
@ -495,7 +534,8 @@ rmm/
|
||||
|
||||
## Technische Details
|
||||
|
||||
- **CGO-frei**: `CGO_ENABLED=0`, pure-Go SQLite (modernc.org/sqlite)
|
||||
- **PostgreSQL + TimescaleDB**: Agent-Registry, Config-Backups, Time-Series Metriken (pgx Driver)
|
||||
- **CGO-frei**: Kein CGO noetig (pure-Go PostgreSQL Driver)
|
||||
- **Cross-Compilation**: Backend linux/amd64, Agent freebsd/amd64
|
||||
- **TLS**: Self-signed Zertifikate (automatisch generiert)
|
||||
- **WebSocket**: gorilla/websocket, Binary Messages fuer Tunnel-Daten
|
||||
|
||||
83
backend/api/metrics.go
Normal file
83
backend/api/metrics.go
Normal file
@ -0,0 +1,83 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GET /api/v1/agents/{id}/metrics — Metriken abfragen
|
||||
func (h *Handler) getMetrics() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("id")
|
||||
metric := r.URL.Query().Get("metric")
|
||||
|
||||
// Zeitraum (default: letzte 24h)
|
||||
from := time.Now().Add(-24 * time.Hour)
|
||||
to := time.Now()
|
||||
|
||||
if f := r.URL.Query().Get("from"); f != "" {
|
||||
if t, err := time.Parse(time.RFC3339, f); err == nil {
|
||||
from = t
|
||||
}
|
||||
}
|
||||
if t := r.URL.Query().Get("to"); t != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, t); err == nil {
|
||||
to = parsed
|
||||
}
|
||||
}
|
||||
|
||||
limit := 1000
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
|
||||
results, err := h.db.GetMetrics(agentID, metric, from, to, limit)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Metriken laden fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, results)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/{id}/metrics/summary — Aggregierte Metriken (Durchschnitt/Min/Max)
|
||||
func (h *Handler) getMetricsSummary() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
agentID := r.PathValue("id")
|
||||
metric := r.URL.Query().Get("metric")
|
||||
|
||||
if metric == "" {
|
||||
writeError(w, http.StatusBadRequest, "Parameter 'metric' erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
bucket := r.URL.Query().Get("bucket")
|
||||
if bucket == "" {
|
||||
bucket = "5 minutes"
|
||||
}
|
||||
|
||||
from := time.Now().Add(-24 * time.Hour)
|
||||
to := time.Now()
|
||||
|
||||
if f := r.URL.Query().Get("from"); f != "" {
|
||||
if t, err := time.Parse(time.RFC3339, f); err == nil {
|
||||
from = t
|
||||
}
|
||||
}
|
||||
if t := r.URL.Query().Get("to"); t != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, t); err == nil {
|
||||
to = parsed
|
||||
}
|
||||
}
|
||||
|
||||
results, err := h.db.GetMetricsSummary(agentID, metric, bucket, from, to)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Metriken laden fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, results)
|
||||
}
|
||||
}
|
||||
@ -26,6 +26,10 @@ func (h *Handler) setupTunnelRoutes(mux *http.ServeMux, hub *ws.Hub) {
|
||||
mux.HandleFunc("PUT /api/v1/agents/{id}/proxy/{tunnel_id}/", h.httpProxy(hub))
|
||||
mux.HandleFunc("DELETE /api/v1/agents/{id}/proxy/{tunnel_id}/", h.httpProxy(hub))
|
||||
|
||||
// Metrics-Endpoints
|
||||
mux.HandleFunc("GET /api/v1/agents/{id}/metrics", h.getMetrics())
|
||||
mux.HandleFunc("GET /api/v1/agents/{id}/metrics/summary", h.getMetricsSummary())
|
||||
|
||||
// WireGuard-Endpoints
|
||||
mux.HandleFunc("POST /api/v1/agents/{id}/wireguard/peers", h.wgAddPeer(hub))
|
||||
mux.HandleFunc("GET /api/v1/agents/{id}/wireguard/peers", h.wgListPeers(hub))
|
||||
|
||||
@ -3,15 +3,17 @@ package config
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/cynfo/rmm-backend/db"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string `yaml:"listen_addr"`
|
||||
TLSCert string `yaml:"tls_cert"`
|
||||
TLSKey string `yaml:"tls_key"`
|
||||
APIKeys []string `yaml:"api_keys"`
|
||||
DBPath string `yaml:"db_path"`
|
||||
ListenAddr string `yaml:"listen_addr"`
|
||||
TLSCert string `yaml:"tls_cert"`
|
||||
TLSKey string `yaml:"tls_key"`
|
||||
APIKeys []string `yaml:"api_keys"`
|
||||
Database db.DBConfig `yaml:"database"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
@ -19,7 +21,14 @@ func Load(path string) (*Config, error) {
|
||||
ListenAddr: ":8443",
|
||||
TLSCert: "certs/server.crt",
|
||||
TLSKey: "certs/server.key",
|
||||
DBPath: "rmm.db",
|
||||
Database: db.DBConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: 5432,
|
||||
User: "rmm",
|
||||
Password: "",
|
||||
DBName: "rmm",
|
||||
SSLMode: "disable",
|
||||
},
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
@ -41,8 +50,11 @@ func Load(path string) (*Config, error) {
|
||||
if v := os.Getenv("RMM_TLS_KEY"); v != "" {
|
||||
cfg.TLSKey = v
|
||||
}
|
||||
if v := os.Getenv("RMM_DB_PATH"); v != "" {
|
||||
cfg.DBPath = v
|
||||
if v := os.Getenv("RMM_DB_HOST"); v != "" {
|
||||
cfg.Database.Host = v
|
||||
}
|
||||
if v := os.Getenv("RMM_DB_PASSWORD"); v != "" {
|
||||
cfg.Database.Password = v
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
|
||||
633
backend/db/postgres.go
Normal file
633
backend/db/postgres.go
Normal file
@ -0,0 +1,633 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cynfo/rmm-backend/models"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type DBConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
DBName string `yaml:"dbname"`
|
||||
SSLMode string `yaml:"sslmode"`
|
||||
}
|
||||
|
||||
func New(cfg DBConfig) (*Database, error) {
|
||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
|
||||
cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode)
|
||||
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Datenbank oeffnen: %w", err)
|
||||
}
|
||||
|
||||
// Connection Pool konfigurieren
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
// Verbindung testen
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return nil, fmt.Errorf("Datenbank-Verbindung fehlgeschlagen: %w", err)
|
||||
}
|
||||
|
||||
d := &Database{db: db}
|
||||
if err := d.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("Migration fehlgeschlagen: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("PostgreSQL verbunden: %s@%s:%d/%s", cfg.User, cfg.Host, cfg.Port, cfg.DBName)
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *Database) migrate() error {
|
||||
// Relationale Tabellen
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
opnsense_version TEXT NOT NULL DEFAULT '',
|
||||
registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_heartbeat TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_data (
|
||||
agent_id TEXT PRIMARY KEY REFERENCES agents(id) ON DELETE CASCADE,
|
||||
data_json JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_backups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
hash TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
config_xml TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_backups_agent ON config_backups(agent_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_config_backups_hash ON config_backups(agent_id, hash);
|
||||
`
|
||||
|
||||
if _, err := d.db.Exec(schema); err != nil {
|
||||
return fmt.Errorf("Schema anlegen: %w", err)
|
||||
}
|
||||
|
||||
// Metrics Hypertable
|
||||
metricsSchema := `
|
||||
CREATE TABLE IF NOT EXISTS metrics (
|
||||
time TIMESTAMPTZ NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
metric TEXT NOT NULL,
|
||||
value DOUBLE PRECISION NOT NULL,
|
||||
tags JSONB
|
||||
);
|
||||
`
|
||||
if _, err := d.db.Exec(metricsSchema); err != nil {
|
||||
return fmt.Errorf("Metrics-Tabelle anlegen: %w", err)
|
||||
}
|
||||
|
||||
// In Hypertable umwandeln (ignoriert wenn schon Hypertable)
|
||||
var isHypertable bool
|
||||
err := d.db.QueryRow(`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM timescaledb_information.hypertables
|
||||
WHERE hypertable_name = 'metrics'
|
||||
)
|
||||
`).Scan(&isHypertable)
|
||||
|
||||
if err != nil || !isHypertable {
|
||||
_, err = d.db.Exec(`SELECT create_hypertable('metrics', 'time', if_not_exists => TRUE)`)
|
||||
if err != nil {
|
||||
log.Printf("Hypertable-Warnung (ggf. schon vorhanden): %v", err)
|
||||
} else {
|
||||
log.Println("Metrics Hypertable erstellt")
|
||||
}
|
||||
}
|
||||
|
||||
// Indices fuer Metrics
|
||||
d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_metrics_agent_metric ON metrics(agent_id, metric, time DESC)`)
|
||||
|
||||
// Retention Policy (90 Tage)
|
||||
d.setupRetention()
|
||||
|
||||
// Compression Policy
|
||||
d.setupCompression()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) setupRetention() {
|
||||
// Pruefen ob schon eine Policy existiert
|
||||
var count int
|
||||
d.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM timescaledb_information.jobs
|
||||
WHERE hypertable_name = 'metrics' AND proc_name = 'policy_retention'
|
||||
`).Scan(&count)
|
||||
|
||||
if count == 0 {
|
||||
_, err := d.db.Exec(`SELECT add_retention_policy('metrics', INTERVAL '90 days')`)
|
||||
if err != nil {
|
||||
log.Printf("Retention Policy Warnung: %v", err)
|
||||
} else {
|
||||
log.Println("Retention Policy: 90 Tage fuer metrics")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Database) setupCompression() {
|
||||
// Compression aktivieren
|
||||
d.db.Exec(`
|
||||
ALTER TABLE metrics SET (
|
||||
timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'agent_id, metric',
|
||||
timescaledb.compress_orderby = 'time DESC'
|
||||
)
|
||||
`)
|
||||
|
||||
var count int
|
||||
d.db.QueryRow(`
|
||||
SELECT COUNT(*) FROM timescaledb_information.jobs
|
||||
WHERE hypertable_name = 'metrics' AND proc_name = 'policy_compression'
|
||||
`).Scan(&count)
|
||||
|
||||
if count == 0 {
|
||||
_, err := d.db.Exec(`SELECT add_compression_policy('metrics', INTERVAL '7 days')`)
|
||||
if err != nil {
|
||||
log.Printf("Compression Policy Warnung: %v", err)
|
||||
} else {
|
||||
log.Println("Compression Policy: nach 7 Tagen fuer metrics")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Database) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
// --- Agent Methoden ---
|
||||
|
||||
func (d *Database) RegisterAgent(agent *models.Agent) error {
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO agents (id, name, hostname, ip, opnsense_version, registered_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name=EXCLUDED.name,
|
||||
hostname=EXCLUDED.hostname,
|
||||
ip=EXCLUDED.ip,
|
||||
opnsense_version=EXCLUDED.opnsense_version
|
||||
`, agent.ID, agent.Name, agent.Hostname, agent.IP, agent.OPNsenseVersion, agent.RegisteredAt.UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) SaveSystemData(agentID string, data *models.SystemData) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSON Marshal: %w", err)
|
||||
}
|
||||
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Heartbeat aktualisieren
|
||||
res, err := tx.Exec("UPDATE agents SET last_heartbeat = $1 WHERE id = $2", now, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("Agent %s nicht gefunden", agentID)
|
||||
}
|
||||
|
||||
// Systemdaten upsert
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO system_data (agent_id, data_json, updated_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT(agent_id) DO UPDATE SET
|
||||
data_json=EXCLUDED.data_json,
|
||||
updated_at=EXCLUDED.updated_at
|
||||
`, agentID, string(jsonData), now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Metriken extrahieren und schreiben
|
||||
if err := d.writeMetrics(tx, agentID, now, data); err != nil {
|
||||
log.Printf("Metriken schreiben Warnung: %v", err)
|
||||
// Kein Abbruch — Systemdaten sind wichtiger
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// writeMetrics extrahiert Metriken aus den Systemdaten und schreibt sie in die Hypertable
|
||||
func (d *Database) writeMetrics(tx *sql.Tx, agentID string, ts time.Time, data *models.SystemData) error {
|
||||
stmt, err := tx.Prepare(`INSERT INTO metrics (time, agent_id, metric, value, tags) VALUES ($1, $2, $3, $4, $5)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
// CPU
|
||||
if data.CPU.UsagePercent > 0 {
|
||||
stmt.Exec(ts, agentID, "cpu_usage", data.CPU.UsagePercent, nil)
|
||||
}
|
||||
|
||||
// Memory
|
||||
if data.Memory.TotalBytes > 0 {
|
||||
stmt.Exec(ts, agentID, "memory_total_bytes", float64(data.Memory.TotalBytes), nil)
|
||||
stmt.Exec(ts, agentID, "memory_used_bytes", float64(data.Memory.UsedBytes), nil)
|
||||
usedPercent := float64(data.Memory.UsedBytes) / float64(data.Memory.TotalBytes) * 100
|
||||
stmt.Exec(ts, agentID, "memory_used_percent", usedPercent, nil)
|
||||
}
|
||||
|
||||
// Uptime
|
||||
if data.UptimeSeconds > 0 {
|
||||
stmt.Exec(ts, agentID, "uptime_seconds", float64(data.UptimeSeconds), nil)
|
||||
}
|
||||
|
||||
// Disks
|
||||
for _, disk := range data.Disks {
|
||||
if disk.TotalBytes > 0 {
|
||||
usedPercent := float64(disk.UsedBytes) / float64(disk.TotalBytes) * 100
|
||||
tags, _ := json.Marshal(map[string]string{"mountpoint": disk.MountPoint, "dataset": disk.Filesystem})
|
||||
stmt.Exec(ts, agentID, "disk_used_percent", usedPercent, string(tags))
|
||||
stmt.Exec(ts, agentID, "disk_used_bytes", float64(disk.UsedBytes), string(tags))
|
||||
}
|
||||
}
|
||||
|
||||
// Network Interfaces
|
||||
for _, iface := range data.NetworkInterfaces {
|
||||
if iface.RxBytes > 0 || iface.TxBytes > 0 {
|
||||
tags, _ := json.Marshal(map[string]string{"interface": iface.Name})
|
||||
stmt.Exec(ts, agentID, "interface_rx_bytes", float64(iface.RxBytes), string(tags))
|
||||
stmt.Exec(ts, agentID, "interface_tx_bytes", float64(iface.TxBytes), string(tags))
|
||||
}
|
||||
}
|
||||
|
||||
// Gateways (Loss und Delay sind Strings wie "0.0%" und "1.2ms")
|
||||
for _, gw := range data.Gateways {
|
||||
if gw.Delay != "" {
|
||||
tags, _ := json.Marshal(map[string]string{"gateway": gw.Name})
|
||||
// Delay parsen ("1.2ms" -> 1.2)
|
||||
delayStr := strings.TrimSuffix(strings.TrimSpace(gw.Delay), "ms")
|
||||
if delay, err := strconv.ParseFloat(delayStr, 64); err == nil {
|
||||
stmt.Exec(ts, agentID, "gateway_rtt_ms", delay, string(tags))
|
||||
}
|
||||
// Loss parsen ("0.0%" -> 0.0)
|
||||
lossStr := strings.TrimSuffix(strings.TrimSpace(gw.Loss), "%")
|
||||
if loss, err := strconv.ParseFloat(lossStr, 64); err == nil {
|
||||
stmt.Exec(ts, agentID, "gateway_loss_percent", loss, string(tags))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAgents() ([]models.Agent, error) {
|
||||
rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var agents []models.Agent
|
||||
for rows.Next() {
|
||||
var a models.Agent
|
||||
var lastHB sql.NullTime
|
||||
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastHB.Valid {
|
||||
a.LastHeartbeat = &lastHB.Time
|
||||
}
|
||||
agents = append(agents, a)
|
||||
}
|
||||
if agents == nil {
|
||||
agents = []models.Agent{}
|
||||
}
|
||||
return agents, rows.Err()
|
||||
}
|
||||
|
||||
func (d *Database) GetAgentByHostname(hostname string) (*models.Agent, error) {
|
||||
var a models.Agent
|
||||
var lastHB sql.NullTime
|
||||
|
||||
err := d.db.QueryRow(
|
||||
"SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents WHERE hostname = $1 LIMIT 1",
|
||||
hostname,
|
||||
).Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lastHB.Valid {
|
||||
a.LastHeartbeat = &lastHB.Time
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error) {
|
||||
var a models.Agent
|
||||
var lastHB sql.NullTime
|
||||
|
||||
err := d.db.QueryRow(
|
||||
"SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents WHERE id = $1",
|
||||
id,
|
||||
).Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, &a.RegisteredAt, &lastHB)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if lastHB.Valid {
|
||||
a.LastHeartbeat = &lastHB.Time
|
||||
}
|
||||
|
||||
// Systemdaten laden
|
||||
var dataJSON sql.NullString
|
||||
err = d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = $1", id).Scan(&dataJSON)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var sysData *models.SystemData
|
||||
if dataJSON.Valid {
|
||||
sysData = &models.SystemData{}
|
||||
if err := json.Unmarshal([]byte(dataJSON.String), sysData); err != nil {
|
||||
return &a, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &a, sysData, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetSystemData(agentID string) (*models.SystemData, error) {
|
||||
var dataJSON string
|
||||
err := d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = $1", agentID).Scan(&dataJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data models.SystemData
|
||||
if err := json.Unmarshal([]byte(dataJSON), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteAgent(id string) (bool, error) {
|
||||
res, err := d.db.Exec("DELETE FROM agents WHERE id = $1", id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
// --- Config Backup Methoden ---
|
||||
|
||||
func (d *Database) SaveConfigBackup(agentID, hash string, size int, configXML string) (int64, bool, error) {
|
||||
var existingID int64
|
||||
err := d.db.QueryRow("SELECT id FROM config_backups WHERE agent_id = $1 AND hash = $2 LIMIT 1", agentID, hash).Scan(&existingID)
|
||||
if err == nil {
|
||||
return existingID, false, nil
|
||||
}
|
||||
|
||||
var id int64
|
||||
err = d.db.QueryRow(
|
||||
"INSERT INTO config_backups (agent_id, hash, size, config_xml) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
agentID, hash, size, configXML,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("Backup speichern: %w", err)
|
||||
}
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetConfigBackups(agentID string, limit int) ([]map[string]interface{}, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := d.db.Query(
|
||||
"SELECT id, hash, size, created_at FROM config_backups WHERE agent_id = $1 ORDER BY created_at DESC LIMIT $2",
|
||||
agentID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var backups []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var hash string
|
||||
var size int
|
||||
var createdAt time.Time
|
||||
if err := rows.Scan(&id, &hash, &size, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
backups = append(backups, map[string]interface{}{
|
||||
"id": id,
|
||||
"hash": hash,
|
||||
"size": size,
|
||||
"created_at": createdAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
if backups == nil {
|
||||
backups = []map[string]interface{}{}
|
||||
}
|
||||
return backups, rows.Err()
|
||||
}
|
||||
|
||||
func (d *Database) GetConfigBackup(agentID string, backupID int64) (map[string]interface{}, error) {
|
||||
var hash, configXML string
|
||||
var size int
|
||||
var id int64
|
||||
var createdAt time.Time
|
||||
|
||||
err := d.db.QueryRow(
|
||||
"SELECT id, hash, size, config_xml, created_at FROM config_backups WHERE agent_id = $1 AND id = $2",
|
||||
agentID, backupID,
|
||||
).Scan(&id, &hash, &size, &configXML, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": id,
|
||||
"hash": hash,
|
||||
"size": size,
|
||||
"config_xml": configXML,
|
||||
"created_at": createdAt.Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetLatestConfigBackup(agentID string) (map[string]interface{}, error) {
|
||||
var hash, configXML string
|
||||
var size int
|
||||
var id int64
|
||||
var createdAt time.Time
|
||||
|
||||
err := d.db.QueryRow(
|
||||
"SELECT id, hash, size, config_xml, created_at FROM config_backups WHERE agent_id = $1 ORDER BY created_at DESC LIMIT 1",
|
||||
agentID,
|
||||
).Scan(&id, &hash, &size, &configXML, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": id,
|
||||
"hash": hash,
|
||||
"size": size,
|
||||
"config_xml": configXML,
|
||||
"created_at": createdAt.Format(time.RFC3339),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) DeleteConfigBackup(agentID string, backupID int64) (bool, error) {
|
||||
res, err := d.db.Exec("DELETE FROM config_backups WHERE agent_id = $1 AND id = $2", agentID, backupID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
// --- Metrics Abfragen ---
|
||||
|
||||
// GetMetrics holt Metriken fuer einen Agent in einem Zeitraum
|
||||
func (d *Database) GetMetrics(agentID, metric string, from, to time.Time, limit int) ([]map[string]interface{}, error) {
|
||||
if limit <= 0 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
query := `SELECT time, metric, value, tags FROM metrics
|
||||
WHERE agent_id = $1 AND time >= $2 AND time <= $3`
|
||||
args := []interface{}{agentID, from, to}
|
||||
|
||||
if metric != "" {
|
||||
query += " AND metric = $4"
|
||||
args = append(args, metric)
|
||||
}
|
||||
|
||||
query += " ORDER BY time DESC LIMIT $" + fmt.Sprintf("%d", len(args)+1)
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := d.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var ts time.Time
|
||||
var m string
|
||||
var v float64
|
||||
var tags sql.NullString
|
||||
if err := rows.Scan(&ts, &m, &v, &tags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry := map[string]interface{}{
|
||||
"time": ts.Format(time.RFC3339),
|
||||
"metric": m,
|
||||
"value": v,
|
||||
}
|
||||
if tags.Valid {
|
||||
var t map[string]interface{}
|
||||
json.Unmarshal([]byte(tags.String), &t)
|
||||
entry["tags"] = t
|
||||
}
|
||||
results = append(results, entry)
|
||||
}
|
||||
if results == nil {
|
||||
results = []map[string]interface{}{}
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// GetMetricsSummary holt aggregierte Metriken (avg/min/max) in Zeitbuckts
|
||||
func (d *Database) GetMetricsSummary(agentID, metric, bucket string, from, to time.Time) ([]map[string]interface{}, error) {
|
||||
if bucket == "" {
|
||||
bucket = "5 minutes"
|
||||
}
|
||||
|
||||
rows, err := d.db.Query(`
|
||||
SELECT time_bucket($1::interval, time) AS t,
|
||||
AVG(value) AS avg_value,
|
||||
MIN(value) AS min_value,
|
||||
MAX(value) AS max_value,
|
||||
COUNT(*) AS samples
|
||||
FROM metrics
|
||||
WHERE agent_id = $2 AND metric = $3 AND time >= $4 AND time <= $5
|
||||
GROUP BY t
|
||||
ORDER BY t DESC
|
||||
`, bucket, agentID, metric, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var t time.Time
|
||||
var avg, min, max float64
|
||||
var samples int
|
||||
if err := rows.Scan(&t, &avg, &min, &max, &samples); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, map[string]interface{}{
|
||||
"time": t.Format(time.RFC3339),
|
||||
"avg": avg,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"samples": samples,
|
||||
})
|
||||
}
|
||||
if results == nil {
|
||||
results = []map[string]interface{}{}
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
@ -1,365 +0,0 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cynfo/rmm-backend/models"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func New(dbPath string) (*Database, error) {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Datenbank oeffnen: %w", err)
|
||||
}
|
||||
|
||||
// WAL-Modus fuer bessere Performance
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d := &Database{db: db}
|
||||
if err := d.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("Migration fehlgeschlagen: %w", err)
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *Database) migrate() error {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
opnsense_version TEXT NOT NULL DEFAULT '',
|
||||
registered_at TEXT NOT NULL,
|
||||
last_heartbeat TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_backups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
agent_id TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
config_xml TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_backups_agent ON config_backups(agent_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_config_backups_hash ON config_backups(agent_id, hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_data (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
data_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
|
||||
);
|
||||
`
|
||||
_, err := d.db.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
// RegisterAgent - Neuen Agent registrieren oder bestehenden aktualisieren
|
||||
func (d *Database) RegisterAgent(agent *models.Agent) error {
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO agents (id, name, hostname, ip, opnsense_version, registered_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
hostname=excluded.hostname,
|
||||
ip=excluded.ip,
|
||||
opnsense_version=excluded.opnsense_version
|
||||
`, agent.ID, agent.Name, agent.Hostname, agent.IP, agent.OPNsenseVersion, agent.RegisteredAt.UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveSystemData - Systemdaten speichern und Heartbeat aktualisieren
|
||||
func (d *Database) SaveSystemData(agentID string, data *models.SystemData) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSON Marshal: %w", err)
|
||||
}
|
||||
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Heartbeat Zeitstempel aktualisieren
|
||||
res, err := tx.Exec("UPDATE agents SET last_heartbeat = ? WHERE id = ?", now, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("Agent %s nicht gefunden", agentID)
|
||||
}
|
||||
|
||||
// Systemdaten upsert
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO system_data (agent_id, data_json, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(agent_id) DO UPDATE SET
|
||||
data_json=excluded.data_json,
|
||||
updated_at=excluded.updated_at
|
||||
`, agentID, string(jsonData), now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetAgents - Alle Agents auflisten
|
||||
func (d *Database) GetAgents() ([]models.Agent, error) {
|
||||
rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var agents []models.Agent
|
||||
for rows.Next() {
|
||||
var a models.Agent
|
||||
var regAt string
|
||||
var lastHB sql.NullString
|
||||
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, ®At, &lastHB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.RegisteredAt, _ = time.Parse(time.RFC3339, regAt)
|
||||
if lastHB.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, lastHB.String)
|
||||
a.LastHeartbeat = &t
|
||||
}
|
||||
agents = append(agents, a)
|
||||
}
|
||||
return agents, rows.Err()
|
||||
}
|
||||
|
||||
// GetAgent - Einzelnen Agent mit Systemdaten
|
||||
func (d *Database) GetAgentByHostname(hostname string) (*models.Agent, error) {
|
||||
var a models.Agent
|
||||
var regAt string
|
||||
var lastHB sql.NullString
|
||||
|
||||
err := d.db.QueryRow("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents WHERE hostname = ? LIMIT 1", hostname).
|
||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, ®At, &lastHB)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.RegisteredAt, _ = time.Parse(time.RFC3339, regAt)
|
||||
if lastHB.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, lastHB.String)
|
||||
a.LastHeartbeat = &t
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error) {
|
||||
var a models.Agent
|
||||
var regAt string
|
||||
var lastHB sql.NullString
|
||||
|
||||
err := d.db.QueryRow("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents WHERE id = ?", id).
|
||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, ®At, &lastHB)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
a.RegisteredAt, _ = time.Parse(time.RFC3339, regAt)
|
||||
if lastHB.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, lastHB.String)
|
||||
a.LastHeartbeat = &t
|
||||
}
|
||||
|
||||
// Systemdaten laden
|
||||
var dataJSON sql.NullString
|
||||
err = d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = ?", id).Scan(&dataJSON)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var sysData *models.SystemData
|
||||
if dataJSON.Valid {
|
||||
sysData = &models.SystemData{}
|
||||
if err := json.Unmarshal([]byte(dataJSON.String), sysData); err != nil {
|
||||
return &a, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &a, sysData, nil
|
||||
}
|
||||
|
||||
// GetSystemData - Nur Systemdaten eines Agents
|
||||
func (d *Database) GetSystemData(agentID string) (*models.SystemData, error) {
|
||||
var dataJSON string
|
||||
err := d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = ?", agentID).Scan(&dataJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data models.SystemData
|
||||
if err := json.Unmarshal([]byte(dataJSON), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// --- Config Backup Methoden ---
|
||||
|
||||
// SaveConfigBackup speichert ein Config-Backup (dedupliziert nach Hash)
|
||||
func (d *Database) SaveConfigBackup(agentID, hash string, size int, configXML string) (int64, bool, error) {
|
||||
// Pruefen ob ein Backup mit gleichem Hash schon existiert
|
||||
var existingID int64
|
||||
err := d.db.QueryRow("SELECT id FROM config_backups WHERE agent_id = ? AND hash = ? LIMIT 1", agentID, hash).Scan(&existingID)
|
||||
if err == nil {
|
||||
// Gleicher Hash existiert bereits — kein neues Backup noetig
|
||||
return existingID, false, nil
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
res, err := d.db.Exec(
|
||||
"INSERT INTO config_backups (agent_id, hash, size, config_xml, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||
agentID, hash, size, configXML, now,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("Backup speichern: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
// GetConfigBackups listet alle Backups eines Agents
|
||||
func (d *Database) GetConfigBackups(agentID string, limit int) ([]map[string]interface{}, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := d.db.Query(
|
||||
"SELECT id, hash, size, created_at FROM config_backups WHERE agent_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||
agentID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var backups []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var hash, createdAt string
|
||||
var size int
|
||||
if err := rows.Scan(&id, &hash, &size, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
backups = append(backups, map[string]interface{}{
|
||||
"id": id,
|
||||
"hash": hash,
|
||||
"size": size,
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if backups == nil {
|
||||
backups = []map[string]interface{}{}
|
||||
}
|
||||
return backups, rows.Err()
|
||||
}
|
||||
|
||||
// GetConfigBackup holt ein einzelnes Backup inkl. config_xml
|
||||
func (d *Database) GetConfigBackup(agentID string, backupID int64) (map[string]interface{}, error) {
|
||||
var hash, configXML, createdAt string
|
||||
var size int
|
||||
var id int64
|
||||
err := d.db.QueryRow(
|
||||
"SELECT id, hash, size, config_xml, created_at FROM config_backups WHERE agent_id = ? AND id = ?",
|
||||
agentID, backupID,
|
||||
).Scan(&id, &hash, &size, &configXML, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": id,
|
||||
"hash": hash,
|
||||
"size": size,
|
||||
"config_xml": configXML,
|
||||
"created_at": createdAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetLatestConfigBackup holt das neueste Backup
|
||||
func (d *Database) GetLatestConfigBackup(agentID string) (map[string]interface{}, error) {
|
||||
var hash, configXML, createdAt string
|
||||
var size int
|
||||
var id int64
|
||||
err := d.db.QueryRow(
|
||||
"SELECT id, hash, size, config_xml, created_at FROM config_backups WHERE agent_id = ? ORDER BY created_at DESC LIMIT 1",
|
||||
agentID,
|
||||
).Scan(&id, &hash, &size, &configXML, &createdAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": id,
|
||||
"hash": hash,
|
||||
"size": size,
|
||||
"config_xml": configXML,
|
||||
"created_at": createdAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteConfigBackup loescht ein einzelnes Backup
|
||||
func (d *Database) DeleteConfigBackup(agentID string, backupID int64) (bool, error) {
|
||||
res, err := d.db.Exec("DELETE FROM config_backups WHERE agent_id = ? AND id = ?", agentID, backupID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
return rows > 0, nil
|
||||
}
|
||||
|
||||
// DeleteAgent - Agent und zugehoerige Daten loeschen
|
||||
func (d *Database) DeleteAgent(id string) (bool, error) {
|
||||
// Foreign Key Cascade aktivieren
|
||||
d.db.Exec("PRAGMA foreign_keys = ON")
|
||||
|
||||
res, err := d.db.Exec("DELETE FROM agents WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
// Sicherheitshalber auch system_data loeschen (falls kein CASCADE)
|
||||
d.db.Exec("DELETE FROM system_data WHERE agent_id = ?", id)
|
||||
return rows > 0, nil
|
||||
}
|
||||
@ -1,25 +1,19 @@
|
||||
module github.com/cynfo/rmm-backend
|
||||
|
||||
go 1.22
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.29.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.41.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
|
||||
@ -1,45 +1,37 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
|
||||
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
|
||||
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/sqlite v1.29.1 h1:19GY2qvWB4VPw0HppFlZCPAbmxFU41r+qjKZQdQ1ryA=
|
||||
modernc.org/sqlite v1.29.1/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@ -39,7 +39,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Datenbank initialisieren
|
||||
database, err := db.New(cfg.DBPath)
|
||||
database, err := db.New(cfg.Database)
|
||||
if err != nil {
|
||||
log.Fatalf("Datenbank-Fehler: %v", err)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user