45f75a916c
Bug fixes: - Add applyEnvOverrides to LoadFromBytes (M05) - Set state=failed on compose-up failure in selfupdate (M16) - Clamp usableMB to min 0 in memory check (M22) - Remove "manual" schedule from triggerAllCrossBackups (M23) - Add mmcblk device handling for partition paths (M21) - Fix stripPartition for mmcblk devices (L25) - Fix TruncateStr for UTF-8 and negative maxLen (L05/L06) - Fix AllDone to return false for empty restore plans (L14) - Fix PushOnce to return actual errors (L39) - Restore pending events on save failure in DrainPendingEvents (M03) - Add duplicate check in AddStoragePath (M04) - Call CleanupTempMounts after drive scan (H13) - Log SetStep save errors (M25) Hardening: - Guard scheduler Start() against double-start (M14) - Acquire mutex in scheduler Stop() before reading cancel (L24) - Cap log lines parameter to 10000 (L31) - Require POST for logout (L32) - Use sync.Once for Server.Close() (L49) - Panic on crypto/rand.Read failure in setup CSRF (L40) - Validate Bearer token against Hub API key in CSRF (H16 fix) - Replace custom hasPrefix with strings.HasPrefix (L13) - Replace simpleHash with crc32.ChecksumIEEE (L48) Cleanup: - Remove dead imageName function (L02) - Remove dead detectHostIPViaRoute function (L03) - Rename shadowed copy variable to cp (L07) - Copy DefaultEnabledEvents in GetNotificationPrefs early return (L09) - Update BUGHUNT.md with comprehensive audit results Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
225 lines
5.4 KiB
Go
225 lines
5.4 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"`
|
|
}
|
|
|
|
// 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] 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] 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.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 sends the infrastructure backup payload to the Hub.
|
|
// Uses the same retry logic as Push.
|
|
func (p *Pusher) PushInfraBackup(data []byte) error {
|
|
if !p.enabled {
|
|
return nil
|
|
}
|
|
|
|
url := p.hubURL + "/api/v1/infra-backup"
|
|
if p.debug {
|
|
p.logger.Printf("[DEBUG] PushInfraBackup: url=%s payload=%d bytes", url, len(data))
|
|
}
|
|
|
|
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
|
|
}
|
|
io.Copy(io.Discard, resp.Body)
|
|
resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
p.logger.Printf("[INFO] Infra backup pushed to Hub (%d bytes)", len(data))
|
|
return nil
|
|
}
|
|
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
if p.debug {
|
|
p.logger.Printf("[DEBUG] PushInfraBackup: attempt %d failed — HTTP %d", attempt+1, resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("infra backup push failed after 3 attempts: %w", lastErr)
|
|
}
|
|
|
|
// 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] Hub push-once sent (%d bytes)", len(data))
|
|
}
|
|
return nil
|
|
}
|