diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index c29379f..9586ddf 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -42,6 +42,12 @@ type Config struct { Alerting struct { StaleThreshold string `yaml:"stale_threshold"` } `yaml:"alerting"` + Registry struct { + Image string `yaml:"image"` + Username string `yaml:"username"` + Token string `yaml:"token"` + CheckInterval string `yaml:"check_interval"` + } `yaml:"registry"` Server struct { Listen string `yaml:"listen"` DataDir string `yaml:"data_dir"` @@ -84,7 +90,7 @@ func main() { // Initialize handlers apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, logger) - webServer := web.New(dataStore, cfg.Auth.PasswordHash, staleThreshold, logger) + webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, staleThreshold, logger) // Build HTTP mux mux := http.NewServeMux() @@ -114,10 +120,25 @@ func main() { IdleTimeout: 120 * time.Second, } - // Background: daily prune + // Background: daily prune + version checker ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Initialize version checker for controller image registry + var versionChecker *web.VersionChecker + if cfg.Registry.Username != "" && cfg.Registry.Token != "" { + checkInterval, err := time.ParseDuration(cfg.Registry.CheckInterval) + if err != nil { + checkInterval = 6 * time.Hour + } + versionChecker = web.NewVersionChecker(cfg.Registry.Image, cfg.Registry.Username, cfg.Registry.Token, checkInterval, logger) + go versionChecker.Run(ctx) + logger.Printf("[INFO] Registry version checker started (every %s)", cfg.Registry.CheckInterval) + } else { + logger.Printf("[INFO] Registry version checker disabled (no credentials configured)") + } + webServer.SetVersionChecker(versionChecker) + if cfg.Retention.MaxDays > 0 { go pruneLoop(ctx, dataStore, cfg.Retention.MaxDays, logger) } @@ -184,6 +205,12 @@ func loadConfig(path string, logger *log.Logger) *Config { if cfg.Notifications.FromEmail == "" { cfg.Notifications.FromEmail = "monitoring@felhom.eu" } + if cfg.Registry.Image == "" { + cfg.Registry.Image = "gitea.dooplex.hu/admin/felhom-controller" + } + if cfg.Registry.CheckInterval == "" { + cfg.Registry.CheckInterval = "6h" + } return cfg } diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 6db205f..d5a6b65 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -110,30 +110,32 @@ func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) { } type customerJSON struct { - ID string `json:"id"` - Name string `json:"name"` - ControllerVersion string `json:"controller_version"` - HealthStatus string `json:"health_status"` - LastSeen time.Time `json:"last_seen"` - CPUPercent float64 `json:"cpu_percent"` - MemoryPercent float64 `json:"memory_percent"` - ContainerTotal int `json:"container_total"` - ContainerRunning int `json:"container_running"` + ID string `json:"id"` + Name string `json:"name"` + ControllerVersion string `json:"controller_version"` + ControllerURL string `json:"controller_url,omitempty"` + HealthStatus string `json:"health_status"` + LastSeen time.Time `json:"last_seen"` + CPUPercent float64 `json:"cpu_percent"` + MemoryPercent float64 `json:"memory_percent"` + ContainerTotal int `json:"container_total"` + ContainerRunning int `json:"container_running"` BackupLastSnapshot *time.Time `json:"backup_last_snapshot"` } result := make([]customerJSON, 0, len(customers)) for _, c := range customers { result = append(result, customerJSON{ - ID: c.CustomerID, - Name: c.CustomerName, - ControllerVersion: c.ControllerVersion, - HealthStatus: c.HealthStatus, - LastSeen: c.ReceivedAt, - CPUPercent: c.CPUPercent, - MemoryPercent: c.MemoryPercent, - ContainerTotal: c.ContainerTotal, - ContainerRunning: c.ContainerRunning, + ID: c.CustomerID, + Name: c.CustomerName, + ControllerVersion: c.ControllerVersion, + ControllerURL: c.ControllerURL, + HealthStatus: c.HealthStatus, + LastSeen: c.ReceivedAt, + CPUPercent: c.CPUPercent, + MemoryPercent: c.MemoryPercent, + ContainerTotal: c.ContainerTotal, + ContainerRunning: c.ContainerRunning, BackupLastSnapshot: c.BackupLastSnapshot, }) } diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 1b26309..41eebfb 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -29,6 +29,7 @@ type CustomerSummary struct { ContainerRunning int BackupLastSnapshot *time.Time ReportJSON string + ControllerURL string // Computed fields (not stored) TimeSinceReport time.Duration @@ -98,7 +99,14 @@ func (s *Store) migrate() error { updated_at DATETIME NOT NULL DEFAULT (datetime('now')) ); `) - return err + if err != nil { + return err + } + + // v0.1.8: add controller_url column (idempotent — ignore error if already exists) + s.db.Exec("ALTER TABLE reports ADD COLUMN controller_url TEXT") + + return nil } // NotificationPrefs holds per-customer notification preferences. @@ -201,6 +209,7 @@ func (s *Store) SaveReport(customerID string, reportJSON []byte) error { // Parse denormalized fields from the JSON var parsed struct { ControllerVersion string `json:"controller_version"` + ControllerURL string `json:"controller_url"` System struct { CPUPercent float64 `json:"cpu_percent"` MemoryPercent float64 `json:"memory_percent"` @@ -229,13 +238,13 @@ func (s *Store) SaveReport(customerID string, reportJSON []byte) error { _, err := s.db.Exec(` INSERT INTO reports (customer_id, report_json, health_status, cpu_percent, memory_percent, container_total, container_running, - backup_last_snapshot, controller_version) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + backup_last_snapshot, controller_version, controller_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, customerID, string(reportJSON), parsed.Health.Status, parsed.System.CPUPercent, parsed.System.MemoryPercent, parsed.Containers.Total, parsed.Containers.Running, backupSnapshot, - parsed.ControllerVersion, + parsed.ControllerVersion, parsed.ControllerURL, ) return err } @@ -246,7 +255,7 @@ func (s *Store) GetCustomers() ([]CustomerSummary, error) { SELECT r.customer_id, r.received_at, r.report_json, r.health_status, r.cpu_percent, r.memory_percent, r.container_total, r.container_running, - r.backup_last_snapshot, r.controller_version + r.backup_last_snapshot, r.controller_version, r.controller_url FROM reports r INNER JOIN ( SELECT customer_id, MAX(received_at) as max_time @@ -265,11 +274,12 @@ func (s *Store) GetCustomers() ([]CustomerSummary, error) { var c CustomerSummary var receivedAt string var backupSnapshot sql.NullString + var controllerURL sql.NullString if err := rows.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON, &c.HealthStatus, &c.CPUPercent, &c.MemoryPercent, &c.ContainerTotal, &c.ContainerRunning, - &backupSnapshot, &c.ControllerVersion); err != nil { + &backupSnapshot, &c.ControllerVersion, &controllerURL); err != nil { return nil, err } @@ -282,6 +292,9 @@ func (s *Store) GetCustomers() ([]CustomerSummary, error) { c.BackupLastSnapshot = &t } } + if controllerURL.Valid { + c.ControllerURL = controllerURL.String + } // Parse customer_name from JSON var report struct { @@ -306,7 +319,7 @@ func (s *Store) GetCustomer(customerID string) (*CustomerSummary, error) { SELECT customer_id, received_at, report_json, health_status, cpu_percent, memory_percent, container_total, container_running, - backup_last_snapshot, controller_version + backup_last_snapshot, controller_version, controller_url FROM reports WHERE customer_id = ? ORDER BY received_at DESC @@ -315,11 +328,12 @@ func (s *Store) GetCustomer(customerID string) (*CustomerSummary, error) { var c CustomerSummary var receivedAt string var backupSnapshot sql.NullString + var controllerURL sql.NullString if err := row.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON, &c.HealthStatus, &c.CPUPercent, &c.MemoryPercent, &c.ContainerTotal, &c.ContainerRunning, - &backupSnapshot, &c.ControllerVersion); err != nil { + &backupSnapshot, &c.ControllerVersion, &controllerURL); err != nil { if err == sql.ErrNoRows { return nil, nil } @@ -335,6 +349,9 @@ func (s *Store) GetCustomer(customerID string) (*CustomerSummary, error) { c.BackupLastSnapshot = &t } } + if controllerURL.Valid { + c.ControllerURL = controllerURL.String + } var report struct { CustomerName string `json:"customer_name"` @@ -355,7 +372,7 @@ func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]Cu SELECT customer_id, received_at, report_json, health_status, cpu_percent, memory_percent, container_total, container_running, - backup_last_snapshot, controller_version + backup_last_snapshot, controller_version, controller_url FROM reports WHERE customer_id = ? AND received_at >= ? ORDER BY received_at DESC`, customerID, cutoff) @@ -369,11 +386,12 @@ func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]Cu var c CustomerSummary var receivedAt string var backupSnapshot sql.NullString + var controllerURL sql.NullString if err := rows.Scan(&c.CustomerID, &receivedAt, &c.ReportJSON, &c.HealthStatus, &c.CPUPercent, &c.MemoryPercent, &c.ContainerTotal, &c.ContainerRunning, - &backupSnapshot, &c.ControllerVersion); err != nil { + &backupSnapshot, &c.ControllerVersion, &controllerURL); err != nil { return nil, err } @@ -386,6 +404,9 @@ func (s *Store) GetCustomerHistory(customerID string, since time.Duration) ([]Cu c.BackupLastSnapshot = &t } } + if controllerURL.Valid { + c.ControllerURL = controllerURL.String + } history = append(history, c) } diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 0d20fb1..1c26042 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -4,9 +4,11 @@ import ( "encoding/json" "fmt" "html/template" + "io" "log" "math" "net/http" + "strconv" "strings" "time" @@ -16,15 +18,17 @@ import ( // Server handles the dashboard web UI. type Server struct { - store *store.Store - passwordHash string - logger *log.Logger - templates *template.Template + store *store.Store + passwordHash string + apiKey string // report API key — used for controller callbacks + logger *log.Logger + templates *template.Template staleThreshold time.Duration + versionChecker *VersionChecker } // New creates a new web server. -func New(store *store.Store, passwordHash string, staleThreshold time.Duration, logger *log.Logger) *Server { +func New(store *store.Store, passwordHash, apiKey string, staleThreshold time.Duration, logger *log.Logger) *Server { funcMap := template.FuncMap{ "timeAgo": timeAgo, "statusColor": statusColor, @@ -42,12 +46,18 @@ func New(store *store.Store, passwordHash string, staleThreshold time.Duration, return &Server{ store: store, passwordHash: passwordHash, + apiKey: apiKey, logger: logger, templates: tmpl, staleThreshold: staleThreshold, } } +// SetVersionChecker sets the version checker (optional, may be nil if no registry credentials). +func (s *Server) SetVersionChecker(vc *VersionChecker) { + s.versionChecker = vc +} + // ServeHTTP routes web requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path @@ -59,6 +69,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleCSS(w, r) case path == "/login": s.handleLogin(w, r) + case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/trigger-update"): + customerID := strings.TrimPrefix(path, "/customers/") + customerID = strings.TrimSuffix(customerID, "/trigger-update") + if r.Method == http.MethodPost { + s.handleTriggerUpdate(w, r, customerID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } case strings.HasPrefix(path, "/customers/"): customerID := strings.TrimPrefix(path, "/customers/") s.handleCustomerDetail(w, r, customerID) @@ -203,6 +221,29 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu RecentNotifications []store.NotificationLogEntry InfraBackup *store.InfraBackupMeta InfraBackupAge string + ControllerURL string // controller's external URL + LatestVersion string // latest controller image version from registry + UpdateAvailable bool // true if latest > current + } + + // Get controller URL (from denormalized field or report JSON fallback) + controllerURL := customer.ControllerURL + if controllerURL == "" { + var rpt struct { + ControllerURL string `json:"controller_url"` + } + json.Unmarshal([]byte(customer.ReportJSON), &rpt) + controllerURL = rpt.ControllerURL + } + + // Check if update is available + var latestVersion string + var updateAvailable bool + if s.versionChecker != nil { + latestVersion = s.versionChecker.LatestVersion() + if latestVersion != "" && customer.ControllerVersion != "" { + updateAvailable = latestVersion != customer.ControllerVersion && compareVersions(latestVersion, customer.ControllerVersion) > 0 + } } overallStatus := "ok" @@ -230,6 +271,9 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu RecentNotifications: recentNotifs, InfraBackup: infraMeta, InfraBackupAge: infraBackupAge, + ControllerURL: controllerURL, + LatestVersion: latestVersion, + UpdateAvailable: updateAvailable, } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -238,6 +282,96 @@ func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, cu } } +func (s *Server) handleTriggerUpdate(w http.ResponseWriter, r *http.Request, customerID string) { + customer, err := s.store.GetCustomer(customerID) + if err != nil { + s.logger.Printf("[ERROR] Trigger update — get customer %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if customer == nil { + http.NotFound(w, r) + return + } + + // Get controller URL — from denormalized field or report JSON fallback + controllerURL := customer.ControllerURL + if controllerURL == "" { + var rpt struct { + ControllerURL string `json:"controller_url"` + } + json.Unmarshal([]byte(customer.ReportJSON), &rpt) + controllerURL = rpt.ControllerURL + } + if controllerURL == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"ok":false,"error":"Controller URL not available — waiting for next report"}`)) + return + } + + if s.apiKey == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"ok":false,"error":"API key not configured"}`)) + return + } + + // POST to controller's self-update endpoint + updateURL := controllerURL + "/api/selfupdate/update" + req, err := http.NewRequest("POST", updateURL, nil) + if err != nil { + s.logger.Printf("[ERROR] Trigger update — create request for %s: %v", updateURL, err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"ok":false,"error":"Failed to create request"}`)) + return + } + req.Header.Set("Authorization", "Bearer "+s.apiKey) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + s.logger.Printf("[ERROR] Trigger update — request to %s failed: %v", updateURL, err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadGateway) + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)}) + return + } + defer resp.Body.Close() + + // Forward the controller's response + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + s.logger.Printf("[INFO] Trigger update for %s — controller responded %d: %s", customerID, resp.StatusCode, string(body)) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) +} + +// compareVersions returns >0 if a > b, 0 if equal, <0 if a < b. +// Accepts "X.Y.Z" format. Returns 0 on parse error. +func compareVersions(a, b string) int { + a = strings.TrimPrefix(a, "v") + b = strings.TrimPrefix(b, "v") + aParts := strings.SplitN(a, ".", 3) + bParts := strings.SplitN(b, ".", 3) + if len(aParts) != 3 || len(bParts) != 3 { + return 0 + } + for i := 0; i < 3; i++ { + ai, e1 := strconv.Atoi(aParts[i]) + bi, e2 := strconv.Atoi(bParts[i]) + if e1 != nil || e2 != nil { + return 0 + } + if ai != bi { + return ai - bi + } + } + return 0 +} + func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) { data, err := templateFS.ReadFile("templates/style.css") if err != nil { diff --git a/hub/internal/web/templates/customer.html b/hub/internal/web/templates/customer.html index 14ba483..7140d55 100644 --- a/hub/internal/web/templates/customer.html +++ b/hub/internal/web/templates/customer.html @@ -183,6 +183,85 @@ {{end}} + +
+

Controller Update

+
+
+ Current version + v{{.Customer.ControllerVersion}} +
+ {{if .LatestVersion}} +
+ Latest version + + v{{.LatestVersion}} + {{if .UpdateAvailable}} + ● update available + {{else}} + — up to date + {{end}} + +
+ {{end}} + {{if .ControllerURL}} +
+ Controller URL + {{.ControllerURL}} +
+ {{end}} +
+ {{if and .ControllerURL .UpdateAvailable}} +
+ + +
+ {{else if and .ControllerURL (not .LatestVersion)}} +
+ + +

Registry check not configured — cannot verify if update is available

+
+ {{end}} +
+ + +

Notifications

diff --git a/hub/internal/web/version.go b/hub/internal/web/version.go new file mode 100644 index 0000000..c98a32d --- /dev/null +++ b/hub/internal/web/version.go @@ -0,0 +1,146 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// VersionChecker periodically queries the Gitea Docker Registry V2 API +// for the latest controller image version tag. +type VersionChecker struct { + image string // e.g., "gitea.dooplex.hu/admin/felhom-controller" + username string + token string + checkInterval time.Duration + logger *log.Logger + + mu sync.RWMutex + latestVersion string + lastCheck time.Time + lastError string +} + +// NewVersionChecker creates a new VersionChecker. +func NewVersionChecker(image, username, token string, checkInterval time.Duration, logger *log.Logger) *VersionChecker { + return &VersionChecker{ + image: image, + username: username, + token: token, + checkInterval: checkInterval, + logger: logger, + } +} + +// Run starts the periodic version check loop. Call in a goroutine. +// It checks immediately on start, then every checkInterval. +func (vc *VersionChecker) Run(ctx context.Context) { + vc.check() + ticker := time.NewTicker(vc.checkInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + vc.check() + } + } +} + +func (vc *VersionChecker) check() { + latest, err := vc.queryRegistry() + vc.mu.Lock() + defer vc.mu.Unlock() + vc.lastCheck = time.Now() + if err != nil { + vc.lastError = err.Error() + vc.logger.Printf("[WARN] Registry version check failed: %v", err) + return + } + vc.lastError = "" + vc.latestVersion = latest + vc.logger.Printf("[DEBUG] Registry version check: latest = %s", latest) +} + +// LatestVersion returns the cached latest version string (e.g., "0.16.1"), or "" if unknown. +func (vc *VersionChecker) LatestVersion() string { + vc.mu.RLock() + defer vc.mu.RUnlock() + return vc.latestVersion +} + +// queryRegistry queries the Gitea Docker Registry V2 API for available tags +// and returns the highest valid semver tag. +func (vc *VersionChecker) queryRegistry() (string, error) { + // Extract "owner/repo" from image reference + // e.g., "gitea.dooplex.hu/admin/felhom-controller" → "admin/felhom-controller" + imagePath := vc.image + if parts := strings.SplitN(vc.image, "/", 2); len(parts) == 2 { + imagePath = parts[1] + } + + url := fmt.Sprintf("https://gitea.dooplex.hu/v2/%s/tags/list", imagePath) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + req.SetBasicAuth(vc.username, vc.token) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 401 { + return "", fmt.Errorf("authentication failed (401)") + } + if resp.StatusCode != 200 { + return "", fmt.Errorf("unexpected status: %d", resp.StatusCode) + } + + var tagsResp struct { + Tags []string `json:"tags"` + } + if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil { + return "", fmt.Errorf("decoding response: %w", err) + } + + // Find the highest valid semver tag + var bestMajor, bestMinor, bestPatch int + found := false + for _, tag := range tagsResp.Tags { + tag = strings.TrimPrefix(tag, "v") + parts := strings.SplitN(tag, ".", 3) + if len(parts) != 3 { + continue + } + major, e1 := strconv.Atoi(parts[0]) + minor, e2 := strconv.Atoi(parts[1]) + patch, e3 := strconv.Atoi(parts[2]) + if e1 != nil || e2 != nil || e3 != nil { + continue + } + if !found || major > bestMajor || + (major == bestMajor && minor > bestMinor) || + (major == bestMajor && minor == bestMinor && patch > bestPatch) { + bestMajor, bestMinor, bestPatch = major, minor, patch + found = true + } + } + + if !found { + return "", fmt.Errorf("no valid semver tags found") + } + + return fmt.Sprintf("%d.%d.%d", bestMajor, bestMinor, bestPatch), nil +}