added all files

This commit is contained in:
kisfenyo
2026-02-13 16:59:51 +01:00
parent 5f67151a2f
commit 83d86cb841
12 changed files with 1770 additions and 5 deletions
+32
View File
@@ -0,0 +1,32 @@
# Build artifacts
bin/
*.exe
*.dll
*.so
*.dylib
# Test artifacts
coverage.out
coverage.html
*.test
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Go
vendor/
# Docker
*.tar
# Local config (don't commit real customer configs)
controller.yaml
restic-password
+301
View File
@@ -0,0 +1,301 @@
package stacks
import (
"crypto/rand"
"encoding/hex"
"fmt"
"math/big"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// AppConfig holds the per-app deployment configuration.
// Saved as app.yaml in each stack directory after first deployment.
type AppConfig struct {
Deployed bool `yaml:"deployed" json:"deployed"`
DeployedAt string `yaml:"deployed_at" json:"deployed_at"`
Env map[string]string `yaml:"env" json:"env"`
LockedFields []string `yaml:"locked_fields" json:"locked_fields"`
}
// DeployRequest contains the user-provided values from the deploy form.
type DeployRequest struct {
StackName string `json:"stack_name"`
Values map[string]string `json:"values"` // env_var -> user-provided value
}
// DeployStack handles first-time deployment of an app:
// 1. Load metadata (.felhom.yml) to know what fields exist
// 2. Auto-generate secrets for secret/password fields without user values
// 3. Auto-fill domain from controller config
// 4. Merge with user-provided values
// 5. Save app.yaml
// 6. Run docker compose up -d with env vars
func (m *Manager) DeployStack(req DeployRequest) error {
stack, ok := m.GetStack(req.StackName)
if !ok {
return fmt.Errorf("stack %q not found", req.StackName)
}
stackDir := filepath.Dir(stack.ComposePath)
meta := LoadMetadata(stackDir)
// Check if already deployed
existing := LoadAppConfig(stackDir)
if existing != nil && existing.Deployed {
return fmt.Errorf("stack %q is already deployed; use update instead", req.StackName)
}
// Build the full env map
env := make(map[string]string)
var lockedFields []string
for _, field := range meta.DeployFields {
var value string
switch field.Type {
case "domain":
// Auto-fill from controller config
value = m.cfg.Customer.Domain
case "secret":
// Always auto-generate, user never sees these
generated, err := generateValue(field.Generate)
if err != nil {
return fmt.Errorf("generating %s: %w", field.EnvVar, err)
}
value = generated
case "password":
// Use user value if provided, otherwise generate
if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
value = userVal
} else if field.Generate != "" {
generated, err := generateValue(field.Generate)
if err != nil {
return fmt.Errorf("generating %s: %w", field.EnvVar, err)
}
value = generated
}
default:
// text, path, select, boolean — use user value or default
if userVal, ok := req.Values[field.EnvVar]; ok {
value = userVal
} else if field.Default != "" {
value = field.Default
}
}
// Validate required fields
if field.Required && value == "" {
return fmt.Errorf("required field %q (%s) is empty", field.Label, field.EnvVar)
}
// Validate path fields exist
if field.Type == "path" && value != "" {
if _, err := os.Stat(value); os.IsNotExist(err) {
return fmt.Errorf("path %q does not exist for field %q", value, field.Label)
}
}
if value != "" {
env[field.EnvVar] = value
}
if field.LockedAfterDeploy {
lockedFields = append(lockedFields, field.EnvVar)
}
}
// Save app.yaml
appCfg := &AppConfig{
Deployed: true,
DeployedAt: time.Now().UTC().Format(time.RFC3339),
Env: env,
LockedFields: lockedFields,
}
if err := SaveAppConfig(stackDir, appCfg); err != nil {
return fmt.Errorf("saving app config: %w", err)
}
m.logger.Printf("[INFO] Deploying stack %s with %d env vars", req.StackName, len(env))
// Run docker compose up -d
_, err := m.composeExecWithEnv(stackDir, env, "up", "-d")
if err != nil {
// Deployment failed — keep app.yaml for debugging but mark as not deployed
appCfg.Deployed = false
_ = SaveAppConfig(stackDir, appCfg)
return fmt.Errorf("docker compose up failed: %w", err)
}
m.logger.Printf("[INFO] Stack %s deployed successfully", req.StackName)
return m.RefreshStatus()
}
// UpdateStackConfig updates non-locked fields for a deployed stack.
func (m *Manager) UpdateStackConfig(name string, values map[string]string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
stackDir := filepath.Dir(stack.ComposePath)
appCfg := LoadAppConfig(stackDir)
if appCfg == nil || !appCfg.Deployed {
return fmt.Errorf("stack %q is not deployed yet", name)
}
// Apply changes, respecting locked fields
lockedSet := make(map[string]bool)
for _, f := range appCfg.LockedFields {
lockedSet[f] = true
}
for key, val := range values {
if lockedSet[key] {
return fmt.Errorf("field %q is locked and cannot be changed after deployment", key)
}
appCfg.Env[key] = val
}
if err := SaveAppConfig(stackDir, appCfg); err != nil {
return fmt.Errorf("saving updated config: %w", err)
}
// Restart with new env
_, err := m.composeExecWithEnv(stackDir, appCfg.Env, "up", "-d")
if err != nil {
return fmt.Errorf("restarting with new config: %w", err)
}
m.logger.Printf("[INFO] Stack %s config updated and restarted", name)
return m.RefreshStatus()
}
// composeExecWithEnv runs a compose command with custom env vars injected.
func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) {
// Build env slice: start with os env, then add our vars
cmdEnv := os.Environ()
for k, v := range env {
cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
}
// Always inject DOMAIN from controller config
cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
return m.composeExecCustomEnv(dir, cmdEnv, args...)
}
// GetDeployFields returns the deployment fields for a stack (for the deploy form).
func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) {
stack, ok := m.GetStack(name)
if !ok {
return nil, nil, fmt.Errorf("stack %q not found", name)
}
stackDir := filepath.Dir(stack.ComposePath)
meta := LoadMetadata(stackDir)
appCfg := LoadAppConfig(stackDir)
return &meta, appCfg, nil
}
// --- App config persistence ---
// LoadAppConfig reads app.yaml from a stack directory.
// Returns nil if the file doesn't exist.
func LoadAppConfig(stackDir string) *AppConfig {
path := filepath.Join(stackDir, "app.yaml")
data, err := os.ReadFile(path)
if err != nil {
return nil
}
cfg := &AppConfig{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil
}
return cfg
}
// SaveAppConfig writes app.yaml to a stack directory.
func SaveAppConfig(stackDir string, cfg *AppConfig) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling app config: %w", err)
}
path := filepath.Join(stackDir, "app.yaml")
header := "# Auto-generated by felhom-controller — do not edit locked fields manually\n"
content := header + string(data)
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
return nil
}
// --- Secret generation ---
const alphanumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// generateValue creates a random value based on the generator spec.
// Formats: "password:N", "hex:N", "static:VALUE"
func generateValue(spec string) (string, error) {
if spec == "" {
return "", fmt.Errorf("empty generator spec")
}
parts := strings.SplitN(spec, ":", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid generator spec: %q (expected type:param)", spec)
}
genType := parts[0]
param := parts[1]
switch genType {
case "password":
length := 0
if _, err := fmt.Sscanf(param, "%d", &length); err != nil || length <= 0 {
return "", fmt.Errorf("invalid password length: %q", param)
}
return randomAlphanumeric(length)
case "hex":
byteLen := 0
if _, err := fmt.Sscanf(param, "%d", &byteLen); err != nil || byteLen <= 0 {
return "", fmt.Errorf("invalid hex length: %q", param)
}
b := make([]byte, byteLen)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("reading random bytes: %w", err)
}
return hex.EncodeToString(b), nil
case "static":
return param, nil
default:
return "", fmt.Errorf("unknown generator type: %q", genType)
}
}
func randomAlphanumeric(length int) (string, error) {
result := make([]byte, length)
for i := range result {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphanumChars))))
if err != nil {
return "", err
}
result[i] = alphanumChars[n.Int64()]
}
return string(result), nil
}
+54
View File
@@ -0,0 +1,54 @@
# =============================================================================
# felhom-controller Docker Compose
# This is deployed as an infrastructure component alongside Traefik/Cloudflared
# =============================================================================
services:
felhom-controller:
image: gitea.dooplex.hu/admin/felhom-controller:latest
container_name: felhom-controller
restart: unless-stopped
ports:
- "8080:8080"
volumes:
# Docker socket — required for compose operations
- /var/run/docker.sock:/var/run/docker.sock:ro
# Controller config
- /opt/docker/felhom-controller/controller.yaml:/opt/docker/felhom-controller/controller.yaml:ro
# Controller persistent data (sessions, state)
- controller-data:/opt/docker/felhom-controller/data
# Stack compose files (read + write for git sync)
- /opt/docker/stacks:/opt/docker/stacks
# Backup directories
- /srv/backups:/srv/backups
# Restic password file
- /opt/docker/felhom-controller/restic-password:/opt/docker/felhom-controller/restic-password:ro
# HDD mount (if available, for backup paths)
- ${HDD_PATH:-/mnt/hdd_placeholder}:${HDD_PATH:-/mnt/hdd_placeholder}:ro
environment:
- TZ=Europe/Budapest
labels:
- "traefik.enable=true"
- "traefik.http.routers.controller.rule=Host(`dashboard.${DOMAIN}`)"
- "traefik.http.routers.controller.entrypoints=websecure"
- "traefik.http.routers.controller.tls=true"
- "traefik.http.services.controller.loadbalancer.server.port=8080"
- "traefik.docker.network=traefik-public"
# Health check labels for monitoring
- "felhom.managed=true"
- "felhom.component=controller"
networks:
- traefik-public
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
volumes:
controller-data:
networks:
traefik-public:
external: true
+106
View File
@@ -0,0 +1,106 @@
# =============================================================================
# .felhom.yml — App metadata for felhom-controller
# =============================================================================
# Place alongside docker-compose.yml in each stack directory:
# /opt/docker/stacks/paperless-ngx/.felhom.yml
#
# This file defines:
# 1. Display info (name, description, icon)
# 2. Deploy fields (what the user fills in during first deployment)
# 3. Asset references (logos, screenshots loaded from felhom.eu)
# 4. Resource hints (RAM, Pi compatibility)
# =============================================================================
# --- Display info (shown on dashboard) ---
display_name: "Paperless-ngx"
description: "Dokumentumok digitalizálása és rendszerezése"
category: "productivity" # productivity, media, finance, security, tools
subdomain: "paperless" # -> paperless.<domain>
# --- Asset slug ---
# Used to construct URLs for logo and screenshots from felhom.eu:
# Logo: {assets.base_url}/assets/{slug}-logo.webp
# Screenshot: {assets.base_url}/assets/{slug}-screenshot-{n}.webp
# App page: {assets.base_url}/alkalmazasok#{slug}
# Falls back to directory name if not set.
slug: "paperless-ngx"
# --- Resource hints (displayed on deploy screen) ---
resources:
ram: "~500MB"
pi_compatible: true # Runs on Raspberry Pi 3B+
needs_hdd: true # Needs external storage for user data
# --- Deploy fields ---
# Shown to the user during first deployment.
# After deployment, values are saved to app.yaml in the stack directory.
#
# Field types:
# domain - Auto-filled from controller config, read-only
# secret - Auto-generated, hidden (user sees "Generated ✓")
# password - Auto-generated but shown, user can override
# path - Filesystem path (validated for existence)
# text - Free text input
# select - Dropdown with predefined options
# boolean - Toggle switch
#
# Generator types (for secret/password):
# password:N - N chars alphanumeric
# hex:N - N bytes hex-encoded
# static:VAL - Fixed value
deploy_fields:
- env_var: DOMAIN
label: "Domain"
type: domain
description: "A szerver domain neve"
locked_after_deploy: true
- env_var: DB_PASSWORD
label: "Adatbázis jelszó"
type: secret
generate: "password:24"
locked_after_deploy: true
- env_var: PAPERLESS_SECRET_KEY
label: "Titkosítási kulcs"
type: secret
generate: "hex:32"
locked_after_deploy: true
- env_var: PAPERLESS_ADMIN_USER
label: "Admin felhasználónév"
type: text
default: "admin"
locked_after_deploy: false
- env_var: PAPERLESS_ADMIN_PASSWORD
label: "Admin jelszó"
type: password
generate: "password:16"
description: "Első bejelentkezéshez. Utána a webes felületen módosítható."
locked_after_deploy: false
- env_var: HDD_PATH
label: "Adattárolási útvonal"
type: path
required: true
placeholder: "/mnt/hdd_1"
description: "A külső merevlemez elérési útja, ahol a dokumentumok tárolódnak"
locked_after_deploy: true
- env_var: PAPERLESS_OCR_LANGUAGE
label: "OCR nyelv"
type: select
default: "hun+eng"
options:
- value: "hun"
label: "Magyar"
- value: "eng"
label: "Angol"
- value: "hun+eng"
label: "Magyar + Angol"
- value: "deu+eng"
label: "Német + Angol"
description: "Dokumentum felismerés nyelve"
locked_after_deploy: false
+8
View File
@@ -0,0 +1,8 @@
module gitea.dooplex.hu/admin/felhom-controller
go 1.22
require (
gopkg.in/yaml.v3 v3.0.1
golang.org/x/crypto v0.31.0
)
+27
View File
@@ -0,0 +1,27 @@
package main
import (
"fmt"
"os"
"golang.org/x/crypto/bcrypt"
)
// Usage: go run scripts/hashpass.go <password>
// Outputs a bcrypt hash suitable for controller.yaml password_hash field.
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "Usage: %s <password>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Generates a bcrypt hash for the felhom-controller config.\n")
os.Exit(1)
}
password := os.Args[1]
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating hash: %v\n", err)
os.Exit(1)
}
fmt.Println(string(hash))
}
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/api"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
)
var (
// Set at build time via ldflags
Version = "dev"
BuildTime = "unknown"
GitCommit = "unknown"
)
func main() {
configPath := flag.String("config", "/opt/docker/felhom-controller/controller.yaml", "Path to configuration file")
showVersion := flag.Bool("version", false, "Show version and exit")
flag.Parse()
if *showVersion {
fmt.Printf("felhom-controller %s (built %s, commit %s)\n", Version, BuildTime, GitCommit)
os.Exit(0)
}
// --- Load configuration ---
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("[FATAL] Failed to load config from %s: %v", *configPath, err)
}
logger := setupLogger(cfg)
logger.Printf("[INFO] felhom-controller %s starting (customer: %s, domain: %s)",
Version, cfg.Customer.ID, cfg.Customer.Domain)
// --- Initialize stack manager ---
stackMgr, err := stacks.NewManager(cfg, logger)
if err != nil {
logger.Fatalf("[FATAL] Failed to initialize stack manager: %v", err)
}
// Initial stack scan
if err := stackMgr.ScanStacks(); err != nil {
logger.Printf("[WARN] Initial stack scan failed: %v", err)
}
// --- Initialize API router ---
apiRouter := api.NewRouter(cfg, stackMgr, logger)
// --- Initialize web server ---
webServer := web.NewServer(cfg, stackMgr, logger, Version)
// --- Build HTTP mux ---
mux := http.NewServeMux()
// API routes (no auth for health endpoint, auth for everything else)
mux.HandleFunc("/api/health", apiRouter.HealthHandler)
mux.Handle("/api/", webServer.RequireAuth(http.HandlerFunc(apiRouter.ServeHTTP)))
// Web UI routes (auth required)
mux.Handle("/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHTTP)))
// --- Start HTTP server ---
server := &http.Server{
Addr: cfg.Web.Listen,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
// --- Graceful shutdown ---
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
logger.Printf("[INFO] Received signal %v, shutting down...", sig)
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
if err := server.Shutdown(shutdownCtx); err != nil {
logger.Printf("[ERROR] HTTP server shutdown error: %v", err)
}
}()
// --- Start background tasks ---
// Periodic stack status refresh
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := stackMgr.RefreshStatus(); err != nil {
logger.Printf("[WARN] Status refresh failed: %v", err)
}
}
}
}()
logger.Printf("[INFO] Web UI listening on %s", cfg.Web.Listen)
if err := server.ListenAndServe(); err != http.ErrServerClosed {
logger.Fatalf("[FATAL] HTTP server error: %v", err)
}
logger.Println("[INFO] felhom-controller stopped")
}
func setupLogger(cfg *config.Config) *log.Logger {
// For now, log to stdout. File logging will be added later.
logger := log.New(os.Stdout, "", log.LstdFlags)
if cfg.Logging.Level == "debug" {
logger.SetFlags(log.LstdFlags | log.Lshortfile)
}
return logger
}
+452
View File
@@ -0,0 +1,452 @@
package stacks
import (
"bytes"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// ContainerState represents the current state of a container.
type ContainerState string
const (
StateRunning ContainerState = "running"
StateStopped ContainerState = "stopped"
StateRestarting ContainerState = "restarting"
StateExited ContainerState = "exited"
StatePaused ContainerState = "paused"
StateUnknown ContainerState = "unknown"
StateNotDeployed ContainerState = "not_deployed"
)
// ContainerInfo holds status info about a single container within a stack.
type ContainerInfo struct {
Name string `json:"name"`
Image string `json:"image"`
State ContainerState `json:"state"`
Status string `json:"status"` // e.g. "Up 3 hours"
}
// Stack represents a docker compose stack on disk.
type Stack struct {
Name string `json:"name"`
Meta Metadata `json:"meta"`
ComposePath string `json:"compose_path"`
State ContainerState `json:"state"`
Deployed bool `json:"deployed"` // Has app.yaml with deployed=true
Protected bool `json:"protected"`
Containers []ContainerInfo `json:"containers"`
AppConfig *AppConfig `json:"app_config,omitempty"`
LastUpdated time.Time `json:"last_updated"`
}
// Manager handles all docker compose stack operations.
type Manager struct {
cfg *config.Config
logger *log.Logger
composeCmd string
stacks map[string]*Stack
mu sync.RWMutex
}
// NewManager creates a new stack manager.
func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) {
composeCmd := cfg.Stacks.ComposeCommand
if composeCmd == "" {
composeCmd = detectComposeCommand()
}
if composeCmd == "" {
return nil, fmt.Errorf("docker compose not found (tried 'docker compose' and 'docker-compose')")
}
logger.Printf("[INFO] Using compose command: %s", composeCmd)
if err := os.MkdirAll(cfg.Paths.StacksDir, 0755); err != nil {
return nil, fmt.Errorf("creating stacks directory %s: %w", cfg.Paths.StacksDir, err)
}
return &Manager{
cfg: cfg,
logger: logger,
composeCmd: composeCmd,
stacks: make(map[string]*Stack),
}, nil
}
// toTitleCase capitalizes the first letter of each word.
func toTitleCase(s string) string {
words := strings.Fields(s)
for i, w := range words {
if len(w) > 0 {
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
}
return strings.Join(words, " ")
}
func detectComposeCommand() string {
if err := exec.Command("docker", "compose", "version").Run(); err == nil {
return "docker compose"
}
if _, err := exec.LookPath("docker-compose"); err == nil {
return "docker-compose"
}
return ""
}
// ScanStacks discovers all compose stacks in the stacks directory.
func (m *Manager) ScanStacks() error {
m.mu.Lock()
defer m.mu.Unlock()
entries, err := os.ReadDir(m.cfg.Paths.StacksDir)
if err != nil {
return fmt.Errorf("reading stacks directory: %w", err)
}
found := make(map[string]bool)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
stackDir := filepath.Join(m.cfg.Paths.StacksDir, name)
composePath := filepath.Join(stackDir, "docker-compose.yml")
if _, err := os.Stat(composePath); os.IsNotExist(err) {
composePath = filepath.Join(stackDir, "docker-compose.yaml")
if _, err := os.Stat(composePath); os.IsNotExist(err) {
continue
}
}
found[name] = true
meta := LoadMetadata(stackDir)
appCfg := LoadAppConfig(stackDir)
deployed := appCfg != nil && appCfg.Deployed
if existing, ok := m.stacks[name]; ok {
existing.ComposePath = composePath
existing.Meta = meta
existing.Protected = m.cfg.IsProtectedStack(name)
existing.Deployed = deployed
existing.AppConfig = appCfg
} else {
m.stacks[name] = &Stack{
Name: name,
Meta: meta,
ComposePath: composePath,
State: StateNotDeployed,
Deployed: deployed,
Protected: m.cfg.IsProtectedStack(name),
AppConfig: appCfg,
}
}
}
// Remove stacks no longer on disk
for name := range m.stacks {
if !found[name] {
delete(m.stacks, name)
}
}
m.logger.Printf("[INFO] Scanned stacks: %d found", len(m.stacks))
return m.refreshStatusLocked()
}
// RefreshStatus updates container status for all known stacks.
func (m *Manager) RefreshStatus() error {
m.mu.Lock()
defer m.mu.Unlock()
return m.refreshStatusLocked()
}
func (m *Manager) refreshStatusLocked() error {
output, err := m.execCommand("docker", "ps", "-a",
"--format", "{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Label \"com.docker.compose.project\"}}",
"--no-trunc")
if err != nil {
return fmt.Errorf("docker ps: %w", err)
}
projectContainers := make(map[string][]ContainerInfo)
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 5)
if len(parts) < 5 || parts[4] == "" {
continue
}
ci := ContainerInfo{
Name: parts[0],
Image: parts[1],
State: parseContainerState(parts[2]),
Status: parts[3],
}
projectContainers[parts[4]] = append(projectContainers[parts[4]], ci)
}
for name, stack := range m.stacks {
containers, exists := projectContainers[name]
if !exists {
stack.Containers = nil
if stack.Deployed {
stack.State = StateStopped
} else {
stack.State = StateNotDeployed
}
} else {
stack.Containers = containers
stack.State = aggregateState(containers)
}
stack.LastUpdated = time.Now()
}
return nil
}
func parseContainerState(s string) ContainerState {
switch strings.ToLower(strings.TrimSpace(s)) {
case "running":
return StateRunning
case "exited":
return StateExited
case "restarting":
return StateRestarting
case "paused":
return StatePaused
case "created", "dead", "removing":
return StateStopped
default:
return StateUnknown
}
}
func aggregateState(containers []ContainerInfo) ContainerState {
if len(containers) == 0 {
return StateNotDeployed
}
for _, c := range containers {
if c.State == StateRunning {
return StateRunning
}
}
for _, c := range containers {
if c.State == StateRestarting {
return StateRestarting
}
}
return StateStopped
}
// --- Stack accessors ---
func (m *Manager) GetStacks() []Stack {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]Stack, 0, len(m.stacks))
for _, s := range m.stacks {
result = append(result, *s)
}
return result
}
func (m *Manager) GetStack(name string) (*Stack, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.stacks[name]
if !ok {
return nil, false
}
copy := *s
return &copy, true
}
// --- Stack operations ---
// StartStack, StopStack, etc. now load app.yaml env for deployed stacks.
func (m *Manager) StartStack(name string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
m.logger.Printf("[INFO] Starting stack: %s", name)
dir := filepath.Dir(stack.ComposePath)
env := m.stackEnv(dir)
if _, err := m.composeExecCustomEnv(dir, env, "up", "-d"); err != nil {
return fmt.Errorf("starting stack %s: %w", name, err)
}
m.logger.Printf("[INFO] Stack %s started", name)
return m.RefreshStatus()
}
func (m *Manager) StopStack(name string) error {
if m.cfg.IsProtectedStack(name) {
return fmt.Errorf("stack %q is protected and cannot be stopped", name)
}
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
m.logger.Printf("[INFO] Stopping stack: %s", name)
dir := filepath.Dir(stack.ComposePath)
if _, err := m.composeExec(dir, "down"); err != nil {
return fmt.Errorf("stopping stack %s: %w", name, err)
}
m.logger.Printf("[INFO] Stack %s stopped", name)
return m.RefreshStatus()
}
func (m *Manager) RestartStack(name string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
m.logger.Printf("[INFO] Restarting stack: %s", name)
dir := filepath.Dir(stack.ComposePath)
if _, err := m.composeExec(dir, "restart"); err != nil {
return fmt.Errorf("restarting stack %s: %w", name, err)
}
m.logger.Printf("[INFO] Stack %s restarted", name)
return m.RefreshStatus()
}
func (m *Manager) UpdateStack(name string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
m.logger.Printf("[INFO] Updating stack: %s", name)
dir := filepath.Dir(stack.ComposePath)
env := m.stackEnv(dir)
if _, err := m.composeExecCustomEnv(dir, env, "pull"); err != nil {
return fmt.Errorf("pulling images for %s: %w", name, err)
}
if _, err := m.composeExecCustomEnv(dir, env, "up", "-d", "--remove-orphans"); err != nil {
return fmt.Errorf("recreating %s: %w", name, err)
}
m.logger.Printf("[INFO] Stack %s updated", name)
return m.RefreshStatus()
}
func (m *Manager) GetLogs(name string, lines int) (string, error) {
stack, ok := m.GetStack(name)
if !ok {
return "", fmt.Errorf("stack %q not found", name)
}
if lines <= 0 {
lines = 100
}
if lines > 1000 {
lines = 1000
}
dir := filepath.Dir(stack.ComposePath)
output, err := m.composeExec(dir, "logs", "--tail", fmt.Sprintf("%d", lines), "--no-color")
if err != nil {
return "", fmt.Errorf("getting logs for %s: %w", name, err)
}
return output, nil
}
// --- Env and compose helpers ---
// stackEnv builds the full OS env slice for a stack, merging app.yaml values.
func (m *Manager) stackEnv(stackDir string) []string {
env := os.Environ()
// Always inject DOMAIN
env = append(env, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
// Load app.yaml if it exists — merge its env vars
appCfg := LoadAppConfig(stackDir)
if appCfg != nil {
for k, v := range appCfg.Env {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
}
return env
}
func (m *Manager) composeExec(dir string, args ...string) (string, error) {
return m.composeExecCustomEnv(dir, nil, args...)
}
func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string) (string, error) {
var cmd *exec.Cmd
if m.composeCmd == "docker compose" {
fullArgs := append([]string{"compose"}, args...)
cmd = exec.Command("docker", fullArgs...)
} else {
cmd = exec.Command("docker-compose", args...)
}
cmd.Dir = dir
if env != nil {
cmd.Env = env
} else {
cmd.Env = m.stackEnv(dir)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
m.logger.Printf("[DEBUG] Running: %s %s (in %s)", m.composeCmd, strings.Join(args, " "), dir)
if err := cmd.Run(); err != nil {
return stdout.String(), fmt.Errorf("%w\nstderr: %s", err, stderr.String())
}
return stdout.String(), nil
}
func (m *Manager) execCommand(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("exec %s %s: %w\nstderr: %s", name, strings.Join(args, " "), err, stderr.String())
}
return stdout.String(), nil
}
+132
View File
@@ -0,0 +1,132 @@
package stacks
import (
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Metadata holds app information parsed from .felhom.yml.
type Metadata struct {
DisplayName string `yaml:"display_name" json:"display_name"`
Description string `yaml:"description" json:"description"`
Category string `yaml:"category" json:"category"`
Subdomain string `yaml:"subdomain" json:"subdomain"`
Slug string `yaml:"slug" json:"slug"`
Resources ResourceHints `yaml:"resources" json:"resources"`
DeployFields []DeployField `yaml:"deploy_fields" json:"deploy_fields"`
}
// ResourceHints describe what the app needs.
type ResourceHints struct {
RAM string `yaml:"ram" json:"ram"`
PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"`
NeedsHDD bool `yaml:"needs_hdd" json:"needs_hdd"`
}
// DeployField defines one configuration field shown during first deployment.
type DeployField struct {
EnvVar string `yaml:"env_var" json:"env_var"`
Label string `yaml:"label" json:"label"`
Type string `yaml:"type" json:"type"` // domain, secret, password, path, text, select, boolean
Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin"
Default string `yaml:"default" json:"default"`
Required bool `yaml:"required" json:"required"`
Placeholder string `yaml:"placeholder" json:"placeholder"`
Description string `yaml:"description" json:"description"`
LockedAfterDeploy bool `yaml:"locked_after_deploy" json:"locked_after_deploy"`
Options []SelectOption `yaml:"options" json:"options,omitempty"`
}
// SelectOption is a choice for "select" type fields.
type SelectOption struct {
Value string `yaml:"value" json:"value"`
Label string `yaml:"label" json:"label"`
}
// LoadMetadata reads .felhom.yml from a stack directory.
// Returns default metadata if the file doesn't exist.
func LoadMetadata(stackDir string) Metadata {
meta := Metadata{}
path := filepath.Join(stackDir, ".felhom.yml")
data, err := os.ReadFile(path)
if err != nil {
// No metadata file — build defaults from directory name
dirName := filepath.Base(stackDir)
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
meta.Slug = dirName
meta.Category = "tools"
return meta
}
if err := yaml.Unmarshal(data, &meta); err != nil {
// Parse error — still return defaults
dirName := filepath.Base(stackDir)
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
meta.Slug = dirName
return meta
}
// Fill in defaults for missing fields
dirName := filepath.Base(stackDir)
if meta.Slug == "" {
meta.Slug = dirName
}
if meta.DisplayName == "" {
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
}
if meta.Category == "" {
meta.Category = "tools"
}
// DOMAIN field is always auto-filled — mark it implicitly required
for i := range meta.DeployFields {
if meta.DeployFields[i].Type == "domain" {
meta.DeployFields[i].Required = true
meta.DeployFields[i].LockedAfterDeploy = true
}
// secret fields are always locked after deploy
if meta.DeployFields[i].Type == "secret" {
meta.DeployFields[i].LockedAfterDeploy = true
}
}
return meta
}
// HasDeployFields returns true if the app has any user-facing deploy fields
// (i.e., fields beyond auto-filled domain and auto-generated secrets).
func (m *Metadata) HasDeployFields() bool {
for _, f := range m.DeployFields {
if f.Type != "domain" && f.Type != "secret" {
return true
}
}
return false
}
// UserFacingFields returns only fields the user needs to interact with.
// Excludes auto-filled (domain) and fully hidden (secret) fields.
func (m *Metadata) UserFacingFields() []DeployField {
var fields []DeployField
for _, f := range m.DeployFields {
if f.Type != "domain" && f.Type != "secret" {
fields = append(fields, f)
}
}
return fields
}
// AutoGeneratedFields returns fields that are generated without user input.
func (m *Metadata) AutoGeneratedFields() []DeployField {
var fields []DeployField
for _, f := range m.DeployFields {
if f.Type == "secret" || f.Type == "domain" {
fields = append(fields, f)
}
}
return fields
}
@@ -0,0 +1,283 @@
# felhom-controller
**Central management container for Felhom home servers.**
Replaces Portainer + scattered systemd scripts with a single, lightweight container that provides:
- Hungarian-language web dashboard for customers
- Docker Compose stack management (start/stop/update)
- Backup orchestration (DB dumps + restic snapshots)
- System health monitoring with Healthchecks pings
- Git-based stack synchronization with update management
- Self-update with automatic rollback on failure
## Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Customer Hardware (N100 mini PC / Raspberry Pi) │
│ │
│ ┌──────────┐ ┌────────────────────────────────────────────┐ │
│ │ Traefik │ │ felhom-controller │ │
│ │ (reverse │──▶│ │ │
│ │ proxy) │ │ ┌──────────┐ ┌─────────────────────────┐│ │
│ └──────────┘ │ │ Web UI │ │ Stack Manager ││ │
│ │ │ (HU dash │ │ (compose up/down/pull, ││ │
│ ┌──────────┐ │ │ board) │ │ git sync, update mgmt) ││ │
│ │cloudflared│ │ └──────────┘ └─────────────────────────┘│ │
│ │ (tunnel) │ │ ┌──────────┐ ┌─────────────────────────┐│ │
│ └──────────┘ │ │ Backup │ │ Monitor & Pinger ││ │
│ │ │ (db dump │ │ (healthchecks pings, ││ │
│ ┌──────────┐ │ │ restic) │ │ system metrics) ││ │
│ │ App │ │ └──────────┘ └─────────────────────────┘│ │
│ │ stacks │ │ ┌──────────┐ ┌─────────────────────────┐│ │
│ │ (docker │ │ │Scheduler │ │ REST API ││ │
│ │ compose) │ │ │(cron-like│ │ (for UI + remote mgmt) ││ │
│ └──────────┘ │ │ jobs) │ └─────────────────────────┘│ │
│ │ └──────────┘ │ │
│ └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ pings │ git pull
▼ ▼
status.felhom.eu gitea.dooplex.hu
(Healthchecks on k3s) (stack definitions)
```
## Module Overview
| Module | Path | Responsibility |
|--------|------|----------------|
| **Config** | `internal/config/` | Load & validate controller.yaml |
| **Stacks** | `internal/stacks/` | Docker Compose operations, catalog, container status |
| **Backup** | `internal/backup/` | DB dumps, restic snapshots, restore |
| **Monitor** | `internal/monitor/` | Health checks, Healthchecks pings, system metrics |
| **Scheduler** | `internal/scheduler/` | Cron-like job runner for all periodic tasks |
| **API** | `internal/api/` | REST API endpoints (consumed by web UI + remote mgmt) |
| **Web** | `internal/web/` | Dashboard UI, static files, server-side templates |
## Stack Management
### How stacks get onto the machine
1. During initial setup, `deploy-felhom-compose.sh` clones the app catalog
2. Compose files + `.felhom.yml` metadata land in `/opt/docker/stacks/<app>/`
3. The controller periodically pulls from Git to detect changes
### First deployment flow (via dashboard)
1. Customer sees app card with "🚀 Telepítés" (Deploy) button
2. Clicks → deploy page shows:
- **Auto-filled**: DOMAIN (from controller config), read-only
- **Auto-generated**: DB passwords, secret keys (shown as "✓ Generated")
- **User input**: HDD path, admin password, language, etc.
- **"🎲 Generálás"** button next to password fields
3. Clicks "Telepítés" → controller:
- Generates all secrets
- Validates required fields (checks path exists, etc.)
- Saves `app.yaml` (env vars + locked fields list)
- Runs `docker compose up -d` with env vars injected
4. Post-deploy: locked fields (DB_PASSWORD, etc.) become read-only
### Update strategy
Stack updates are classified in the Git repository via markers:
| Marker | Behavior |
|--------|----------|
| No marker | Optional update — shown on dashboard, customer clicks "Update" |
| `UPDATE_REQUIRED=true` | Mandatory — auto-applied during next update window |
| `UPDATE_SECURITY=true` | Critical — applied immediately (within minutes) |
The update window is configurable per customer (default: 03:00-05:00 local time).
### Protected stacks
The following stacks cannot be stopped from the customer UI:
- `traefik` (reverse proxy)
- `cloudflared` (tunnel)
- `felhom-controller` (this container)
## Backup Strategy
The controller replaces Backrest and manages backups directly:
1. **DB dumps** (default 02:30): Discovers running database containers, dumps via pg_dump/mysqldump
2. **Restic snapshots** (default 03:00): Backs up `/opt/docker/stacks/` data + DB dumps
3. **Verification**: Periodically checks snapshot integrity
4. **Pruning**: Configurable retention (default: 7 daily, 4 weekly, 6 monthly)
Backup status is displayed on the dashboard and reported to Healthchecks.
## Self-Update Mechanism
1. Controller checks for new image versions periodically
2. Before updating: creates a restic snapshot of its own config
3. Pulls new image, recreates container
4. Health check timeout (60s) — if new container doesn't become healthy → rollback
5. Rollback: restores previous image tag, restarts with old config
## Configuration
### Controller config (infrastructure only)
Single YAML file per customer: `/opt/docker/felhom-controller/controller.yaml`
Contains customer identity, infrastructure secrets, backup/monitoring settings.
Does **not** contain app-specific config (HDD paths, DB passwords, etc.).
See `configs/controller.yaml.example` for the full reference.
### Per-app config (created during deployment)
Each deployed app gets an `app.yaml` in its stack directory:
```yaml
# /opt/docker/stacks/paperless-ngx/app.yaml
# Auto-generated by felhom-controller — do not edit locked fields manually
deployed: true
deployed_at: "2026-02-13T14:30:00Z"
env:
DOMAIN: "demo-felhom.eu"
DB_PASSWORD: "a7f2b9c1e4d..." # locked
PAPERLESS_SECRET_KEY: "8b3e..." # locked
PAPERLESS_ADMIN_USER: "admin" # editable
HDD_PATH: "/mnt/hdd_1" # locked
locked_fields:
- DB_PASSWORD
- PAPERLESS_SECRET_KEY
- DOMAIN
- HDD_PATH
```
Fields are defined in each stack's `.felhom.yml` metadata file. See
`configs/example-felhom-metadata.yml` for the full format.
### App assets (logos, screenshots, descriptions)
Baked into the container image at build time — no external dependencies at runtime.
Assets are synced from the felhom.eu website repo before building:
```bash
make sync-assets # copies from ../felhom.eu/website/assets/
make sync-assets WEBSITE_ASSETS_DIR=/path # or specify custom path
```
Served locally at `/static/assets/`. Naming convention matches the website:
| Asset | File pattern | Served at |
|-------|-------------|-----------|
| Logo (SVG) | `assets/{slug}-logo.svg` | `/static/assets/{slug}-logo.svg` |
| Logo (PNG fallback) | `assets/{slug}-logo.png` | `/static/assets/{slug}-logo.png` |
| Screenshot | `assets/{slug}-screenshot-{n}.webp` | `/static/assets/{slug}-screenshot-{n}.webp` |
## Build & Deploy
```bash
# Build for both architectures
make build-all
# Build Docker image
make docker-build
# Push to registry
make docker-push
# Build for specific arch
make build-amd64
make build-arm64
```
## Development
```bash
# Run locally (needs Docker socket)
go run ./cmd/controller/ --config configs/controller.yaml.example
# Run tests
go test ./...
# Lint
golangci-lint run
```
## Repository Layout
```
felhom-controller/
├── cmd/controller/ # Entry point
│ └── main.go
├── internal/
│ ├── config/ # Configuration loading
│ │ └── config.go
│ ├── stacks/ # Docker Compose stack management
│ │ ├── manager.go # Core: scan, start, stop, restart, update, logs
│ │ ├── metadata.go # Parse .felhom.yml app metadata
│ │ └── deploy.go # First-deploy flow: secret gen, app.yaml, compose up
│ ├── backup/ # DB dumps + restic operations (Phase 3)
│ ├── monitor/ # Health checks + metrics (Phase 2)
│ ├── scheduler/ # Periodic job runner (Phase 2)
│ ├── api/ # REST API
│ │ └── router.go
│ └── web/ # Dashboard UI
│ ├── server.go # HTTP server, auth, page handlers
│ └── templates.go # Embedded HTML templates + CSS (Hungarian)
├── configs/ # Example config files
│ ├── controller.yaml.example
│ └── example-felhom-metadata.yml
├── docs/
│ └── BUILDING.md # Container image build & registry guide
├── scripts/
│ └── hashpass.go # Password hash generator
├── Dockerfile # Multi-stage build (Go + debian-slim)
├── docker-compose.yml # Controller's own compose definition
├── Makefile # Build targets (amd64, arm64, docker)
├── go.mod
└── README.md
```
## Status & Roadmap
### Phase 1 — Stack Manager + Deploy Flow (current)
- [x] Project skeleton & config format
- [x] .felhom.yml app metadata format with deploy fields
- [x] Per-app config persistence (app.yaml)
- [x] Secret generation engine (password, hex, static)
- [x] Stack catalog (read compose files + metadata from disk)
- [x] Docker Compose operations (up/down/pull/ps/logs)
- [x] Deploy flow with interactive field input
- [x] Basic web dashboard with start/stop/deploy buttons
- [x] REST API for stack + deploy operations
- [x] Simple web authentication (bcrypt sessions)
- [x] App logos + screenshots loaded from felhom.eu
- [x] Container image build pipeline (Dockerfile + Makefile)
- [ ] First build & test on N100 hardware
- [ ] End-to-end test: deploy an app through dashboard
### Phase 2 — Monitoring & Health
- [ ] System metrics collection (CPU, RAM, disk, temperature)
- [ ] Healthchecks.io ping integration
- [ ] Dashboard system health panel
- [ ] Customer notifications (email/Telegram)
### Phase 3 — Backups
- [ ] DB dump engine (PostgreSQL, MariaDB/MySQL, SQLite)
- [ ] Restic integration (snapshot, prune, check)
- [ ] Backup status on dashboard
- [ ] Manual backup trigger from UI
- [ ] Restore workflow
### Phase 4 — Git Sync & Updates
- [ ] Periodic git pull for stack definitions
- [ ] Update classification (optional/required/security)
- [ ] Update window enforcement
- [ ] Dashboard update notifications with "Update" button
### Phase 5 — Self-Update & Resilience
- [ ] Self-update check & execution
- [ ] Pre-update config backup
- [ ] Health-based rollback mechanism
- [ ] Config export/import
### Phase 6 — Central Management (future)
- [ ] API authentication for remote management
- [ ] Central dashboard on k3s querying all customer controllers
- [ ] Fleet-wide update management
@@ -7,17 +7,19 @@ They are baked into the Docker image at build time.
Files must follow the felhom.eu website convention:
- `{slug}-logo.webp` — App logo (48x48 or larger, will be scaled)
- `{slug}-logo.svg` — App logo (SVG preferred, displayed on dark background)
- `{slug}-logo.png` — App logo fallback (PNG, for apps without SVG)
- `{slug}-screenshot-1.webp` — First screenshot
- `{slug}-screenshot-2.webp` — Second screenshot (and so on)
The dashboard tries SVG first, falls back to PNG if not found.
Example:
```
paperless-ngx-logo.webp
paperless-ngx-logo.svg
paperless-ngx-screenshot-1.webp
paperless-ngx-screenshot-2.webp
immich-logo.webp
immich-screenshot-1.webp
adventurelog-logo.png
adventurelog-screenshot-1.webp
```
## Syncing from felhom.eu website
+231
View File
@@ -0,0 +1,231 @@
package api
import (
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// Router handles all /api/* requests.
type Router struct {
cfg *config.Config
stackMgr *stacks.Manager
logger *log.Logger
}
func NewRouter(cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger) *Router {
return &Router{cfg: cfg, stackMgr: stackMgr, logger: logger}
}
type apiResponse struct {
OK bool `json:"ok"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
// ServeHTTP routes /api/* requests.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/api")
path = strings.TrimSuffix(path, "/")
switch {
// GET /api/stacks
case path == "/stacks" && req.Method == http.MethodGet:
r.listStacks(w, req)
// GET /api/stacks/{name}
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodGet && !hasSubpath(path, "/stacks/"):
r.getStack(w, req, trimSegment(path, "/stacks/"))
// GET /api/stacks/{name}/deploy-fields
case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet:
r.getDeployFields(w, req, extractName(path, "/deploy-fields"))
// POST /api/stacks/{name}/deploy
case hasSuffix(path, "/deploy") && req.Method == http.MethodPost:
r.deployStack(w, req, extractName(path, "/deploy"))
// POST /api/stacks/{name}/start
case hasSuffix(path, "/start") && req.Method == http.MethodPost:
r.actionStack(w, "start", extractName(path, "/start"))
// POST /api/stacks/{name}/stop
case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
r.actionStack(w, "stop", extractName(path, "/stop"))
// POST /api/stacks/{name}/restart
case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
r.actionStack(w, "restart", extractName(path, "/restart"))
// POST /api/stacks/{name}/update
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
r.actionStack(w, "update", extractName(path, "/update"))
// GET /api/stacks/{name}/logs
case hasSuffix(path, "/logs") && req.Method == http.MethodGet:
r.getStackLogs(w, req, extractName(path, "/logs"))
// GET /api/system/info
case path == "/system/info" && req.Method == http.MethodGet:
r.systemInfo(w, req)
default:
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
}
}
// HealthHandler responds to /api/health (no auth required).
func (r *Router) HealthHandler(w http.ResponseWriter, req *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "felhom-controller is healthy"})
}
// --- Stack handlers ---
func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()})
}
func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
stack, ok := r.stackMgr.GetStack(name)
if !ok {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
}
func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name string) {
meta, appCfg, err := r.stackMgr.GetDeployFields(name)
if err != nil {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
return
}
data := map[string]interface{}{
"metadata": meta,
"app_config": appCfg,
"domain": r.cfg.Customer.Domain,
"logo_url": r.cfg.AppLogoURL(meta.Slug),
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
r.logger.Printf("[API] Deploy requested for stack: %s", name)
var body struct {
Values map[string]string `json:"values"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"})
return
}
deployReq := stacks.DeployRequest{
StackName: name,
Values: body.Values,
}
if err := r.stackMgr.DeployStack(deployReq); err != nil {
r.logger.Printf("[API] Deploy failed for %s: %v", name, err)
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "already deployed") {
status = http.StatusConflict
}
if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") {
status = http.StatusBadRequest
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " deployed"})
}
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
r.logger.Printf("[API] %s requested for stack: %s", action, name)
var err error
switch action {
case "start":
err = r.stackMgr.StartStack(name)
case "stop":
err = r.stackMgr.StopStack(name)
case "restart":
err = r.stackMgr.RestartStack(name)
case "update":
err = r.stackMgr.UpdateStack(name)
}
if err != nil {
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
}
func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name string) {
lines := 100
if v := req.URL.Query().Get("lines"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
lines = n
}
}
output, err := r.stackMgr.GetLogs(name, lines)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
}
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
"customer_id": r.cfg.Customer.ID,
"customer_name": r.cfg.Customer.Name,
"domain": r.cfg.Customer.Domain,
"backup_enabled": r.cfg.Backup.Enabled,
"monitor_enabled": r.cfg.Monitoring.Enabled,
}})
}
// --- Helpers ---
func hasSuffix(path, suffix string) bool { return strings.HasSuffix(path, suffix) }
func hasSubpath(path, prefix string) bool {
rest := strings.TrimPrefix(path, prefix)
return strings.Contains(rest, "/")
}
func trimSegment(path, prefix string) string {
return strings.TrimPrefix(path, prefix)
}
func extractName(path, suffix string) string {
s := strings.TrimPrefix(path, "/stacks/")
return strings.TrimSuffix(s, suffix)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("[ERROR] Failed to write JSON response: %v", err)
}
}