diff --git a/hub/README.md b/hub/README.md index bf7051c..4f3bded 100644 --- a/hub/README.md +++ b/hub/README.md @@ -4,7 +4,7 @@ A lightweight Go service that receives periodic reports from felhom-controller instances, stores them in SQLite, and provides a web dashboard for fleet monitoring. Also serves as the infrastructure backup store for disaster recovery. -**Current version: v0.1.6** +**Current version: v0.2.0** --- @@ -38,7 +38,7 @@ A lightweight Go service that receives periodic reports from felhom-controller i ## API Endpoints -All API endpoints require `Authorization: Bearer ` (except `/healthz`). +All API endpoints require `Authorization: Bearer ` (except `/healthz` and `/api/v1/config/{id}`). Auth accepts both the global `report_api_key` and per-customer API keys (generated when creating customer configs). ### Report Ingest @@ -79,6 +79,14 @@ The infra-backup payload contains everything needed to restore a customer deploy Notifications are sent via Resend.com email API. +### Customer Config Retrieval + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/v1/config/{customer_id}` | Download generated controller.yaml (auth: `X-Retrieval-Password` header) | + +Config retrieval uses a separate per-customer retrieval password (not the API key). The Hub generates a complete `controller.yaml` by deep-merging `controller.yaml.example` (periodically fetched from the Gitea repo) with customer-specific overrides (identity, infrastructure tokens, hub API key, session secret). + ### Health | Method | Path | Description | @@ -91,6 +99,7 @@ Protected by bcrypt password + session cookie (7-day expiry). - **Customer overview table:** status indicators (OK/WARN/DOWN), CPU/memory %, disk usage, container counts, backup age, controller version - **Customer detail page:** system info, storage bars, container table, notification preferences, notification log, 24h history graphs +- **Configurations page:** CRUD management for customer configs — pre-configure customer identity, infrastructure secrets, monitoring UUIDs; auto-generates retrieval password + per-customer API key; shows setup commands (`docker-setup.sh` and `curl`); YAML preview - **Auto-refresh:** 60-second cycle - **Status logic:** - Green: report < 30 min old, health = ok @@ -107,6 +116,7 @@ SQLite with WAL mode. Tables: | `infra_backups` | Per-customer infrastructure snapshots for disaster recovery | | `customer_notifications` | Email + enabled event types per customer | | `notification_log` | Send/skip/fail history for notifications | +| `customer_configs` | Pre-configured customer settings, retrieval passwords, per-customer API keys | Retention: configurable (default 90 days), daily prune at 04:30 Budapest time. @@ -131,6 +141,13 @@ retention: alerting: stale_threshold: "30m" # Customer considered stale after this duration +registry: + image: "gitea.dooplex.hu/admin/felhom-controller" + username: "" # Gitea registry credentials + token: "" + check_interval: "30m" # How often to check for new controller versions + template_interval: "1h" # How often to refresh controller.yaml.example + server: listen: ":8080" data_dir: "/data" # SQLite database location diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 9586ddf..c096bd8 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -43,10 +43,11 @@ type Config 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"` + Image string `yaml:"image"` + Username string `yaml:"username"` + Token string `yaml:"token"` + CheckInterval string `yaml:"check_interval"` + TemplateInterval string `yaml:"template_interval"` } `yaml:"registry"` Server struct { Listen string `yaml:"listen"` @@ -88,9 +89,30 @@ func main() { staleThreshold = 30 * time.Minute } - // Initialize handlers - apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, logger) + // Background context for all goroutines + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Initialize template fetcher for customer config generation + var templateFetcher *web.TemplateFetcher + if cfg.Registry.Username != "" && cfg.Registry.Token != "" { + templateInterval, err := time.ParseDuration(cfg.Registry.TemplateInterval) + if err != nil { + templateInterval = 1 * time.Hour + } + templateFetcher = web.NewTemplateFetcher(cfg.Registry.Username, cfg.Registry.Token, templateInterval, logger) + go templateFetcher.Run(ctx) + logger.Printf("[INFO] Template fetcher started (every %s)", cfg.Registry.TemplateInterval) + } + + // Initialize handlers — pass templateFetcher as interface (nil-safe) + var templateProvider api.ConfigTemplateProvider + if templateFetcher != nil { + templateProvider = templateFetcher + } + apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, cfg.Notifications.ResendAPIKey, cfg.Notifications.FromEmail, templateProvider, logger) webServer := web.New(dataStore, cfg.Auth.PasswordHash, cfg.API.ReportAPIKey, staleThreshold, logger) + webServer.SetTemplateFetcher(templateFetcher) // Build HTTP mux mux := http.NewServeMux() @@ -120,10 +142,6 @@ func main() { IdleTimeout: 120 * time.Second, } - // 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 != "" { @@ -211,6 +229,9 @@ func loadConfig(path string, logger *log.Logger) *Config { if cfg.Registry.CheckInterval == "" { cfg.Registry.CheckInterval = "6h" } + if cfg.Registry.TemplateInterval == "" { + cfg.Registry.TemplateInterval = "1h" + } return cfg } diff --git a/hub/configs/hub.yaml.example b/hub/configs/hub.yaml.example index 755c2c3..00e84b6 100644 --- a/hub/configs/hub.yaml.example +++ b/hub/configs/hub.yaml.example @@ -31,6 +31,14 @@ retention: alerting: stale_threshold: "30m" # Customer considered stale if no report for this duration +# --- Registry / template fetching --- +registry: + image: "gitea.dooplex.hu/admin/felhom-controller" + username: "" # Gitea username (for version check + template fetch) + token: "" # Gitea token + check_interval: "6h" # How often to check for new controller versions + template_interval: "1h" # How often to fetch controller.yaml.example for config generation + # --- Server --- server: listen: ":8080" diff --git a/hub/go.mod b/hub/go.mod index fb35fcd..14f20b2 100644 --- a/hub/go.mod +++ b/hub/go.mod @@ -16,7 +16,6 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/sys v0.37.0 // indirect - gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index d5a6b65..16f489c 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "crypto/subtle" "encoding/json" "fmt" "io" @@ -10,31 +11,58 @@ import ( "strings" "time" + "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) +// ConfigTemplateProvider returns the controller.yaml template for config generation. +type ConfigTemplateProvider interface { + Template() string +} + // Handler handles API endpoints for report ingest and customer queries. type Handler struct { - store *store.Store - apiKey string - resendAPIKey string - fromEmail string - logger *log.Logger - httpClient *http.Client + store *store.Store + apiKey string + resendAPIKey string + fromEmail string + logger *log.Logger + httpClient *http.Client + templateProvider ConfigTemplateProvider } // New creates a new API handler. -func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, logger *log.Logger) *Handler { +func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templateProvider ConfigTemplateProvider, logger *log.Logger) *Handler { return &Handler{ - store: store, - apiKey: apiKey, - resendAPIKey: resendAPIKey, - fromEmail: fromEmail, - logger: logger, - httpClient: &http.Client{Timeout: 10 * time.Second}, + store: store, + apiKey: apiKey, + resendAPIKey: resendAPIKey, + fromEmail: fromEmail, + logger: logger, + httpClient: &http.Client{Timeout: 10 * time.Second}, + templateProvider: templateProvider, } } +// checkAuth verifies the Bearer token against the global API key or a per-customer API key. +// Returns true if authorized. +func (h *Handler) checkAuth(r *http.Request) bool { + auth := r.Header.Get("Authorization") + if !strings.HasPrefix(auth, "Bearer ") { + return false + } + token := strings.TrimPrefix(auth, "Bearer ") + + // Check global key first + if h.apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1 { + return true + } + + // Check per-customer key + cfg, err := h.store.GetCustomerConfigByAPIKey(token) + return err == nil && cfg != nil +} + // ServeHTTP routes API requests. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/v1") @@ -60,19 +88,18 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } else { h.handleCustomer(w, r, customerID) } + case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"): + customerID := strings.TrimPrefix(path, "/config/") + h.handleConfigRetrieve(w, r, customerID) default: http.NotFound(w, r) } } func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) { - // Verify bearer token - if h.apiKey != "" { - auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } + if !h.checkAuth(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit @@ -203,13 +230,9 @@ func (h *Handler) handleCustomerHistory(w http.ResponseWriter, r *http.Request, // handleNotify processes notification events from customer controllers. func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) { - // Verify bearer token (same auth as /report) - if h.apiKey != "" { - auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } + if !h.checkAuth(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) @@ -292,13 +315,9 @@ func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) { // handleSavePreferences stores notification preferences pushed from a customer controller. func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request) { - // Same bearer token auth as /report and /notify - if h.apiKey != "" { - auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } + if !h.checkAuth(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) @@ -330,12 +349,9 @@ func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request) // handleInfraBackupPush stores an infrastructure snapshot from a controller. func (h *Handler) handleInfraBackupPush(w http.ResponseWriter, r *http.Request) { - if h.apiKey != "" { - auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } + if !h.checkAuth(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit @@ -365,12 +381,9 @@ func (h *Handler) handleInfraBackupPush(w http.ResponseWriter, r *http.Request) // handleInfraBackupGet returns the infrastructure backup for a customer. func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, customerID string) { - if h.apiKey != "" { - auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } + if !h.checkAuth(r) { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return } if customerID == "" { @@ -393,6 +406,54 @@ func (h *Handler) handleInfraBackupGet(w http.ResponseWriter, r *http.Request, c w.Write(data) } +// handleConfigRetrieve returns a generated controller.yaml for a customer. +// Auth: X-Retrieval-Password header (not Bearer token). +func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, customerID string) { + if customerID == "" { + http.Error(w, "Missing customer_id", http.StatusBadRequest) + return + } + + password := r.Header.Get("X-Retrieval-Password") + if password == "" { + http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized) + return + } + + cfg, err := h.store.GetCustomerConfig(customerID) + if err != nil { + h.logger.Printf("[ERROR] Failed to get customer config for %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if cfg == nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + + // Constant-time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 { + http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized) + return + } + + if h.templateProvider == nil { + http.Error(w, "Config generation not available", http.StatusServiceUnavailable) + return + } + + yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg) + if err != nil { + h.logger.Printf("[ERROR] Failed to generate config for %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + h.logger.Printf("[INFO] Config downloaded for customer %s", customerID) + w.Header().Set("Content-Type", "text/yaml; charset=utf-8") + w.Write([]byte(yamlOutput)) +} + // sendResendEmail sends an email via the Resend HTTP API. func (h *Handler) sendResendEmail(to, subject, textBody string) error { payload := map[string]interface{}{ diff --git a/hub/internal/configgen/configgen.go b/hub/internal/configgen/configgen.go new file mode 100644 index 0000000..db4a104 --- /dev/null +++ b/hub/internal/configgen/configgen.go @@ -0,0 +1,116 @@ +package configgen + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" + "gopkg.in/yaml.v3" +) + +// Generate takes the template YAML and a customer config, +// then produces a complete controller.yaml with customer-specific values +// merged in. The returned string is valid YAML ready for deployment. +func Generate(templateYAML string, cfg *store.CustomerConfig) (string, error) { + // Parse template into generic map + var base map[string]interface{} + if err := yaml.Unmarshal([]byte(templateYAML), &base); err != nil { + return "", fmt.Errorf("parsing template YAML: %w", err) + } + if base == nil { + base = make(map[string]interface{}) + } + + // Parse customer config_json overrides + var overrides map[string]interface{} + if cfg.ConfigJSON != "" && cfg.ConfigJSON != "{}" { + if err := yaml.Unmarshal([]byte(cfg.ConfigJSON), &overrides); err != nil { + return "", fmt.Errorf("parsing config overrides: %w", err) + } + } + + // Apply config_json overrides first (deep merge) + if len(overrides) > 0 { + base = deepMerge(base, overrides) + } + + // Apply programmatic overrides — these always win over config_json + setNested(base, []string{"customer", "id"}, cfg.CustomerID) + setNested(base, []string{"customer", "name"}, cfg.CustomerName) + setNested(base, []string{"customer", "domain"}, cfg.Domain) + setNested(base, []string{"customer", "email"}, cfg.Email) + + setNested(base, []string{"hub", "enabled"}, true) + setNested(base, []string{"hub", "url"}, "https://hub.felhom.eu") + setNested(base, []string{"hub", "api_key"}, cfg.APIKey) + + // Generate session secret + sessionSecret, err := RandomHex(32) + if err != nil { + return "", fmt.Errorf("generating session secret: %w", err) + } + setNested(base, []string{"web", "session_secret"}, sessionSecret) + + // Marshal back to YAML + out, err := yaml.Marshal(base) + if err != nil { + return "", fmt.Errorf("marshaling YAML: %w", err) + } + + // Add header comment + header := fmt.Sprintf( + "# Felhom Controller Configuration\n# Generated by Felhom Hub for %q on %s\n# Download URL: https://hub.felhom.eu/api/v1/config/%s\n\n", + cfg.CustomerID, + time.Now().UTC().Format(time.RFC3339), + cfg.CustomerID, + ) + + return header + string(out), nil +} + +// deepMerge recursively merges overlay into base. +// When both base and overlay have a map at the same key, they are merged recursively. +// Otherwise, the overlay value wins. +func deepMerge(base, overlay map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}, len(base)) + for k, v := range base { + result[k] = v + } + for k, v := range overlay { + if baseMap, ok := result[k].(map[string]interface{}); ok { + if overlayMap, ok := v.(map[string]interface{}); ok { + result[k] = deepMerge(baseMap, overlayMap) + continue + } + } + result[k] = v + } + return result +} + +// setNested sets a value at a nested path in a map, creating intermediate maps as needed. +func setNested(m map[string]interface{}, path []string, value interface{}) { + for i, key := range path { + if i == len(path)-1 { + m[key] = value + return + } + sub, ok := m[key].(map[string]interface{}) + if !ok { + sub = make(map[string]interface{}) + m[key] = sub + } + m = sub + } +} + +// RandomHex generates n random bytes and returns them as a hex string. +func RandomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 41eebfb..e7e2ac6 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -98,6 +98,18 @@ func (s *Store) migrate() error { backup_json TEXT NOT NULL, updated_at DATETIME NOT NULL DEFAULT (datetime('now')) ); + + CREATE TABLE IF NOT EXISTS customer_configs ( + customer_id TEXT PRIMARY KEY, + customer_name TEXT NOT NULL DEFAULT '', + domain TEXT NOT NULL DEFAULT '', + email TEXT NOT NULL DEFAULT '', + retrieval_password TEXT NOT NULL, + api_key TEXT NOT NULL, + config_json TEXT NOT NULL DEFAULT '{}', + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + updated_at DATETIME NOT NULL DEFAULT (datetime('now')) + ); `) if err != nil { return err @@ -499,6 +511,129 @@ func (s *Store) Close() error { return s.db.Close() } +// CustomerConfig holds a pre-provisioned customer configuration. +type CustomerConfig struct { + CustomerID string + CustomerName string + Domain string + Email string + RetrievalPassword string + APIKey string + ConfigJSON string // JSON object with customer-specific override fields + CreatedAt time.Time + UpdatedAt time.Time +} + +// SaveCustomerConfig creates or updates a customer configuration. +func (s *Store) SaveCustomerConfig(cfg *CustomerConfig) error { + _, err := s.db.Exec(` + INSERT INTO customer_configs (customer_id, customer_name, domain, email, + retrieval_password, api_key, config_json, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(customer_id) DO UPDATE SET + customer_name = excluded.customer_name, + domain = excluded.domain, + email = excluded.email, + retrieval_password = excluded.retrieval_password, + api_key = excluded.api_key, + config_json = excluded.config_json, + updated_at = datetime('now')`, + cfg.CustomerID, cfg.CustomerName, cfg.Domain, cfg.Email, + cfg.RetrievalPassword, cfg.APIKey, cfg.ConfigJSON, + ) + return err +} + +// GetCustomerConfig returns a customer configuration by ID, or nil if not found. +func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) { + var cfg CustomerConfig + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT customer_id, customer_name, domain, email, + retrieval_password, api_key, config_json, created_at, updated_at + FROM customer_configs WHERE customer_id = ?`, + customerID, + ).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email, + &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, + &createdAt, &updatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + cfg.CreatedAt = parseSQLiteTime(createdAt) + cfg.UpdatedAt = parseSQLiteTime(updatedAt) + return &cfg, nil +} + +// ListCustomerConfigs returns all customer configurations ordered by ID. +func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) { + rows, err := s.db.Query(` + SELECT customer_id, customer_name, domain, email, + retrieval_password, api_key, config_json, created_at, updated_at + FROM customer_configs ORDER BY customer_id`) + if err != nil { + return nil, err + } + defer rows.Close() + + var configs []CustomerConfig + for rows.Next() { + var cfg CustomerConfig + var createdAt, updatedAt string + if err := rows.Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email, + &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, + &createdAt, &updatedAt); err != nil { + return nil, err + } + cfg.CreatedAt = parseSQLiteTime(createdAt) + cfg.UpdatedAt = parseSQLiteTime(updatedAt) + configs = append(configs, cfg) + } + return configs, rows.Err() +} + +// DeleteCustomerConfig deletes a customer configuration. +func (s *Store) DeleteCustomerConfig(customerID string) error { + _, err := s.db.Exec("DELETE FROM customer_configs WHERE customer_id = ?", customerID) + return err +} + +// GetCustomerConfigByAPIKey looks up a customer config by its unique API key. +// Returns nil if no matching key is found. +func (s *Store) GetCustomerConfigByAPIKey(apiKey string) (*CustomerConfig, error) { + var cfg CustomerConfig + var createdAt, updatedAt string + err := s.db.QueryRow(` + SELECT customer_id, customer_name, domain, email, + retrieval_password, api_key, config_json, created_at, updated_at + FROM customer_configs WHERE api_key = ?`, + apiKey, + ).Scan(&cfg.CustomerID, &cfg.CustomerName, &cfg.Domain, &cfg.Email, + &cfg.RetrievalPassword, &cfg.APIKey, &cfg.ConfigJSON, + &createdAt, &updatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + cfg.CreatedAt = parseSQLiteTime(createdAt) + cfg.UpdatedAt = parseSQLiteTime(updatedAt) + return &cfg, nil +} + +// UpdateRetrievalPassword updates the retrieval password for a customer config. +func (s *Store) UpdateRetrievalPassword(customerID, newPassword string) error { + _, err := s.db.Exec(` + UPDATE customer_configs SET retrieval_password = ?, updated_at = datetime('now') + WHERE customer_id = ?`, + newPassword, customerID, + ) + return err +} + // parseSQLiteTime tries multiple formats that modernc.org/sqlite may return. func parseSQLiteTime(s string) time.Time { formats := []string{ diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go new file mode 100644 index 0000000..ad24c3c --- /dev/null +++ b/hub/internal/web/configs.go @@ -0,0 +1,338 @@ +package web + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + + "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`) + +// handleConfigList shows all customer configurations. +func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) { + configs, err := s.store.ListCustomerConfigs() + if err != nil { + s.logger.Printf("[ERROR] Failed to list configs: %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + data := struct { + Configs []store.CustomerConfig + ActiveNav string + Flash string + }{ + Configs: configs, + ActiveNav: "configs", + Flash: r.URL.Query().Get("flash"), + } + s.templates.ExecuteTemplate(w, "configs.html", data) +} + +// handleConfigNewForm shows the form to create a new customer config. +func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) { + data := struct { + IsNew bool + Config *store.CustomerConfig + Overrides map[string]interface{} + ActiveNav string + Error string + }{ + IsNew: true, + Config: &store.CustomerConfig{}, + Overrides: make(map[string]interface{}), + ActiveNav: "configs", + } + s.templates.ExecuteTemplate(w, "config_form.html", data) +} + +// handleConfigCreate processes the form submission to create a new config. +func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + customerID := strings.TrimSpace(r.FormValue("customer_id")) + if customerID == "" || !validCustomerID.MatchString(customerID) { + s.renderConfigForm(w, true, &store.CustomerConfig{ + CustomerName: r.FormValue("customer_name"), + Domain: r.FormValue("domain"), + Email: r.FormValue("email"), + }, nil, "Invalid Customer ID. Use only letters, numbers, dots, and hyphens.") + return + } + + // Check for duplicates + existing, _ := s.store.GetCustomerConfig(customerID) + if existing != nil { + s.renderConfigForm(w, true, &store.CustomerConfig{ + CustomerID: customerID, + CustomerName: r.FormValue("customer_name"), + Domain: r.FormValue("domain"), + Email: r.FormValue("email"), + }, nil, fmt.Sprintf("Customer ID %q already exists.", customerID)) + return + } + + // Generate credentials + retrievalPassword, err := configgen.RandomHex(32) + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + apiKey, err := configgen.RandomHex(32) + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + // Build config_json from optional form fields + configJSON := buildConfigJSON(r) + + cfg := &store.CustomerConfig{ + CustomerID: customerID, + CustomerName: strings.TrimSpace(r.FormValue("customer_name")), + Domain: strings.TrimSpace(r.FormValue("domain")), + Email: strings.TrimSpace(r.FormValue("email")), + RetrievalPassword: retrievalPassword, + APIKey: apiKey, + ConfigJSON: configJSON, + } + + if err := s.store.SaveCustomerConfig(cfg); err != nil { + s.logger.Printf("[ERROR] Failed to save config for %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + s.logger.Printf("[INFO] Customer config created: %s", customerID) + http.Redirect(w, r, "/configs/"+customerID+"?flash=created", http.StatusSeeOther) +} + +// handleConfigDetail shows a customer config with credentials and setup commands. +func (s *Server) handleConfigDetail(w http.ResponseWriter, r *http.Request, customerID string) { + cfg, err := s.store.GetCustomerConfig(customerID) + if err != nil { + s.logger.Printf("[ERROR] Failed to get config %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + if cfg == nil { + http.NotFound(w, r) + return + } + + // Parse config_json for display + var overrides map[string]interface{} + json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) + + data := struct { + Config *store.CustomerConfig + Overrides map[string]interface{} + ActiveNav string + Flash string + }{ + Config: cfg, + Overrides: overrides, + ActiveNav: "configs", + Flash: r.URL.Query().Get("flash"), + } + s.templates.ExecuteTemplate(w, "config_detail.html", data) +} + +// handleConfigEditForm shows the edit form for a customer config. +func (s *Server) handleConfigEditForm(w http.ResponseWriter, r *http.Request, customerID string) { + cfg, err := s.store.GetCustomerConfig(customerID) + if err != nil || cfg == nil { + http.NotFound(w, r) + return + } + + var overrides map[string]interface{} + json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) + + data := struct { + IsNew bool + Config *store.CustomerConfig + Overrides map[string]interface{} + ActiveNav string + Error string + }{ + IsNew: false, + Config: cfg, + Overrides: overrides, + ActiveNav: "configs", + } + s.templates.ExecuteTemplate(w, "config_form.html", data) +} + +// handleConfigUpdate processes the edit form submission. +func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, customerID string) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + cfg, err := s.store.GetCustomerConfig(customerID) + if err != nil || cfg == nil { + http.NotFound(w, r) + return + } + + cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name")) + cfg.Domain = strings.TrimSpace(r.FormValue("domain")) + cfg.Email = strings.TrimSpace(r.FormValue("email")) + cfg.ConfigJSON = buildConfigJSON(r) + + if err := s.store.SaveCustomerConfig(cfg); err != nil { + s.logger.Printf("[ERROR] Failed to update config for %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + s.logger.Printf("[INFO] Customer config updated: %s", customerID) + http.Redirect(w, r, "/configs/"+customerID+"?flash=updated", http.StatusSeeOther) +} + +// handleConfigDelete deletes a customer config. +func (s *Server) handleConfigDelete(w http.ResponseWriter, r *http.Request, customerID string) { + if err := s.store.DeleteCustomerConfig(customerID); err != nil { + s.logger.Printf("[ERROR] Failed to delete config %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + s.logger.Printf("[INFO] Customer config deleted: %s", customerID) + http.Redirect(w, r, "/configs?flash=deleted", http.StatusSeeOther) +} + +// handleConfigPreview returns the generated YAML for a customer config. +func (s *Server) handleConfigPreview(w http.ResponseWriter, r *http.Request, customerID string) { + cfg, err := s.store.GetCustomerConfig(customerID) + if err != nil || cfg == nil { + http.NotFound(w, r) + return + } + + templateYAML := defaultControllerTemplate + if s.templateFetcher != nil { + templateYAML = s.templateFetcher.Template() + } + + yamlOutput, err := configgen.Generate(templateYAML, cfg) + if err != nil { + s.logger.Printf("[ERROR] Failed to generate preview for %s: %v", customerID, err) + http.Error(w, "Generation error: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/yaml; charset=utf-8") + w.Write([]byte(yamlOutput)) +} + +// handleConfigRegenPassword regenerates the retrieval password. +func (s *Server) handleConfigRegenPassword(w http.ResponseWriter, r *http.Request, customerID string) { + newPassword, err := configgen.RandomHex(32) + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + if err := s.store.UpdateRetrievalPassword(customerID, newPassword); err != nil { + s.logger.Printf("[ERROR] Failed to regen password for %s: %v", customerID, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + s.logger.Printf("[INFO] Retrieval password regenerated for %s", customerID) + http.Redirect(w, r, "/configs/"+customerID+"?flash=password_regenerated", http.StatusSeeOther) +} + +// renderConfigForm is a helper to re-render the form with an error. +func (s *Server) renderConfigForm(w http.ResponseWriter, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) { + if overrides == nil { + overrides = make(map[string]interface{}) + } + data := struct { + IsNew bool + Config *store.CustomerConfig + Overrides map[string]interface{} + ActiveNav string + Error string + }{ + IsNew: isNew, + Config: cfg, + Overrides: overrides, + ActiveNav: "configs", + Error: errMsg, + } + s.templates.ExecuteTemplate(w, "config_form.html", data) +} + +// buildConfigJSON builds the config_json from optional form fields. +func buildConfigJSON(r *http.Request) string { + overrides := make(map[string]interface{}) + + // Infrastructure + infra := make(map[string]interface{}) + if v := strings.TrimSpace(r.FormValue("cf_tunnel_token")); v != "" { + infra["cf_tunnel_token"] = v + } + if v := strings.TrimSpace(r.FormValue("cf_api_token")); v != "" { + infra["cf_api_token"] = v + } + if len(infra) > 0 { + overrides["infrastructure"] = infra + } + + // Git + git := make(map[string]interface{}) + if v := strings.TrimSpace(r.FormValue("git_username")); v != "" { + git["username"] = v + } + if v := strings.TrimSpace(r.FormValue("git_token")); v != "" { + git["token"] = v + } + if len(git) > 0 { + overrides["git"] = git + } + + // Monitoring UUIDs + uuids := make(map[string]interface{}) + for _, key := range []string{"heartbeat", "system_health", "db_dump", "backup", "backup_integrity"} { + if v := strings.TrimSpace(r.FormValue("uuid_" + key)); v != "" { + uuids[key] = v + } + } + if len(uuids) > 0 { + if _, ok := overrides["monitoring"]; !ok { + overrides["monitoring"] = make(map[string]interface{}) + } + overrides["monitoring"].(map[string]interface{})["ping_uuids"] = uuids + } + + data, _ := json.Marshal(overrides) + return string(data) +} + +// getNestedString is a helper to extract a nested string from a map. +func getNestedString(m map[string]interface{}, keys ...string) string { + var current interface{} = m + for _, key := range keys { + if cm, ok := current.(map[string]interface{}); ok { + current = cm[key] + } else { + return "" + } + } + if s, ok := current.(string); ok { + return s + } + return "" +} diff --git a/hub/internal/web/controller.yaml.default b/hub/internal/web/controller.yaml.default new file mode 100644 index 0000000..cf974d3 --- /dev/null +++ b/hub/internal/web/controller.yaml.default @@ -0,0 +1,140 @@ +# ============================================================================= +# Felhom Controller Configuration +# ============================================================================= +# Location: /opt/docker/felhom-controller/controller.yaml +# +# This file contains ONLY infrastructure and customer identity config. +# Application-specific configuration (passwords, paths, etc.) is handled +# interactively during first deployment via the dashboard UI and stored +# per-app in /opt/docker/stacks//app.yaml +# +# Environment variable overrides: FELHOM_
_ +# (e.g., FELHOM_CUSTOMER_DOMAIN=example.hu) +# ============================================================================= + +# --- Customer identity --- +customer: + id: "demo-felhom" # Unique customer identifier + name: "Demo Ügyfél" # Display name (shown on dashboard) + domain: "demo-felhom.eu" # Base domain for all services + email: "" # Customer notification email (optional) + telegram_chat_id: "" # Telegram notifications (optional, future) + +# --- Infrastructure secrets --- +infrastructure: + cf_tunnel_token: "" # Cloudflare Tunnel token + cf_api_token: "" # Cloudflare API token (DNS-01 challenge) + +# --- Paths (system-level only) --- +paths: + stacks_dir: "/opt/docker/stacks" # Where compose files live + data_dir: "/opt/docker/felhom-controller/data" + system_data_path: "/mnt/sys_drive" # Mount point of user-data partition on system drive (e.g., /mnt/sys_drive) + +# --- System --- +system: + reserved_memory_mb: 384 # Memory reserved for OS (excluded from app budget) + +# --- Web UI --- +web: + listen: ":8080" + # Bcrypt hash. Empty = first-visit setup prompt. + password_hash: "" + session_secret: "" # Auto-generated on first start + +# --- Git synchronization --- +git: + repo_url: "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git" + branch: "main" + sync_interval: "15m" + username: "" + token: "" + +# --- Stack management --- +stacks: + protected: + - "traefik" + - "cloudflared" + - "felhom-controller" + - "filebrowser" + update_window: "03:00-05:00" + compose_command: "" + +# --- Backup --- +# Per-drive backup paths are computed automatically: +# /backups/primary/restic/ — restic repo per drive +# /backups/primary//db-dumps/ — DB dumps per app +# /backups/secondary/ — cross-drive rsync + restic +backup: + enabled: true + restic_password_file: "/opt/docker/felhom-controller/data/restic-password" + db_dump_schedule: "02:30" + restic_schedule: "03:00" + retention: + keep_daily: 7 + keep_weekly: 4 + keep_monthly: 6 + prune_schedule: "weekly" + +# --- Monitoring --- +monitoring: + enabled: true + healthchecks_base: "https://status.felhom.eu" + ping_uuids: + heartbeat: "" # Every 5 min — controller process alive + system_health: "" # Every 5 min — comprehensive system check + db_dump: "" # Daily — after database dumps + backup: "" # Daily — after restic snapshot + backup_integrity: "" # Weekly (Sunday) — restic check + system_health_interval: "5m" + health_check_schedule: "06:00" + thresholds: + disk_warn_percent: 80 + disk_crit_percent: 90 + backup_max_age_hours: 36 + cpu_warn_percent: 90 + memory_warn_percent: 85 + temperature_warn_celsius: 75 + +# --- Central hub (operator dashboard) --- +hub: + enabled: true # Enable central reporting + url: "https://hub.felhom.eu" # Hub API endpoint + api_key: "" # Per-customer API key + push_interval: "15m" # How often to push reports + +# --- Self-update --- +self_update: + enabled: true + check_interval: "6h" + image: "gitea.dooplex.hu/admin/felhom-controller" + auto_update: false + health_timeout_seconds: 60 + +# --- Notifications --- +notifications: + customer_events: + - "disk_warning" + - "backup_failed" + - "update_available" + - "security_update" + operator_events: + - "disk_critical" + - "backup_failed" + - "self_update_failed" + - "container_unhealthy" + +# --- Logging --- +logging: + level: "info" + file: "" + max_size_mb: 10 + max_files: 3 + +# --- Assets --- +assets: + # App logos, screenshots, and descriptions are baked into the container + # image at build time (from the felhom.eu website assets). + # Served locally at /static/assets/ — no external dependency. + # The source URL is only used during image build, not at runtime. + source_url: "https://felhom.eu" diff --git a/hub/internal/web/embed.go b/hub/internal/web/embed.go index f0f4a48..0ed578d 100644 --- a/hub/internal/web/embed.go +++ b/hub/internal/web/embed.go @@ -4,3 +4,6 @@ import "embed" //go:embed templates/* var templateFS embed.FS + +//go:embed controller.yaml.default +var defaultControllerTemplate string diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 1c26042..be10917 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -18,13 +18,14 @@ import ( // Server handles the dashboard web UI. type Server struct { - 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 + 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 + templateFetcher *TemplateFetcher } // New creates a new web server. @@ -58,6 +59,11 @@ func (s *Server) SetVersionChecker(vc *VersionChecker) { s.versionChecker = vc } +// SetTemplateFetcher sets the template fetcher for config generation (optional). +func (s *Server) SetTemplateFetcher(tf *TemplateFetcher) { + s.templateFetcher = tf +} + // ServeHTTP routes web requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path @@ -80,6 +86,46 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { case strings.HasPrefix(path, "/customers/"): customerID := strings.TrimPrefix(path, "/customers/") s.handleCustomerDetail(w, r, customerID) + // Config management routes — exact matches first, then prefix matches + case path == "/configs": + s.handleConfigList(w, r) + case path == "/configs/new": + if r.Method == http.MethodPost { + s.handleConfigCreate(w, r) + } else { + s.handleConfigNewForm(w, r) + } + case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"): + customerID := strings.TrimPrefix(path, "/configs/") + customerID = strings.TrimSuffix(customerID, "/delete") + if r.Method == http.MethodPost { + s.handleConfigDelete(w, r, customerID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/edit"): + customerID := strings.TrimPrefix(path, "/configs/") + customerID = strings.TrimSuffix(customerID, "/edit") + if r.Method == http.MethodPost { + s.handleConfigUpdate(w, r, customerID) + } else { + s.handleConfigEditForm(w, r, customerID) + } + case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/preview"): + customerID := strings.TrimPrefix(path, "/configs/") + customerID = strings.TrimSuffix(customerID, "/preview") + s.handleConfigPreview(w, r, customerID) + case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/regen-password"): + customerID := strings.TrimPrefix(path, "/configs/") + customerID = strings.TrimSuffix(customerID, "/regen-password") + if r.Method == http.MethodPost { + s.handleConfigRegenPassword(w, r, customerID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + case strings.HasPrefix(path, "/configs/"): + customerID := strings.TrimPrefix(path, "/configs/") + s.handleConfigDetail(w, r, customerID) default: http.NotFound(w, r) } diff --git a/hub/internal/web/templatefetcher.go b/hub/internal/web/templatefetcher.go new file mode 100644 index 0000000..cf618ed --- /dev/null +++ b/hub/internal/web/templatefetcher.go @@ -0,0 +1,113 @@ +package web + +import ( + "context" + "fmt" + "io" + "log" + "net/http" + "sync" + "time" +) + +const templateRawURL = "https://gitea.dooplex.hu/admin/deploy-felhom-compose/raw/branch/main/controller/configs/controller.yaml.example" + +// TemplateFetcher periodically fetches controller.yaml.example from the Gitea +// repo and caches it for config generation. Falls back to go:embed default. +type TemplateFetcher struct { + username string + token string + fetchInterval time.Duration + logger *log.Logger + + mu sync.RWMutex + cachedTemplate string + lastFetch time.Time + lastError string +} + +// NewTemplateFetcher creates a new TemplateFetcher. +func NewTemplateFetcher(username, token string, fetchInterval time.Duration, logger *log.Logger) *TemplateFetcher { + return &TemplateFetcher{ + username: username, + token: token, + fetchInterval: fetchInterval, + logger: logger, + } +} + +// Run starts the periodic fetch loop. Call in a goroutine. +// It fetches immediately on start, then every fetchInterval. +func (tf *TemplateFetcher) Run(ctx context.Context) { + tf.fetch() + ticker := time.NewTicker(tf.fetchInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + tf.fetch() + } + } +} + +func (tf *TemplateFetcher) fetch() { + req, err := http.NewRequest("GET", templateRawURL, nil) + if err != nil { + tf.mu.Lock() + tf.lastError = err.Error() + tf.mu.Unlock() + tf.logger.Printf("[WARN] Template fetch: failed to create request: %v", err) + return + } + if tf.username != "" && tf.token != "" { + req.SetBasicAuth(tf.username, tf.token) + } + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + tf.mu.Lock() + tf.lastError = err.Error() + tf.mu.Unlock() + tf.logger.Printf("[WARN] Template fetch: HTTP request failed: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + tf.mu.Lock() + tf.lastError = fmt.Sprintf("HTTP %d", resp.StatusCode) + tf.mu.Unlock() + tf.logger.Printf("[WARN] Template fetch: unexpected status %d", resp.StatusCode) + return + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) // 64KB max + if err != nil { + tf.mu.Lock() + tf.lastError = err.Error() + tf.mu.Unlock() + tf.logger.Printf("[WARN] Template fetch: read error: %v", err) + return + } + + tf.mu.Lock() + tf.cachedTemplate = string(body) + tf.lastFetch = time.Now() + tf.lastError = "" + tf.mu.Unlock() + tf.logger.Printf("[DEBUG] Template fetched (%d bytes)", len(body)) +} + +// Template returns the cached controller.yaml template. +// If the cache is empty (never fetched or all failures), returns the go:embed fallback. +func (tf *TemplateFetcher) Template() string { + tf.mu.RLock() + defer tf.mu.RUnlock() + if tf.cachedTemplate != "" { + return tf.cachedTemplate + } + return defaultControllerTemplate +} diff --git a/hub/internal/web/templates/config_detail.html b/hub/internal/web/templates/config_detail.html new file mode 100644 index 0000000..5380af3 --- /dev/null +++ b/hub/internal/web/templates/config_detail.html @@ -0,0 +1,153 @@ + + + + + + Felhom Hub — {{.Config.CustomerID}} + + + +
+
+

Felhom Hub

+ +
+ + ← All Configurations + + {{if .Flash}} +
+ {{if eq .Flash "created"}}Configuration created successfully. + {{else if eq .Flash "updated"}}Configuration updated. + {{else if eq .Flash "password_regenerated"}}Retrieval password regenerated. + {{end}} +
+ {{end}} + +
+

+ {{.Config.CustomerID}} + {{if .Config.CustomerName}} — {{.Config.CustomerName}}{{end}} +

+
+ Edit +
+ +
+
+
+ +
+

Customer Details

+
+
+ Customer ID + {{.Config.CustomerID}} +
+
+ Name + {{if .Config.CustomerName}}{{.Config.CustomerName}}{{else}}—{{end}} +
+
+ Domain + {{if .Config.Domain}}{{.Config.Domain}}{{else}}—{{end}} +
+
+ Email + {{if .Config.Email}}{{.Config.Email}}{{else}}—{{end}} +
+
+ Created + {{timeAgo .Config.CreatedAt}} +
+
+ Updated + {{timeAgo .Config.UpdatedAt}} +
+
+
+ +
+

Credentials

+
+
+ Retrieval Password +
+ {{.Config.RetrievalPassword}} + +
+
+
+ +
+
+
+
+ API Key +
+ {{.Config.APIKey}} + +
+
+ Used by the controller for ongoing hub communication (reports, notifications, backups) +
+
+ +
+

Setup Commands

+

Use one of these methods to configure a customer node:

+ +

Option 1: docker-setup.sh (recommended)

+
+ sudo ./docker-setup.sh --hub-customer {{.Config.CustomerID}} --hub-password {{.Config.RetrievalPassword}} + +
+ +

Option 2: Direct download

+
+ curl -fsSL https://hub.felhom.eu/api/v1/config/{{.Config.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml + +
+
+ +
+

YAML Preview

+
+

Loading preview...

+
+
+ +
+

Felhom Hub — Configuration Management

+
+
+ + + + diff --git a/hub/internal/web/templates/config_form.html b/hub/internal/web/templates/config_form.html new file mode 100644 index 0000000..b7c5456 --- /dev/null +++ b/hub/internal/web/templates/config_form.html @@ -0,0 +1,148 @@ + + + + + + Felhom Hub — {{if .IsNew}}New Configuration{{else}}Edit {{.Config.CustomerID}}{{end}} + + + +
+
+

Felhom Hub

+ +
+ + ← Back +

{{if .IsNew}}New Customer Configuration{{else}}Edit: {{.Config.CustomerID}}{{end}}

+ + {{if .Error}} +
{{.Error}}
+ {{end}} + +
+
+

Customer Identity

+
+
+ + + {{if .IsNew}}Letters, numbers, dots, hyphens only{{end}} +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Infrastructure

+
+
+ + +
+
+ + +
+
+
+ +
+

Git Sync

+
+
+ + +
+
+ + +
+
+
+ +
+

Monitoring UUIDs

+
+ {{$uuids := ""}} + {{with .Overrides}}{{with index . "monitoring"}}{{with index . "ping_uuids"}}{{$uuids = .}}{{end}}{{end}}{{end}} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + Cancel +
+
+ +
+

Felhom Hub — Configuration Management

+
+
+ + diff --git a/hub/internal/web/templates/configs.html b/hub/internal/web/templates/configs.html new file mode 100644 index 0000000..7d78f17 --- /dev/null +++ b/hub/internal/web/templates/configs.html @@ -0,0 +1,63 @@ + + + + + + Felhom Hub — Configurations + + + +
+
+

Felhom Hub

+ +
+ + {{if .Flash}} +
+ {{if eq .Flash "deleted"}}Configuration deleted.{{end}} +
+ {{end}} + +
+

Customer Configurations

+ + New Configuration +
+ + {{if not .Configs}} +
+

No customer configurations yet.

+

Create a configuration to pre-provision a customer node.

+
+ {{else}} + + + + + + + + + + + {{range .Configs}} + + + + + + + {{end}} + +
Customer IDNameDomainCreated
{{.CustomerID}}{{if .CustomerName}}{{.CustomerName}}{{else}}{{end}}{{if .Domain}}{{.Domain}}{{else}}{{end}}{{timeAgo .CreatedAt}}
+ {{end}} + +
+

Felhom Hub — Configuration Management

+
+
+ + diff --git a/hub/internal/web/templates/customer.html b/hub/internal/web/templates/customer.html index 7140d55..8769359 100644 --- a/hub/internal/web/templates/customer.html +++ b/hub/internal/web/templates/customer.html @@ -10,6 +10,10 @@
+ ← Back to Dashboard

{{statusIcon .OverallStatus}} diff --git a/hub/internal/web/templates/dashboard.html b/hub/internal/web/templates/dashboard.html index f8936a8..48de88e 100644 --- a/hub/internal/web/templates/dashboard.html +++ b/hub/internal/web/templates/dashboard.html @@ -11,7 +11,10 @@

Felhom Hub

-

Customer Overview Dashboard

+
{{if not .}} diff --git a/hub/internal/web/templates/style.css b/hub/internal/web/templates/style.css index a6eb63b..cf4e73a 100644 --- a/hub/internal/web/templates/style.css +++ b/hub/internal/web/templates/style.css @@ -339,6 +339,202 @@ code { color: var(--accent); } +/* Navigation */ +.nav-links { + display: flex; + gap: 1.5rem; + margin-top: 0.5rem; +} + +.nav-link { + color: var(--text-secondary); + text-decoration: none; + font-size: 0.9rem; + padding-bottom: 2px; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; +} + +.nav-link:hover { + color: var(--text-primary); +} + +.nav-link.active { + color: var(--accent); + border-bottom-color: var(--accent); + font-weight: 600; +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 0.5rem 1rem; + background: var(--accent); + color: #0f172a; + border: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: opacity 0.15s; +} + +.btn:hover { opacity: 0.85; } + +.btn-outline { + background: transparent; + color: var(--text-secondary); + border: 1px solid var(--border); +} + +.btn-outline:hover { + color: var(--text-primary); + border-color: var(--text-secondary); +} + +.btn-danger { + background: var(--red); + color: #fff; +} + +.btn-sm { + padding: 0.3rem 0.6rem; + font-size: 0.8rem; +} + +/* Forms */ +.config-form .form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.form-group label { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.form-group input[type="text"], +.form-group input[type="email"] { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + color: var(--text-primary); + font-size: 0.9rem; + font-family: inherit; +} + +.form-group input:focus { + outline: none; + border-color: var(--accent); +} + +.form-group input[readonly] { + opacity: 0.6; + cursor: not-allowed; +} + +.form-hint { + font-size: 0.75rem; + color: var(--text-muted); +} + +/* Credentials */ +.credential-row { + margin-bottom: 0.5rem; +} + +.credential-box { + display: flex; + align-items: center; + gap: 0.5rem; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + margin-top: 0.25rem; + overflow-x: auto; +} + +.credential-box code { + flex: 1; + font-size: 0.8rem; + word-break: break-all; + color: var(--text-primary); +} + +.copy-btn { + background: transparent; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + padding: 0.2rem 0.5rem; + font-size: 0.85rem; + flex-shrink: 0; + transition: color 0.15s, border-color 0.15s; +} + +.copy-btn:hover { + color: var(--accent); + border-color: var(--accent); +} + +/* Flash messages */ +.flash { + padding: 0.75rem 1rem; + border-radius: 6px; + margin-bottom: 1rem; + font-size: 0.9rem; +} + +.flash-success { + background: rgba(74, 222, 128, 0.1); + color: var(--green); + border: 1px solid rgba(74, 222, 128, 0.3); +} + +.flash-error { + background: rgba(248, 113, 113, 0.1); + color: var(--red); + border: 1px solid rgba(248, 113, 113, 0.3); +} + +/* YAML Preview */ +.yaml-preview { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 1rem; + max-height: 500px; + overflow: auto; +} + +.yaml-preview pre { + font-family: var(--font-mono); + font-size: 0.8rem; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + margin: 0; +} + +/* Muted text */ +.text-muted { + color: var(--text-muted); +} + /* Responsive */ @media (max-width: 768px) { .container { padding: 1rem; }