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:
2026-02-21 12:33:17 +01:00
parent e217c3a445
commit 6eb75204b6
28 changed files with 2970 additions and 505 deletions
+98
View File
@@ -2,6 +2,7 @@ package report
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -9,6 +10,103 @@ import (
"time"
)
// Recovery pull error types for UI display.
var (
ErrHubUnreachable = errors.New("hub unreachable")
ErrAuthFailed = errors.New("authentication failed")
ErrNotFound = errors.New("customer not found")
ErrHubError = errors.New("hub error")
)
// RecoveryResponse is the combined config + infra backup from the Hub recovery endpoint.
type RecoveryResponse struct {
CustomerID string `json:"customer_id"`
ConfigYAML string `json:"config_yaml"`
InfraBackup *InfraBackup `json:"infra_backup"`
HasInfraBackup bool `json:"has_infra_backup"`
}
// PullRecovery fetches combined recovery data from the Hub (config + infra backup).
// Auth: X-Retrieval-Password header.
func PullRecovery(hubURL, customerID, retrievalPassword string) (*RecoveryResponse, error) {
url := strings.TrimRight(hubURL, "/") + "/api/v1/recovery/" + customerID
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrHubError, err)
}
req.Header.Set("X-Retrieval-Password", retrievalPassword)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrHubUnreachable, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// success, continue below
case http.StatusUnauthorized:
return nil, ErrAuthFailed
case http.StatusNotFound:
return nil, ErrNotFound
default:
return nil, fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10MB limit
if err != nil {
return nil, fmt.Errorf("%w: reading response: %v", ErrHubError, err)
}
var rr RecoveryResponse
if err := json.Unmarshal(body, &rr); err != nil {
return nil, fmt.Errorf("%w: parsing response: %v", ErrHubError, err)
}
return &rr, nil
}
// PullConfig fetches a generated controller.yaml from the Hub config endpoint.
// Auth: X-Retrieval-Password header.
func PullConfig(hubURL, customerID, retrievalPassword string) (string, error) {
url := strings.TrimRight(hubURL, "/") + "/api/v1/config/" + customerID
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrHubError, err)
}
req.Header.Set("X-Retrieval-Password", retrievalPassword)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrHubUnreachable, err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
// success
case http.StatusUnauthorized:
return "", ErrAuthFailed
case http.StatusNotFound:
return "", ErrNotFound
default:
return "", fmt.Errorf("%w: HTTP %d", ErrHubError, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1MB limit
if err != nil {
return "", fmt.Errorf("%w: reading response: %v", ErrHubError, err)
}
return string(body), nil
}
// PullInfraBackup fetches the infrastructure backup from the Hub.
// Returns nil, nil if no backup exists for this customer.
func PullInfraBackup(hubURL, apiKey, customerID string) (*InfraBackup, error) {