v0.22.0: First-run setup wizard, local infra backup, hub verification
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>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user