feat: customer config management — CRUD, API retrieval, per-customer auth (v0.2.0)

New "Configurations" section lets operators pre-configure customer settings
in the Hub, then docker-setup.sh can download a ready-made controller.yaml
using just a customer ID and retrieval password.

- Store: customer_configs table with CRUD + per-customer API key lookup
- API: GET /api/v1/config/{id} with X-Retrieval-Password auth
- Auth: per-customer API keys alongside existing global key (backward compatible)
- Web UI: /configs list, create, edit, delete, YAML preview, copy-to-clipboard
- YAML gen: deep-merge controller.yaml.example template with customer overrides
- Template fetcher: background goroutine refreshing template from Gitea repo
- Navigation: Dashboard / Configurations tabs on all pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 13:36:32 +01:00
parent 36a7d1c162
commit 4c8bf63ce3
18 changed files with 1631 additions and 67 deletions
+19 -2
View File
@@ -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 <report_api_key>` (except `/healthz`).
All API endpoints require `Authorization: Bearer <api_key>` (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
+27 -6
View File
@@ -47,6 +47,7 @@ type Config struct {
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
}
+8
View File
@@ -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"
-1
View File
@@ -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
+85 -24
View File
@@ -2,6 +2,7 @@ package api
import (
"bytes"
"crypto/subtle"
"encoding/json"
"fmt"
"io"
@@ -10,9 +11,15 @@ 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
@@ -21,10 +28,11 @@ type Handler struct {
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,
@@ -32,9 +40,29 @@ func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, logger *log
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,20 +88,19 @@ 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 {
if !h.checkAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
if err != nil {
@@ -203,14 +230,10 @@ 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 {
if !h.checkAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
@@ -292,14 +315,10 @@ 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 {
if !h.checkAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
@@ -330,13 +349,10 @@ 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 {
if !h.checkAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
if err != nil {
@@ -365,13 +381,10 @@ 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 {
if !h.checkAuth(r) {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
if customerID == "" {
http.Error(w, "Missing customer_id", http.StatusBadRequest)
@@ -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{}{
+116
View File
@@ -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
}
+135
View File
@@ -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{
+338
View File
@@ -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 ""
}
+140
View File
@@ -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>/app.yaml
#
# Environment variable overrides: FELHOM_<SECTION>_<KEY>
# (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:
# <drive>/backups/primary/restic/ — restic repo per drive
# <drive>/backups/primary/<app>/db-dumps/ — DB dumps per app
# <drive>/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"
+3
View File
@@ -4,3 +4,6 @@ import "embed"
//go:embed templates/*
var templateFS embed.FS
//go:embed controller.yaml.default
var defaultControllerTemplate string
+46
View File
@@ -25,6 +25,7 @@ type Server struct {
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)
}
+113
View File
@@ -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
}
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Felhom Hub — {{.Config.CustomerID}}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<header>
<h1>Felhom Hub</h1>
<nav class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link active">Configurations</a>
</nav>
</header>
<a href="/configs" class="back-link">&larr; All Configurations</a>
{{if .Flash}}
<div class="flash flash-success">
{{if eq .Flash "created"}}Configuration created successfully.
{{else if eq .Flash "updated"}}Configuration updated.
{{else if eq .Flash "password_regenerated"}}Retrieval password regenerated.
{{end}}
</div>
{{end}}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">
<code>{{.Config.CustomerID}}</code>
{{if .Config.CustomerName}}<span class="text-muted" style="font-weight: 400;"> — {{.Config.CustomerName}}</span>{{end}}
</h2>
<div style="display: flex; gap: 0.5rem;">
<a href="/configs/{{.Config.CustomerID}}/edit" class="btn btn-outline">Edit</a>
<form method="POST" action="/configs/{{.Config.CustomerID}}/delete" style="display:inline"
onsubmit="return confirm('Delete configuration for {{.Config.CustomerID}}? This cannot be undone.')">
<button type="submit" class="btn btn-danger">Delete</button>
</form>
</div>
</div>
<div class="card">
<h2>Customer Details</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Customer ID</span>
<span class="value"><code>{{.Config.CustomerID}}</code></span>
</div>
<div class="info-item">
<span class="label">Name</span>
<span class="value">{{if .Config.CustomerName}}{{.Config.CustomerName}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Domain</span>
<span class="value">{{if .Config.Domain}}{{.Config.Domain}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Email</span>
<span class="value">{{if .Config.Email}}{{.Config.Email}}{{else}}—{{end}}</span>
</div>
<div class="info-item">
<span class="label">Created</span>
<span class="value">{{timeAgo .Config.CreatedAt}}</span>
</div>
<div class="info-item">
<span class="label">Updated</span>
<span class="value">{{timeAgo .Config.UpdatedAt}}</span>
</div>
</div>
</div>
<div class="card">
<h2>Credentials</h2>
<div class="credential-row">
<div>
<span class="label">Retrieval Password</span>
<div class="credential-box">
<code id="retrieval-pw">{{.Config.RetrievalPassword}}</code>
<button type="button" class="copy-btn" onclick="copyText('retrieval-pw')" title="Copy">&#x2398;</button>
</div>
</div>
<form method="POST" action="/configs/{{.Config.CustomerID}}/regen-password" style="margin-top: 0.5rem;"
onsubmit="return confirm('Regenerate retrieval password? The old password will stop working immediately.')">
<button type="submit" class="btn btn-outline btn-sm">Regenerate</button>
</form>
</div>
<div class="credential-row" style="margin-top: 1rem;">
<div>
<span class="label">API Key</span>
<div class="credential-box">
<code id="api-key">{{.Config.APIKey}}</code>
<button type="button" class="copy-btn" onclick="copyText('api-key')" title="Copy">&#x2398;</button>
</div>
</div>
<span class="form-hint">Used by the controller for ongoing hub communication (reports, notifications, backups)</span>
</div>
</div>
<div class="card">
<h2>Setup Commands</h2>
<p class="text-muted" style="margin-bottom: 1rem; font-size: 0.85rem;">Use one of these methods to configure a customer node:</p>
<h3>Option 1: docker-setup.sh (recommended)</h3>
<div class="credential-box">
<code id="cmd-setup">sudo ./docker-setup.sh --hub-customer {{.Config.CustomerID}} --hub-password {{.Config.RetrievalPassword}}</code>
<button type="button" class="copy-btn" onclick="copyText('cmd-setup')" title="Copy">&#x2398;</button>
</div>
<h3 style="margin-top: 1rem;">Option 2: Direct download</h3>
<div class="credential-box">
<code id="cmd-curl">curl -fsSL https://hub.felhom.eu/api/v1/config/{{.Config.CustomerID}} -H "X-Retrieval-Password: {{.Config.RetrievalPassword}}" -o controller.yaml</code>
<button type="button" class="copy-btn" onclick="copyText('cmd-curl')" title="Copy">&#x2398;</button>
</div>
</div>
<div class="card">
<h2>YAML Preview</h2>
<div id="yaml-preview" class="yaml-preview">
<p class="text-muted">Loading preview...</p>
</div>
</div>
<footer>
<p>Felhom Hub — Configuration Management</p>
</footer>
</div>
<script>
function copyText(elementId) {
const el = document.getElementById(elementId);
const text = el.textContent || el.innerText;
navigator.clipboard.writeText(text.trim()).then(function() {
const btn = el.parentElement.querySelector('.copy-btn');
const orig = btn.innerHTML;
btn.innerHTML = '&#x2713;';
setTimeout(function() { btn.innerHTML = orig; }, 1500);
});
}
// Load YAML preview
fetch('/configs/{{.Config.CustomerID}}/preview')
.then(function(r) { return r.text(); })
.then(function(yaml) {
document.getElementById('yaml-preview').innerHTML = '<pre>' + yaml.replace(/&/g,'&amp;').replace(/</g,'&lt;') + '</pre>';
})
.catch(function() {
document.getElementById('yaml-preview').innerHTML = '<p class="text-muted">Failed to load preview.</p>';
});
</script>
</body>
</html>
+148
View File
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Felhom Hub — {{if .IsNew}}New Configuration{{else}}Edit {{.Config.CustomerID}}{{end}}</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<header>
<h1>Felhom Hub</h1>
<nav class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link active">Configurations</a>
</nav>
</header>
<a href="{{if .IsNew}}/configs{{else}}/configs/{{.Config.CustomerID}}{{end}}" class="back-link">&larr; Back</a>
<h2>{{if .IsNew}}New Customer Configuration{{else}}Edit: {{.Config.CustomerID}}{{end}}</h2>
{{if .Error}}
<div class="flash flash-error">{{.Error}}</div>
{{end}}
<form method="POST" action="{{if .IsNew}}/configs/new{{else}}/configs/{{.Config.CustomerID}}/edit{{end}}" class="config-form">
<div class="card">
<h2>Customer Identity</h2>
<div class="form-grid">
<div class="form-group">
<label for="customer_id">Customer ID *</label>
<input type="text" id="customer_id" name="customer_id"
value="{{.Config.CustomerID}}"
pattern="[a-zA-Z0-9.\-]+"
placeholder="e.g. customer-1"
{{if not .IsNew}}readonly{{end}}
required>
{{if .IsNew}}<span class="form-hint">Letters, numbers, dots, hyphens only</span>{{end}}
</div>
<div class="form-group">
<label for="customer_name">Display Name *</label>
<input type="text" id="customer_name" name="customer_name"
value="{{.Config.CustomerName}}"
placeholder="e.g. Kovács család"
required>
</div>
<div class="form-group">
<label for="domain">Domain *</label>
<input type="text" id="domain" name="domain"
value="{{.Config.Domain}}"
placeholder="e.g. kovacs.felhom.eu"
required>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email"
value="{{.Config.Email}}"
placeholder="e.g. kovacs@example.com">
</div>
</div>
</div>
<details class="card" {{if index .Overrides "infrastructure"}}open{{end}}>
<summary><h2 style="display:inline">Infrastructure</h2></summary>
<div class="form-grid" style="margin-top: 1rem;">
<div class="form-group">
<label for="cf_tunnel_token">Cloudflare Tunnel Token</label>
<input type="text" id="cf_tunnel_token" name="cf_tunnel_token"
value="{{with .Overrides}}{{with index . "infrastructure"}}{{with index . "cf_tunnel_token"}}{{.}}{{end}}{{end}}{{end}}"
placeholder="Optional">
</div>
<div class="form-group">
<label for="cf_api_token">Cloudflare API Token</label>
<input type="text" id="cf_api_token" name="cf_api_token"
value="{{with .Overrides}}{{with index . "infrastructure"}}{{with index . "cf_api_token"}}{{.}}{{end}}{{end}}{{end}}"
placeholder="For DNS-01 challenge">
</div>
</div>
</details>
<details class="card" {{if index .Overrides "git"}}open{{end}}>
<summary><h2 style="display:inline">Git Sync</h2></summary>
<div class="form-grid" style="margin-top: 1rem;">
<div class="form-group">
<label for="git_username">Git Username</label>
<input type="text" id="git_username" name="git_username"
value="{{with .Overrides}}{{with index . "git"}}{{with index . "username"}}{{.}}{{end}}{{end}}{{end}}"
placeholder="For private catalog">
</div>
<div class="form-group">
<label for="git_token">Git Token</label>
<input type="text" id="git_token" name="git_token"
value="{{with .Overrides}}{{with index . "git"}}{{with index . "token"}}{{.}}{{end}}{{end}}{{end}}"
placeholder="For private catalog">
</div>
</div>
</details>
<details class="card" {{if index .Overrides "monitoring"}}open{{end}}>
<summary><h2 style="display:inline">Monitoring UUIDs</h2></summary>
<div class="form-grid" style="margin-top: 1rem;">
{{$uuids := ""}}
{{with .Overrides}}{{with index . "monitoring"}}{{with index . "ping_uuids"}}{{$uuids = .}}{{end}}{{end}}{{end}}
<div class="form-group">
<label for="uuid_heartbeat">Heartbeat</label>
<input type="text" id="uuid_heartbeat" name="uuid_heartbeat"
value="{{with $uuids}}{{with index . "heartbeat"}}{{.}}{{end}}{{end}}"
placeholder="UUID">
</div>
<div class="form-group">
<label for="uuid_system_health">System Health</label>
<input type="text" id="uuid_system_health" name="uuid_system_health"
value="{{with $uuids}}{{with index . "system_health"}}{{.}}{{end}}{{end}}"
placeholder="UUID">
</div>
<div class="form-group">
<label for="uuid_db_dump">DB Dump</label>
<input type="text" id="uuid_db_dump" name="uuid_db_dump"
value="{{with $uuids}}{{with index . "db_dump"}}{{.}}{{end}}{{end}}"
placeholder="UUID">
</div>
<div class="form-group">
<label for="uuid_backup">Backup</label>
<input type="text" id="uuid_backup" name="uuid_backup"
value="{{with $uuids}}{{with index . "backup"}}{{.}}{{end}}{{end}}"
placeholder="UUID">
</div>
<div class="form-group">
<label for="uuid_backup_integrity">Backup Integrity</label>
<input type="text" id="uuid_backup_integrity" name="uuid_backup_integrity"
value="{{with $uuids}}{{with index . "backup_integrity"}}{{.}}{{end}}{{end}}"
placeholder="UUID">
</div>
</div>
</details>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button type="submit" class="btn">{{if .IsNew}}Create Configuration{{else}}Save Changes{{end}}</button>
<a href="{{if .IsNew}}/configs{{else}}/configs/{{.Config.CustomerID}}{{end}}" class="btn btn-outline">Cancel</a>
</div>
</form>
<footer>
<p>Felhom Hub — Configuration Management</p>
</footer>
</div>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Felhom Hub — Configurations</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="container">
<header>
<h1>Felhom Hub</h1>
<nav class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link active">Configurations</a>
</nav>
</header>
{{if .Flash}}
<div class="flash flash-success">
{{if eq .Flash "deleted"}}Configuration deleted.{{end}}
</div>
{{end}}
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Customer Configurations</h2>
<a href="/configs/new" class="btn">+ New Configuration</a>
</div>
{{if not .Configs}}
<div class="empty-state">
<p>No customer configurations yet.</p>
<p class="hint">Create a configuration to pre-provision a customer node.</p>
</div>
{{else}}
<table class="dashboard-table">
<thead>
<tr>
<th>Customer ID</th>
<th>Name</th>
<th>Domain</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{{range .Configs}}
<tr onclick="window.location='/configs/{{.CustomerID}}'">
<td><code>{{.CustomerID}}</code></td>
<td>{{if .CustomerName}}{{.CustomerName}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td>{{if .Domain}}{{.Domain}}{{else}}<span class="text-muted"></span>{{end}}</td>
<td>{{timeAgo .CreatedAt}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
<footer>
<p>Felhom Hub — Configuration Management</p>
</footer>
</div>
</body>
</html>
+4
View File
@@ -10,6 +10,10 @@
<body>
<div class="container">
<header>
<nav class="nav-links" style="margin-bottom: 0.5rem;">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link">Configurations</a>
</nav>
<a href="/" class="back-link">&larr; Back to Dashboard</a>
<h1>
<span class="status-dot" style="color: {{statusColor .OverallStatus}}">{{statusIcon .OverallStatus}}</span>
+4 -1
View File
@@ -11,7 +11,10 @@
<div class="container">
<header>
<h1>Felhom Hub</h1>
<p class="subtitle">Customer Overview Dashboard</p>
<nav class="nav-links">
<a href="/" class="nav-link active">Dashboard</a>
<a href="/configs" class="nav-link">Configurations</a>
</nav>
</header>
{{if not .}}
+196
View File
@@ -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; }