6eb75204b6
New controller features:
- Web-based setup wizard replaces docker-setup.sh interactive config
- Dual listener: :8080 (Traefik) + :8081 (direct HTTP for LAN)
- Drive scanner finds .felhom-infra-backup/ on all block devices
- Hub recovery pull (GET /api/v1/recovery/{id}) with retrieval password
- Fresh install: Hub config download or manual wizard
- CSRF protection, state persistence, Hungarian UI
- Local infra backup written to all connected drives after each backup cycle
- .felhom-infra-backup/backup.json + metadata.json with SHA256 checksum
- Hub verification: parse customer_blocked from report push response
- Limited mode after 7 days without verification
- Recovery info page on Settings + recovery-info.txt file generation
- Pending events queue: DR events sent to Hub on next report push
- docker-setup.sh v6.0.0: removed interactive wizard, minimal controller.yaml only
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
137 lines
4.1 KiB
Go
137 lines
4.1 KiB
Go
package backup
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// MaxSchemaVersion is the highest infra backup schema version this controller can read.
|
|
const MaxSchemaVersion = 1
|
|
|
|
// InfraMetadata is the lightweight metadata file written alongside backup.json.
|
|
type InfraMetadata struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Timestamp string `json:"timestamp"`
|
|
CustomerID string `json:"customer_id"`
|
|
ControllerVersion string `json:"controller_version"`
|
|
Checksum string `json:"checksum"` // SHA256 hex of backup.json
|
|
}
|
|
|
|
// WriteLocalInfraBackup writes the infra backup to .felhom-infra-backup/ on each drive.
|
|
// Individual drive failures are logged but not returned — the function is best-effort.
|
|
func WriteLocalInfraBackup(backupJSON []byte, customerID, controllerVersion, timestamp string, drives []string, logger *log.Logger) {
|
|
if len(drives) == 0 {
|
|
logger.Printf("[DEBUG] No drives configured for local infra backup")
|
|
return
|
|
}
|
|
|
|
// Compute checksum of backup data
|
|
hash := sha256.Sum256(backupJSON)
|
|
checksum := hex.EncodeToString(hash[:])
|
|
|
|
meta := InfraMetadata{
|
|
SchemaVersion: 1,
|
|
Timestamp: timestamp,
|
|
CustomerID: customerID,
|
|
ControllerVersion: controllerVersion,
|
|
Checksum: checksum,
|
|
}
|
|
|
|
metaJSON, err := json.MarshalIndent(meta, "", " ")
|
|
if err != nil {
|
|
logger.Printf("[ERROR] Local infra backup: failed to marshal metadata: %v", err)
|
|
return
|
|
}
|
|
|
|
written := 0
|
|
for _, drive := range drives {
|
|
dir := InfraBackupDir(drive)
|
|
if err := writeInfraToDir(dir, backupJSON, metaJSON); err != nil {
|
|
logger.Printf("[WARN] Local infra backup: failed to write to %s: %v", drive, err)
|
|
continue
|
|
}
|
|
written++
|
|
}
|
|
|
|
logger.Printf("[INFO] Local infra backup written to %d/%d drive(s)", written, len(drives))
|
|
}
|
|
|
|
// writeInfraToDir writes backup.json and metadata.json atomically to the given directory.
|
|
func writeInfraToDir(dir string, backupData, metaData []byte) error {
|
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
return fmt.Errorf("creating dir: %w", err)
|
|
}
|
|
|
|
// Write backup.json atomically
|
|
backupPath := filepath.Join(dir, "backup.json")
|
|
if err := atomicWrite(backupPath, backupData, 0600); err != nil {
|
|
return fmt.Errorf("writing backup.json: %w", err)
|
|
}
|
|
|
|
// Write metadata.json atomically
|
|
metaPath := filepath.Join(dir, "metadata.json")
|
|
if err := atomicWrite(metaPath, metaData, 0600); err != nil {
|
|
return fmt.Errorf("writing metadata.json: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// atomicWrite writes data to a .tmp file then renames to the target path.
|
|
func atomicWrite(path string, data []byte, perm os.FileMode) error {
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, perm); err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadLocalInfraBackup reads and validates an infra backup from a mount point.
|
|
// Returns the raw backup JSON, metadata, and any error.
|
|
func ReadLocalInfraBackup(mountPath string) ([]byte, *InfraMetadata, error) {
|
|
dir := InfraBackupDir(mountPath)
|
|
|
|
// Read metadata
|
|
metaPath := filepath.Join(dir, "metadata.json")
|
|
metaData, err := os.ReadFile(metaPath)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("reading metadata.json: %w", err)
|
|
}
|
|
|
|
var meta InfraMetadata
|
|
if err := json.Unmarshal(metaData, &meta); err != nil {
|
|
return nil, nil, fmt.Errorf("parsing metadata.json: %w", err)
|
|
}
|
|
|
|
// Check schema version
|
|
if meta.SchemaVersion > MaxSchemaVersion {
|
|
return nil, &meta, fmt.Errorf("backup schema version %d is newer than supported version %d — upgrade the controller", meta.SchemaVersion, MaxSchemaVersion)
|
|
}
|
|
|
|
// Read backup data
|
|
backupPath := filepath.Join(dir, "backup.json")
|
|
backupData, err := os.ReadFile(backupPath)
|
|
if err != nil {
|
|
return nil, &meta, fmt.Errorf("reading backup.json: %w", err)
|
|
}
|
|
|
|
// Verify checksum
|
|
hash := sha256.Sum256(backupData)
|
|
actual := hex.EncodeToString(hash[:])
|
|
if actual != meta.Checksum {
|
|
return nil, &meta, fmt.Errorf("checksum mismatch: expected %s, got %s", meta.Checksum, actual)
|
|
}
|
|
|
|
return backupData, &meta, nil
|
|
}
|