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:
@@ -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) {
|
||||
|
||||
@@ -22,6 +22,12 @@ type PushStatus struct {
|
||||
Consecutive int // consecutive failures
|
||||
}
|
||||
|
||||
// PushResponse is the parsed response from the Hub after a report push.
|
||||
type PushResponse struct {
|
||||
Status string `json:"status"`
|
||||
CustomerBlocked bool `json:"customer_blocked"`
|
||||
}
|
||||
|
||||
// Pusher sends reports to the central hub.
|
||||
type Pusher struct {
|
||||
hubURL string
|
||||
@@ -32,6 +38,10 @@ type Pusher struct {
|
||||
|
||||
statusMu sync.RWMutex
|
||||
status PushStatus
|
||||
|
||||
// OnPushResponse is called after each successful report push with the parsed response.
|
||||
// Set by main.go to update hub verification state.
|
||||
OnPushResponse func(resp *PushResponse)
|
||||
}
|
||||
|
||||
// NewPusher creates a new report pusher from hub configuration.
|
||||
@@ -85,7 +95,9 @@ func (p *Pusher) Push(report *Report) error {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
|
||||
// Read response body to parse customer_blocked field
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
@@ -95,6 +107,14 @@ func (p *Pusher) Push(report *Report) error {
|
||||
p.status.LastError = ""
|
||||
p.status.Consecutive = 0
|
||||
p.statusMu.Unlock()
|
||||
|
||||
// Parse response for customer_blocked field
|
||||
if p.OnPushResponse != nil && len(respBody) > 0 {
|
||||
var pr PushResponse
|
||||
if json.Unmarshal(respBody, &pr) == nil {
|
||||
p.OnPushResponse(&pr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
|
||||
Reference in New Issue
Block a user