Files
deploy-felhom-compose/controller/internal/report/pusher.go
T
admin 45f75a916c fix: P2+P3 bug fixes, hardening, and cleanup (18 files)
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>
2026-02-25 13:47:52 +01:00

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
}