63701dd086
Addresses the gaps identified in the last audit. Restore (was a stub returning "not yet implemented"). Every repository shares one connection pool, so the database cannot be swapped underneath a live server. Restore is therefore two-phase: RestoreBackup validates the file and stages it beside the database; db.New applies it before the pool is opened, which is the only safe moment. The database being replaced is preserved as <db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot replay the old journal over the restored file. Validation is strict — SQLite integrity_check plus a schema probe — because applying an unrelated file would destroy the install. GET/DELETE /api/v1/backups/restore inspect and cancel a staged restore. The CLI does both phases at once, since it runs standalone; `orchestrad backup` was also a stub and now works. Secret key. With nothing configured the key is generated once and persisted to <data>/secret.key, so restarts reuse it and moving the stack to another server is a matter of copying the data directory. Upgrades are handled: if a database already exists the install was silently running on the legacy built-in default, so that value is adopted and written out rather than replaced — generating a fresh key there would make every stored credential undecryptable. The file is owner-only (ACL-restricted on Windows). Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the architectures the release binaries already covered. The Dockerfile cross-compiles via TARGETARCH rather than emulating, so arm64 costs little. CSRF: the middleware previously checked only that a header was *present* and was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens are now nonce + HMAC-SHA256 signed with the application secret, validated properly, and the middleware is mounted on /api/v1. Bearer and API-key requests are not CSRF-reachable and pass through untouched, so this is transparent to the SPA and to API clients. Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not the source of truth — <data>/tls holds the key, so portability is unaffected and a non-exportable server key is the better posture), the PFX password is written to server.pfx.password beside the bundle so an operator importing it by hand does not have to hunt for a password they never chose, and the "renewed" log line now reflects whether a leaf was actually issued instead of guessing from its age. Verified live: backup -> stage -> restart applies and preserves the previous database; secret key generated, adopted, and read back across restarts with the credential check confirming decryptability; CSRF endpoint issues real signed tokens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
175 lines
5.9 KiB
Go
175 lines
5.9 KiB
Go
// Package api - Backups handlers
|
|
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/audit"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/services"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// BackupsHandler handles database backup endpoints
|
|
type BackupsHandler struct {
|
|
service *services.BackupService
|
|
auditService *audit.Service
|
|
logger *logging.Logger
|
|
}
|
|
|
|
// NewBackupsHandler creates a new BackupsHandler
|
|
func NewBackupsHandler(service *services.BackupService, auditService *audit.Service, logger *logging.Logger) *BackupsHandler {
|
|
return &BackupsHandler{service: service, auditService: auditService, logger: logger}
|
|
}
|
|
|
|
// BackupResponse represents a backup in responses
|
|
type BackupResponse struct {
|
|
ID string `json:"id,omitempty"`
|
|
Filename string `json:"filename"`
|
|
FilePath string `json:"filePath"`
|
|
SizeBytes int64 `json:"sizeBytes"`
|
|
BackupType string `json:"backupType,omitempty"`
|
|
CreatedAt time.Time `json:"createdAt"`
|
|
}
|
|
|
|
// CreateBackupRequest represents a backup creation request
|
|
type CreateBackupRequest struct {
|
|
BackupType string `json:"backupType,omitempty"`
|
|
}
|
|
|
|
// RestoreBackupRequest represents a restore request body
|
|
type RestoreBackupRequest struct {
|
|
FilePath string `json:"filePath,omitempty"`
|
|
}
|
|
|
|
// List handles GET /api/v1/backups
|
|
func (h *BackupsHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
backups, err := h.service.ListBackups()
|
|
if err != nil {
|
|
h.logger.Error("BackupsHandler", "List failed: %v", err)
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to list backups")
|
|
return
|
|
}
|
|
resp := make([]BackupResponse, 0, len(backups))
|
|
for i := range backups {
|
|
resp = append(resp, backupToResponse(&backups[i]))
|
|
}
|
|
WriteJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// Create handles POST /api/v1/backups
|
|
func (h *BackupsHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
var req CreateBackupRequest
|
|
if r.ContentLength > 0 {
|
|
if err := DecodeJSON(r, &req); err != nil {
|
|
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
|
|
return
|
|
}
|
|
}
|
|
backupType := req.BackupType
|
|
if backupType == "" {
|
|
backupType = "manual"
|
|
}
|
|
|
|
triggeredBy := "api"
|
|
if user := GetUserFromContext(r.Context()); user != nil {
|
|
triggeredBy = user.Username
|
|
}
|
|
|
|
backup, err := h.service.CreateBackup(backupType, triggeredBy)
|
|
if err != nil {
|
|
h.logger.Error("BackupsHandler", "Create failed: %v", err)
|
|
emitAudit(h.auditService, r, audit.EventBackup, "Backup", "", "Create", false,
|
|
map[string]any{"backupType": backupType}, err.Error())
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to create backup")
|
|
return
|
|
}
|
|
emitAudit(h.auditService, r, audit.EventBackup, "Backup", backup.ID, "Create", true,
|
|
map[string]any{"filename": backup.Filename, "backupType": backup.BackupType}, "")
|
|
WriteJSON(w, http.StatusCreated, backupToResponse(backup))
|
|
}
|
|
|
|
// Restore handles POST /api/v1/backups/{id}/restore
|
|
// The {id} parameter is the backup filename; alternatively callers may pass a
|
|
// filePath in the request body for backups that are not in the default location.
|
|
func (h *BackupsHandler) Restore(w http.ResponseWriter, r *http.Request) {
|
|
var req RestoreBackupRequest
|
|
if r.ContentLength > 0 {
|
|
if err := DecodeJSON(r, &req); err != nil {
|
|
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
|
|
return
|
|
}
|
|
}
|
|
|
|
filePath := req.FilePath
|
|
if filePath == "" {
|
|
filename := chi.URLParam(r, "id")
|
|
if filename == "" {
|
|
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "backup filename or filePath is required")
|
|
return
|
|
}
|
|
backups, err := h.service.ListBackups()
|
|
if err != nil {
|
|
h.logger.Error("BackupsHandler", "ListBackups failed: %v", err)
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to locate backup")
|
|
return
|
|
}
|
|
for i := range backups {
|
|
if backups[i].Filename == filename {
|
|
filePath = backups[i].FilePath
|
|
break
|
|
}
|
|
}
|
|
if filePath == "" {
|
|
WriteError(w, http.StatusNotFound, ErrCodeNotFound, "Backup not found")
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := h.service.RestoreBackup(filePath); err != nil {
|
|
h.logger.Error("BackupsHandler", "Restore failed: %v", err)
|
|
emitAudit(h.auditService, r, audit.EventRestore, "Backup", "", "Restore", false,
|
|
map[string]any{"filePath": filePath}, err.Error())
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, err.Error())
|
|
return
|
|
}
|
|
emitAudit(h.auditService, r, audit.EventRestore, "Backup", "", "Restore", true,
|
|
map[string]any{"filePath": filePath}, "")
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"staged": true,
|
|
"restartRequired": true,
|
|
"message": "Restore staged and validated. It is applied the next time OrchestrAD starts; " +
|
|
"the database being replaced is preserved alongside it.",
|
|
})
|
|
}
|
|
|
|
// CancelRestore handles DELETE /api/v1/backups/restore — discards a staged
|
|
// restore that has not been applied yet.
|
|
func (h *BackupsHandler) CancelRestore(w http.ResponseWriter, r *http.Request) {
|
|
if err := h.service.CancelRestore(); err != nil {
|
|
h.logger.Error("BackupsHandler", "CancelRestore failed: %v", err)
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Failed to cancel the staged restore")
|
|
return
|
|
}
|
|
emitAudit(h.auditService, r, audit.EventRestore, "Backup", "", "CancelRestore", true, nil, "")
|
|
WriteJSON(w, http.StatusOK, map[string]bool{"cancelled": true})
|
|
}
|
|
|
|
// RestoreStatus handles GET /api/v1/backups/restore — reports whether a restore
|
|
// is staged and waiting for a restart.
|
|
func (h *BackupsHandler) RestoreStatus(w http.ResponseWriter, r *http.Request) {
|
|
WriteJSON(w, http.StatusOK, map[string]bool{"pending": h.service.PendingRestore()})
|
|
}
|
|
|
|
func backupToResponse(b *services.BackupInfo) BackupResponse {
|
|
return BackupResponse{
|
|
ID: b.ID,
|
|
Filename: b.Filename,
|
|
FilePath: b.FilePath,
|
|
SizeBytes: b.SizeBytes,
|
|
BackupType: b.BackupType,
|
|
CreatedAt: b.CreatedAt,
|
|
}
|
|
}
|