mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-05-12 21:24:43 +00:00
b06adf9f2a
## Summary Implements `GET /api/backup` — one-click SQLite database export per #474. Operators can now grab a complete, consistent snapshot of the analyzer DB with a single authenticated request — no SSH, no scripts, no DB tooling. ## Endpoint ``` GET /api/backup X-API-Key: <key> # required → 200 OK Content-Type: application/octet-stream Content-Disposition: attachment; filename="corescope-backup-<unix>.db" <body: complete SQLite database file> ``` ## Approach Uses SQLite's `VACUUM INTO 'path'` to produce an atomic, defragmented copy of the database into a fresh file: - **Consistent**: VACUUM INTO runs at read isolation — the snapshot reflects a single point in time even while the ingestor is writing to the WAL. - **Non-blocking**: writers continue uninterrupted; we never hold a write lock. - **Works on read-only connections**: verified manually against a WAL-mode source DB (`mode=ro` connection successfully produces a snapshot). - **No corruption risk**: even if the live on-disk DB has issues, VACUUM INTO surfaces what the server can read rather than copying broken pages byte-for-byte. The snapshot is staged in `os.MkdirTemp(...)` and removed after the response body is fully streamed (deferred cleanup). Requesting client IP is logged for audit. The issue suggested an alternative in-memory rebuild path; `VACUUM INTO` is simpler, faster, and produces a strictly more accurate copy of what the server actually sees, so going with it. ## Security - Mounted under `requireAPIKey` middleware — same gate as other admin endpoints (`/api/admin/prune`, `/api/perf/reset`). - Returns 401 without a valid `X-API-Key` header. - Returns 403 if no API key is configured server-side. - `X-Content-Type-Options: nosniff` set on the response. ## TDD - **Red** (`99548f2`): `cmd/server/backup_test.go` adds `TestBackupRequiresAPIKey` + `TestBackupReturnsValidSQLiteSnapshot`. Stub handler returns 200 with no body so the tests fail on assertions (Content-Type / Content-Disposition / SQLite magic header), not on import or build errors. - **Green** (`837b2fe`): real implementation lands; both tests pass; full `go test ./...` suite stays green. ## Files - `cmd/server/backup.go` — handler implementation - `cmd/server/backup_test.go` — red-then-green tests - `cmd/server/routes.go` — route registration under `requireAPIKey` - `cmd/server/openapi.go` — OpenAPI metadata so `/api/openapi` advertises the endpoint ## Out of scope (follow-ups) - Rate limiting (issue suggested 1 req/min). Not added here — admin-key-gated endpoint with a fast snapshot path is acceptable for v1; happy to add a token-bucket limiter in a follow-up if operators report hammering. - UI button to trigger the download (frontend work — separate PR). Fixes #474 --------- Co-authored-by: corescope-bot <bot@corescope.local>
56 lines
1.7 KiB
Go
56 lines
1.7 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// sqliteMagic is the 16-byte file header identifying a valid SQLite 3 database.
|
|
// See https://www.sqlite.org/fileformat.html#magic_header_string
|
|
const sqliteMagic = "SQLite format 3\x00"
|
|
|
|
func TestBackupRequiresAPIKey(t *testing.T) {
|
|
_, router := setupTestServerWithAPIKey(t, "test-secret-key-strong-enough")
|
|
|
|
req := httptest.NewRequest("GET", "/api/backup", nil)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 without API key, got %d (body: %s)", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBackupReturnsValidSQLiteSnapshot(t *testing.T) {
|
|
const apiKey = "test-secret-key-strong-enough"
|
|
_, router := setupTestServerWithAPIKey(t, apiKey)
|
|
|
|
req := httptest.NewRequest("GET", "/api/backup", nil)
|
|
req.Header.Set("X-API-Key", apiKey)
|
|
w := httptest.NewRecorder()
|
|
router.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String())
|
|
}
|
|
|
|
ct := w.Header().Get("Content-Type")
|
|
if ct != "application/octet-stream" {
|
|
t.Errorf("expected Content-Type application/octet-stream, got %q", ct)
|
|
}
|
|
|
|
cd := w.Header().Get("Content-Disposition")
|
|
if !strings.HasPrefix(cd, "attachment;") || !strings.Contains(cd, "filename=\"corescope-backup-") || !strings.HasSuffix(cd, ".db\"") {
|
|
t.Errorf("expected Content-Disposition attachment with corescope-backup-<ts>.db filename, got %q", cd)
|
|
}
|
|
|
|
body := w.Body.Bytes()
|
|
if len(body) < len(sqliteMagic) {
|
|
t.Fatalf("backup body too short (%d bytes) — expected SQLite file", len(body))
|
|
}
|
|
if got := string(body[:len(sqliteMagic)]); got != sqliteMagic {
|
|
t.Fatalf("expected SQLite magic header %q, got %q", sqliteMagic, got)
|
|
}
|
|
}
|