rmm2/backend/db/sqlite.go
cynfo3000 e7beada405 Config-Backup Feature: Agent liest config.xml, Backend speichert versioniert mit Deduplizierung
- Agent: neuer 'backup' Command (SHA256 Hash, Base64 Transfer)
- Backend: config_backups Tabelle in SQLite
- API: trigger, list, download (JSON/XML), delete, diff
- Deduplizierung: gleicher Hash = kein neuer Eintrag
- WebSocket ReadLimit auf 4MB erhoeht (grosse config.xml)
- README aktualisiert mit Backup-Docs
2026-02-28 10:44:09 +01:00

366 lines
9.8 KiB
Go

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, &regAt, &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, &regAt, &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, &regAt, &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
}