package bacula import ( "context" "database/sql" "encoding/json" "net/http" "time" "github.com/atlasos/calypso/internal/common/database" "github.com/atlasos/calypso/internal/common/logger" "github.com/atlasos/calypso/internal/iam" "github.com/gin-gonic/gin" "github.com/lib/pq" "go.uber.org/zap" ) const ( requestTimeout = 5 * time.Second maxHistoryEntries = 10 ) type Handler struct { db *database.DB logger *logger.Logger } func NewHandler(db *database.DB, log *logger.Logger) *Handler { return &Handler{db: db, logger: log.WithFields(zap.String("component", "bacula-handler"))} } type RegisterRequest struct { Hostname string `json:"hostname" binding:"required"` IPAddress string `json:"ip_address" binding:"required"` AgentVersion string `json:"agent_version"` Status string `json:"status"` BackupTypes []string `json:"backup_types" binding:"required"` Metadata map[string]string `json:"metadata"` } type UpdateCapabilitiesRequest struct { BackupTypes []string `json:"backup_types" binding:"required"` Notes string `json:"notes"` } type PingRequest struct { Status string `json:"status"` } type ClientResponse struct { ID string `json:"id"` Hostname string `json:"hostname"` IPAddress string `json:"ip_address"` AgentVersion string `json:"agent_version"` Status string `json:"status"` BackupTypes []string `json:"backup_types"` PendingBackupTypes []string `json:"pending_backup_types,omitempty"` PendingRequestedBy string `json:"pending_requested_by,omitempty"` PendingRequestedAt *time.Time `json:"pending_requested_at,omitempty"` PendingNotes string `json:"pending_notes,omitempty"` Metadata map[string]interface{} `json:"metadata,omitempty"` RegisteredBy string `json:"registered_by"` LastSeen *time.Time `json:"last_seen,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` CapabilityHistory []CapabilityHistoryEntry `json:"capability_history,omitempty"` } type CapabilityHistoryEntry struct { BackupTypes []string `json:"backup_types"` Source string `json:"source"` RequestedBy string `json:"requested_by,omitempty"` RequestedAt time.Time `json:"requested_at"` Notes string `json:"notes,omitempty"` } type PendingUpdateResponse struct { BackupTypes []string `json:"backup_types"` RequestedBy string `json:"requested_by,omitempty"` RequestedAt time.Time `json:"requested_at"` Notes string `json:"notes,omitempty"` } func (h *Handler) Register(c *gin.Context) { var req RegisterRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) return } if len(req.BackupTypes) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "backup_types is required"}) return } user, err := currentUser(c) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() backupPayload, err := json.Marshal(req.BackupTypes) if err != nil { h.logger.Error("failed to marshal backup types", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode backup types"}) return } var metadataPayload []byte if len(req.Metadata) > 0 { metadataPayload, err = json.Marshal(req.Metadata) if err != nil { h.logger.Error("failed to marshal metadata", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode metadata"}) return } } status := req.Status if status == "" { status = "online" } tx, err := h.db.BeginTx(ctx, nil) if err != nil { h.logger.Error("failed to begin database transaction", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register client"}) return } defer tx.Rollback() var row clientRow err = tx.QueryRowContext(ctx, ` INSERT INTO bacula_clients ( hostname, ip_address, agent_version, status, backup_types, metadata, registered_by_user_id, last_seen, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) ON CONFLICT (hostname) DO UPDATE SET ip_address = EXCLUDED.ip_address, agent_version = EXCLUDED.agent_version, status = EXCLUDED.status, backup_types = EXCLUDED.backup_types, metadata = COALESCE(EXCLUDED.metadata, bacula_clients.metadata), registered_by_user_id = EXCLUDED.registered_by_user_id, last_seen = EXCLUDED.last_seen, updated_at = NOW() RETURNING id, hostname, ip_address, agent_version, status, backup_types, metadata, pending_backup_types, pending_requested_by, pending_requested_at, pending_notes, registered_by_user_id, last_seen, created_at, updated_at `, req.Hostname, req.IPAddress, req.AgentVersion, status, backupPayload, metadataPayload, user.ID).Scan( &row.ID, &row.Hostname, &row.IPAddress, &row.AgentVersion, &row.Status, &row.BackupJSON, &row.MetadataJSON, &row.PendingBackupJSON, &row.PendingRequestedBy, &row.PendingRequestedAt, &row.PendingNotes, &row.RegisteredBy, &row.LastSeen, &row.CreatedAt, &row.UpdatedAt, ) if err != nil { h.logger.Error("failed to ensure bacula client", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register client"}) return } resp, err := buildClientResponse(&row) if err != nil { h.logger.Error("failed to marshal client response", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build response"}) return } if err := insertCapabilityHistory(ctx, tx, row.ID, req.BackupTypes, "agent", user.ID, "agent registration"); err != nil { h.logger.Error("failed to record capability history", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record capability history"}) return } if len(resp.PendingBackupTypes) > 0 && stringSlicesEqual(resp.PendingBackupTypes, resp.BackupTypes) { if _, err := tx.ExecContext(ctx, ` UPDATE bacula_clients SET pending_backup_types = NULL, pending_requested_by = NULL, pending_requested_at = NULL, pending_notes = NULL, updated_at = NOW() WHERE id = $1 `, resp.ID); err != nil { h.logger.Error("failed to clear pending capability update", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update client"}) return } resp.PendingBackupTypes = nil resp.PendingRequestedBy = "" resp.PendingRequestedAt = nil resp.PendingNotes = "" } if err := tx.Commit(); err != nil { h.logger.Error("failed to commit client registration", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register client"}) return } c.JSON(http.StatusOK, resp) } func (h *Handler) UpdateCapabilities(c *gin.Context) { var req UpdateCapabilitiesRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"}) return } if len(req.BackupTypes) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "backup_types is required"}) return } user, err := currentUser(c) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) return } clientID := c.Param("id") if clientID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "client id is required"}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() tx, err := h.db.BeginTx(ctx, nil) if err != nil { h.logger.Error("failed to begin transaction", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "unable to update capabilities"}) return } defer tx.Rollback() var exists bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM bacula_clients WHERE id = $1)`, clientID).Scan(&exists); err != nil { h.logger.Error("failed to verify client", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "unable to update client"}) return } if !exists { c.JSON(http.StatusNotFound, gin.H{"error": "client not found"}) return } backupPayload, err := json.Marshal(req.BackupTypes) if err != nil { h.logger.Error("failed to marshal backup types", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encode backup types"}) return } if _, err := tx.ExecContext(ctx, ` UPDATE bacula_clients SET pending_backup_types = $1, pending_requested_by = $2, pending_requested_at = NOW(), pending_notes = $3, updated_at = NOW() WHERE id = $4 `, backupPayload, user.ID, req.Notes, clientID); err != nil { h.logger.Error("failed to mark pending update", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update client"}) return } if err := insertCapabilityHistory(ctx, tx, clientID, req.BackupTypes, "ui", user.ID, req.Notes); err != nil { h.logger.Error("failed to insert capability history", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record capability change"}) return } if err := tx.Commit(); err != nil { h.logger.Error("failed to commit capability update", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update client"}) return } c.JSON(http.StatusOK, PendingUpdateResponse{ BackupTypes: req.BackupTypes, RequestedBy: user.ID, RequestedAt: time.Now(), Notes: req.Notes, }) } func (h *Handler) GetPendingUpdate(c *gin.Context) { clientID := c.Param("id") if clientID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "client id is required"}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() var pendingJSON []byte var requestedBy sql.NullString var requestedAt sql.NullTime var notes sql.NullString err := h.db.QueryRowContext(ctx, ` SELECT pending_backup_types, pending_requested_by, pending_requested_at, pending_notes FROM bacula_clients WHERE id = $1 `, clientID).Scan(&pendingJSON, &requestedBy, &requestedAt, ¬es) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "client not found"}) return } h.logger.Error("failed to fetch pending update", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read pending update"}) return } if len(pendingJSON) == 0 { c.Status(http.StatusNoContent) return } var backupTypes []string if err := json.Unmarshal(pendingJSON, &backupTypes); err != nil { h.logger.Error("failed to unmarshal pending backup types", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read pending update"}) return } response := PendingUpdateResponse{ BackupTypes: backupTypes, Notes: notes.String, } if requestedBy.Valid { response.RequestedBy = requestedBy.String } if requestedAt.Valid { response.RequestedAt = requestedAt.Time } c.JSON(http.StatusOK, response) } func (h *Handler) Ping(c *gin.Context) { clientID := c.Param("id") if clientID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "client id is required"}) return } var req PingRequest if err := c.ShouldBindJSON(&req); err != nil { // swallow body if absent } ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() query := ` UPDATE bacula_clients SET last_seen = NOW(), status = COALESCE(NULLIF($2, ''), status), updated_at = NOW() WHERE id = $1 RETURNING id ` var id string err := h.db.QueryRowContext(ctx, query, clientID, req.Status).Scan(&id) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "client not found"}) return } h.logger.Error("failed to update heartbeat", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update client"}) return } c.Status(http.StatusNoContent) } func (h *Handler) ListClients(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() rows, err := h.db.QueryContext(ctx, ` SELECT id, hostname, ip_address, agent_version, status, backup_types, metadata, pending_backup_types, pending_requested_by, pending_requested_at, pending_notes, registered_by_user_id, last_seen, created_at, updated_at FROM bacula_clients ORDER BY created_at DESC `) if err != nil { h.logger.Error("failed to query clients", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch clients"}) return } defer rows.Close() var clients []*ClientResponse var ids []string for rows.Next() { row := clientRow{} if err := rows.Scan(&row.ID, &row.Hostname, &row.IPAddress, &row.AgentVersion, &row.Status, &row.BackupJSON, &row.MetadataJSON, &row.PendingBackupJSON, &row.PendingRequestedBy, &row.PendingRequestedAt, &row.PendingNotes, &row.RegisteredBy, &row.LastSeen, &row.CreatedAt, &row.UpdatedAt); err != nil { h.logger.Error("failed to scan client row", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch clients"}) return } resp, err := buildClientResponse(&row) if err != nil { h.logger.Error("failed to build client response", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch clients"}) return } clients = append(clients, resp) ids = append(ids, resp.ID) } if len(ids) > 0 { if err := h.attachHistory(ctx, ids, clients); err != nil { h.logger.Error("failed to attach history", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch client history"}) return } } c.JSON(http.StatusOK, clients) } func (h *Handler) GetClient(c *gin.Context) { clientID := c.Param("id") if clientID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "client id is required"}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) defer cancel() var row clientRow err := h.db.QueryRowContext(ctx, ` SELECT id, hostname, ip_address, agent_version, status, backup_types, metadata, pending_backup_types, pending_requested_by, pending_requested_at, pending_notes, registered_by_user_id, last_seen, created_at, updated_at FROM bacula_clients WHERE id = $1 `, clientID).Scan(&row.ID, &row.Hostname, &row.IPAddress, &row.AgentVersion, &row.Status, &row.BackupJSON, &row.MetadataJSON, &row.PendingBackupJSON, &row.PendingRequestedBy, &row.PendingRequestedAt, &row.PendingNotes, &row.RegisteredBy, &row.LastSeen, &row.CreatedAt, &row.UpdatedAt) if err != nil { if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "client not found"}) return } h.logger.Error("failed to read client", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch client"}) return } resp, err := buildClientResponse(&row) if err != nil { h.logger.Error("failed to build client response", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch client"}) return } if err := h.attachHistory(ctx, []string{resp.ID}, []*ClientResponse{resp}); err != nil { h.logger.Error("failed to attach history", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to fetch client history"}) return } c.JSON(http.StatusOK, resp) } func (h *Handler) attachHistory(ctx context.Context, ids []string, clients []*ClientResponse) error { if len(ids) == 0 { return nil } rows, err := h.db.QueryContext(ctx, ` SELECT client_id, backup_types, source, requested_by_user_id, requested_at, notes FROM bacula_client_capability_history WHERE client_id = ANY($1) ORDER BY requested_at DESC `, pq.Array(ids)) if err != nil { return err } defer rows.Close() clientMap := make(map[string]*ClientResponse) for _, client := range clients { clientMap[client.ID] = client } for rows.Next() { var clientID string var backupJSON []byte var source string var requestedBy sql.NullString var requestedAt time.Time var notes sql.NullString if err := rows.Scan(&clientID, &backupJSON, &source, &requestedBy, &requestedAt, ¬es); err != nil { return err } resp, ok := clientMap[clientID] if !ok { continue } if len(resp.CapabilityHistory) >= maxHistoryEntries { continue } var backupTypes []string if err := json.Unmarshal(backupJSON, &backupTypes); err != nil { return err } entry := CapabilityHistoryEntry{ BackupTypes: backupTypes, Source: source, RequestedAt: requestedAt, } if requestedBy.Valid { entry.RequestedBy = requestedBy.String } if notes.Valid { entry.Notes = notes.String } resp.CapabilityHistory = append(resp.CapabilityHistory, entry) } return nil } type clientRow struct { ID string Hostname string IPAddress string AgentVersion string Status string BackupJSON []byte MetadataJSON []byte PendingBackupJSON []byte PendingRequestedBy sql.NullString PendingRequestedAt sql.NullTime PendingNotes sql.NullString RegisteredBy string LastSeen sql.NullTime CreatedAt time.Time UpdatedAt time.Time } func buildClientResponse(row *clientRow) (*ClientResponse, error) { backupTypes, err := decodeStringSlice(row.BackupJSON) if err != nil { return nil, err } pendingTypes, err := decodeStringSlice(row.PendingBackupJSON) if err != nil { return nil, err } metadata, err := decodeMetadata(row.MetadataJSON) if err != nil { return nil, err } resp := &ClientResponse{ ID: row.ID, Hostname: row.Hostname, IPAddress: row.IPAddress, AgentVersion: row.AgentVersion, Status: row.Status, BackupTypes: backupTypes, Metadata: metadata, RegisteredBy: row.RegisteredBy, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } if len(pendingTypes) > 0 { resp.PendingBackupTypes = pendingTypes if row.PendingRequestedBy.Valid { resp.PendingRequestedBy = row.PendingRequestedBy.String } if row.PendingRequestedAt.Valid { resp.PendingRequestedAt = &row.PendingRequestedAt.Time } if row.PendingNotes.Valid { resp.PendingNotes = row.PendingNotes.String } } if row.LastSeen.Valid { resp.LastSeen = &row.LastSeen.Time } return resp, nil } func currentUser(c *gin.Context) (*iam.User, error) { user, exists := c.Get("user") if !exists { return nil, sql.ErrNoRows } authUser, ok := user.(*iam.User) if !ok { return nil, sql.ErrNoRows } return authUser, nil } func decodeStringSlice(data []byte) ([]string, error) { if len(data) == 0 { return []string{}, nil } var dst []string if err := json.Unmarshal(data, &dst); err != nil { return nil, err } return dst, nil } func decodeMetadata(data []byte) (map[string]interface{}, error) { if len(data) == 0 { return nil, nil } var metadata map[string]interface{} if err := json.Unmarshal(data, &metadata); err != nil { return nil, err } return metadata, nil } func insertCapabilityHistory(ctx context.Context, tx *sql.Tx, clientID string, backupTypes []string, source, requestedBy, notes string) error { payload, err := json.Marshal(backupTypes) if err != nil { return err } _, err = tx.ExecContext(ctx, ` INSERT INTO bacula_client_capability_history ( client_id, backup_types, source, requested_by_user_id, notes ) VALUES ($1, $2, $3, $4, $5) `, clientID, payload, source, requestedBy, notes) return err } func stringSlicesEqual(a, b []string) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }