Files
felhom-controller/controller/internal/report/pusher.go
T
admin 3cf49c7fd5 controller: customer-claim password gate v0.122.0 (closes DRILL-day0-vm F-4/F-5)
The customer sets + owns the dashboard password via a hub-emailed one-time
claim code. An unclaimed box (code hash present, no password) serves ONLY the
claim page — every other route → claim page (302) or 401, so a Day-0 box is
never open on the internet. A set password disables the gate (auth wins).
Reset rides the same code engine (login "Elfelejtett jelszó"). Legacy-open
(no password, no hash) shows a red transition banner until the hub delivers a
hash. Report ACK caches the code state idempotently by generation; report
carries claimed (set-only). --print-reset-code root escape hatch. Requires
hub v0.50.0. Gate-coverage signature test + 4 red-proofs proven.
2026-07-12 18:42:39 +02:00

207 lines
6.3 KiB
Go

package report
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// PushStatus tracks the last hub push attempt and result.
type PushStatus struct {
LastAttempt time.Time
LastSuccess time.Time
LastError string
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"`
// Phase 2 managed updates: the effective controller-version FLOOR (the operator's enforced
// minimum) and the latest available version. Empty when the hub has none configured / is old.
// The controller auto-updates to the floor when below it (latest stays the customer's opt-in
// "update to latest" button — never the auto-target).
MinControllerVersion string `json:"min_controller_version"`
LatestVersion string `json:"latest_version"`
// ConfigVersion is the hub's per-customer config counter (v0.26.0). On a change vs. the
// last-applied version, the controller re-pulls controller.yaml + self-restarts (pull-based config
// delivery — the hub never connects into the box). 0 = the hub didn't advertise it (old hub, or a
// report-only customer with no config row) → the controller does nothing.
ConfigVersion int `json:"config_version"`
// Escrow (SLICE 3) is the hub's escrow status for this customer — the input to the hub-verified
// auto-confirm (EscrowAutoConfirmer). nil = no escrow row on the hub (or an old hub) → stays pending.
Escrow *EscrowStatus `json:"escrow"`
// LogTailRequests (v0.111.0) — app names the operator wants a log tail for. Same
// pull-based ACK-flag pattern as escrow: the NEXT report ships the tails; the hub
// clears the pending request on receipt (consume-once). Absent/empty = nothing pending.
LogTailRequests []string `json:"log_tail_requests"`
// ControllerLogRequested (v0.116.0) — the operator wants THIS controller's own debug
// ring; the NEXT report ships controller_log_tail (selftail.go). Absent/false on an
// old hub = nothing pending.
ControllerLogRequested bool `json:"controller_log_requested"`
// Claim (v0.122.0, F-4) — the hub's active claim-code state (bcrypt hash + generation) for
// the customer-claim gate. nil on an old hub / no claim row → the cache stays as-is.
Claim *ClaimStatus `json:"claim"`
}
// Pusher sends reports to the central hub.
type Pusher struct {
hubURL string
apiKey string
httpClient *http.Client
logger *log.Logger
enabled bool
debug bool
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.
func NewPusher(cfg *config.HubConfig, logger *log.Logger, debug bool) *Pusher {
return &Pusher{
hubURL: strings.TrimRight(cfg.URL, "/"),
apiKey: cfg.APIKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
logger: logger,
enabled: cfg.Enabled,
debug: debug,
}
}
// Push sends a report to the hub. Retries 3 times with 5s backoff.
func (p *Pusher) Push(report *Report) error {
if !p.enabled {
return nil
}
data, err := json.Marshal(report)
if err != nil {
return fmt.Errorf("marshal report: %w", err)
}
url := p.hubURL + "/api/v1/report"
if p.debug {
p.logger.Printf("[DEBUG] [report] Push: url=%s payload=%d bytes", url, len(data))
}
p.statusMu.Lock()
p.status.LastAttempt = time.Now()
p.statusMu.Unlock()
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if attempt > 0 {
time.Sleep(5 * time.Second)
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
if err != nil {
lastErr = err
continue
}
req.Header.Set("Content-Type", "application/json")
if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
resp, err := p.httpClient.Do(req)
if err != nil {
lastErr = err
continue
}
// 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 {
p.logger.Printf("[INFO] [report] Hub report pushed successfully (%d bytes)", len(data))
p.statusMu.Lock()
p.status.LastSuccess = time.Now()
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)
}
p.logger.Printf("[WARN] [report] Push failed: %v", lastErr)
p.statusMu.Lock()
p.status.LastError = lastErr.Error()
p.status.Consecutive++
p.statusMu.Unlock()
return fmt.Errorf("hub push failed after 3 attempts: %w", lastErr)
}
// GetStatus returns a snapshot of the current push status.
func (p *Pusher) GetStatus() PushStatus {
p.statusMu.RLock()
defer p.statusMu.RUnlock()
return p.status
}
// (PushInfraBackup removed 2026-06-16 — the infra-backup mechanism was retired hub-side.
// It was dead since slice 8C, had no callers, and pushed plaintext secrets to the hub.)
// PushOnce sends a single report regardless of the enabled flag.
// Used for one-time notifications (e.g., reporting-disabled on startup).
func (p *Pusher) PushOnce(report *Report) error {
if p.hubURL == "" || p.apiKey == "" {
return nil
}
data, err := json.Marshal(report)
if err != nil {
return fmt.Errorf("marshal report: %w", err)
}
url := p.hubURL + "/api/v1/report"
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("hub push-once: %w", err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
p.logger.Printf("[INFO] [report] Hub push-once sent (%d bytes)", len(data))
return nil
}
return fmt.Errorf("hub push-once: HTTP %d", resp.StatusCode)
}