commit f72bb57edac3a32bd408c1b7ce4bb97fc22c4534 Author: kisfenyo Date: Fri Feb 13 18:54:08 2026 +0100 added controller diff --git a/controller/.gitignore b/controller/.gitignore new file mode 100644 index 0000000..92c2483 --- /dev/null +++ b/controller/.gitignore @@ -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 diff --git a/controller/BUILDING.md b/controller/BUILDING.md new file mode 100644 index 0000000..db3c099 --- /dev/null +++ b/controller/BUILDING.md @@ -0,0 +1,226 @@ +# Building & Publishing felhom-controller + +## Prerequisites + +- Docker installed on your build machine +- Docker Buildx plugin (for multi-arch builds — included with Docker Desktop, may need install on Linux) +- Access to Gitea at gitea.dooplex.hu + +## Step 1: Enable Gitea Container Registry + +Gitea has a built-in container registry. Check if it's enabled: + +```bash +# SSH into your k3s node or wherever Gitea runs +# Check Gitea config (app.ini) +kubectl exec -it -n deploy/gitea -- cat /data/gitea/conf/app.ini | grep -A5 '\[packages\]' +``` + +If the `[packages]` section is missing or `ENABLED=false`, add/update: + +```ini +[packages] +ENABLED = true +``` + +Then restart Gitea: + +```bash +kubectl rollout restart deploy/gitea -n +``` + +**Verify it works:** + +```bash +# Login to the registry (use your Gitea username + password or access token) +docker login gitea.dooplex.hu +# Username: admin +# Password: +``` + +If login succeeds, the registry is working. The image URL pattern is: +`gitea.dooplex.hu//:` + +## Step 2: Sync app assets before building + +The container image includes app logos and screenshots. Sync them from +the felhom.eu website repo before building: + +```bash +# If the website repo is checked out alongside this repo: +make sync-assets + +# Or specify the path explicitly: +make sync-assets WEBSITE_ASSETS_DIR=/home/admin/repos/felhom.eu/website/assets + +# Verify +ls assets/*.webp +``` + +If you skip this step, the dashboard will work but show no app logos/screenshots +(the `onerror` handler hides broken images gracefully). + +## Step 3: Build for single architecture (quick test) + +```bash +cd ~/repos/deploy-felhom-compose + +# Build for your current architecture +docker build \ + --build-arg VERSION=$(git describe --tags --always 2>/dev/null || echo "0.1.0") \ + --build-arg GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") \ + -t gitea.dooplex.hu/admin/felhom-controller:latest \ + -t gitea.dooplex.hu/admin/felhom-controller:0.1.0 \ + . + +# Push +docker push gitea.dooplex.hu/admin/felhom-controller:latest +docker push gitea.dooplex.hu/admin/felhom-controller:0.1.0 +``` + +## Step 4: Build for both architectures (production) + +You need both amd64 (N100 mini PCs) and arm64 (Raspberry Pi). Use Docker Buildx: + +```bash +# One-time: Create a buildx builder that supports multi-arch +docker buildx create --name felhom-builder --use --bootstrap + +# Verify it supports the architectures we need +docker buildx inspect felhom-builder +# Should show: linux/amd64, linux/arm64 in Platforms + +# Build + push multi-arch image in one step +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION=0.1.0 \ + --build-arg GIT_COMMIT=$(git rev-parse --short HEAD) \ + -t gitea.dooplex.hu/admin/felhom-controller:latest \ + -t gitea.dooplex.hu/admin/felhom-controller:0.1.0 \ + --push \ + . +``` + +**Note:** `--push` is required with multi-arch builds because buildx doesn't store +multi-platform images in the local Docker cache. It pushes directly to the registry. + +### If buildx multi-arch doesn't work (missing QEMU) + +On Linux you might need QEMU for cross-compilation: + +```bash +# Install QEMU user-mode emulation +sudo apt-get install -y qemu-user-static binfmt-support + +# Register QEMU with Docker +docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + +# Verify +docker buildx ls +``` + +### Alternative: Build natively on each architecture + +If you don't want to cross-compile, build on each machine: + +```bash +# On the N100 (amd64): +docker build -t gitea.dooplex.hu/admin/felhom-controller:latest-amd64 . +docker push gitea.dooplex.hu/admin/felhom-controller:latest-amd64 + +# On the Pi (arm64): +docker build -t gitea.dooplex.hu/admin/felhom-controller:latest-arm64 . +docker push gitea.dooplex.hu/admin/felhom-controller:latest-arm64 + +# Then create a manifest list to combine them: +docker manifest create gitea.dooplex.hu/admin/felhom-controller:latest \ + gitea.dooplex.hu/admin/felhom-controller:latest-amd64 \ + gitea.dooplex.hu/admin/felhom-controller:latest-arm64 +docker manifest push gitea.dooplex.hu/admin/felhom-controller:latest +``` + +## Step 5: Deploy on a customer node + +On the customer's machine, the docker-compose.yml for the controller references +the image from the registry: + +```yaml +services: + felhom-controller: + image: gitea.dooplex.hu/admin/felhom-controller:latest + # ... +``` + +Pull and start: + +```bash +# Login to registry on the customer node (one-time) +docker login gitea.dooplex.hu + +# Start the controller +cd /opt/docker/felhom-controller +docker compose pull +docker compose up -d + +# Verify +docker compose logs -f +curl -s http://localhost:8080/api/health +``` + +## Step 6: Updating the controller + +When you release a new version: + +```bash +# On your build machine: +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION=0.2.0 \ + -t gitea.dooplex.hu/admin/felhom-controller:latest \ + -t gitea.dooplex.hu/admin/felhom-controller:0.2.0 \ + --push . + +# On the customer node (manually for now; auto-update comes in Phase 5): +cd /opt/docker/felhom-controller +docker compose pull +docker compose up -d +``` + +## Makefile shortcuts + +The Makefile has convenience targets: + +```bash +make docker-build # Build for current platform +make docker-buildx # Build multi-arch + push +make docker-push # Push current platform image +``` + +## Troubleshooting + +### "unauthorized" when pushing +```bash +docker logout gitea.dooplex.hu +docker login gitea.dooplex.hu +``` + +### Gitea registry not accessible +Check if Gitea's HTTPS is working and the domain resolves: +```bash +curl -v https://gitea.dooplex.hu/v2/ +# Should return: {"errors":[{"code":"UNAUTHORIZED",...}]} +``` + +### Build fails on arm64 via QEMU (too slow or errors) +Cross-compiling Go via QEMU can be slow. Since the Go binary itself is cross-compiled +(CGO_ENABLED=0), only the Debian packages in the runtime stage need QEMU. +Alternative: build the Go binary natively, then build only the runtime Docker layer +via buildx. + +### Image too large +Expected sizes: +- Go binary: ~15-20 MB +- Runtime image (debian-slim + docker-cli + restic + pg_dump): ~250-350 MB + +To reduce: consider Alpine instead of Debian slim, but test pg_dump/mysqldump +compatibility first. diff --git a/controller/Dockerfile b/controller/Dockerfile new file mode 100644 index 0000000..448b256 --- /dev/null +++ b/controller/Dockerfile @@ -0,0 +1,81 @@ +# ============================================================================= +# felhom-controller Dockerfile +# Multi-stage build: Go binary + minimal runtime +# Supports amd64 (N100 mini PCs) and arm64 (Raspberry Pi) +# ============================================================================= + +# --- Build stage --- +FROM golang:1.22-bookworm AS builder + +ARG TARGETOS=linux +ARG TARGETARCH +ARG VERSION=dev +ARG GIT_COMMIT=unknown + +WORKDIR /build + +# Cache dependencies first +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source +COPY . . + +# Build static binary +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ + -ldflags="-s -w \ + -X main.Version=${VERSION} \ + -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ + -X main.GitCommit=${GIT_COMMIT}" \ + -o /build/felhom-controller \ + ./cmd/controller/ + +# --- Runtime stage --- +FROM debian:bookworm-slim + +# Install runtime dependencies: +# - docker-cli: for "docker compose" commands +# - ca-certificates: for HTTPS (healthchecks pings, git) +# - restic: for backup operations +# - postgresql-client: for pg_dump +# - default-mysql-client: for mysqldump +# - sqlite3: for SQLite backup +# - git: for stack sync from Gitea +# - curl: for health pings and debugging +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + git \ + restic \ + postgresql-client \ + default-mysql-client \ + sqlite3 \ + && rm -rf /var/lib/apt/lists/* + +# Install docker-cli (without daemon) +RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] \ + https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user (but we'll run as root for Docker socket access) +# The Docker socket requires root or docker group membership +RUN mkdir -p /opt/docker/felhom-controller/data + +COPY --from=builder /build/felhom-controller /usr/local/bin/felhom-controller + +# Copy baked-in app assets (logos, screenshots) +# These are synced from the felhom.eu website repo before building. +# See: make sync-assets +COPY assets/ /usr/share/felhom/assets/ + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:8080/api/health || exit 1 + +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/felhom-controller"] +CMD ["--config", "/opt/docker/felhom-controller/controller.yaml"] diff --git a/controller/Makefile b/controller/Makefile new file mode 100644 index 0000000..e9b36f1 --- /dev/null +++ b/controller/Makefile @@ -0,0 +1,98 @@ +# felhom-controller Makefile +# Build targets for amd64 (N100 mini PCs) and arm64 (Raspberry Pi) + +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) + +BINARY = felhom-controller +REGISTRY ?= gitea.dooplex.hu/admin +IMAGE = $(REGISTRY)/felhom-controller + +LDFLAGS = -ldflags="-s -w \ + -X main.Version=$(VERSION) \ + -X main.BuildTime=$(BUILD_TIME) \ + -X main.GitCommit=$(GIT_COMMIT)" + +.PHONY: all build build-amd64 build-arm64 build-all test lint clean \ + docker-build docker-push docker-buildx run sync-assets + +# Default: build for current platform +all: build + +build: + go build $(LDFLAGS) -o bin/$(BINARY) ./cmd/controller/ + +build-amd64: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o bin/$(BINARY)-linux-amd64 ./cmd/controller/ + +build-arm64: + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o bin/$(BINARY)-linux-arm64 ./cmd/controller/ + +build-all: build-amd64 build-arm64 + +# Run locally (for development) +run: + go run ./cmd/controller/ --config configs/controller.yaml.example + +# Tests +test: + go test -v ./... + +test-cover: + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + +# Lint (requires golangci-lint) +lint: + golangci-lint run ./... + +# Docker image (current platform) +docker-build: + docker build \ + --build-arg VERSION=$(VERSION) \ + --build-arg GIT_COMMIT=$(GIT_COMMIT) \ + -t $(IMAGE):$(VERSION) \ + -t $(IMAGE):latest \ + . + +# Docker multi-arch build (requires docker buildx) +docker-buildx: + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION=$(VERSION) \ + --build-arg GIT_COMMIT=$(GIT_COMMIT) \ + -t $(IMAGE):$(VERSION) \ + -t $(IMAGE):latest \ + --push \ + . + +# Push docker image +docker-push: + docker push $(IMAGE):$(VERSION) + docker push $(IMAGE):latest + +# Sync app assets from felhom.eu website repo +# Override with: make sync-assets WEBSITE_ASSETS_DIR=/path/to/website/assets +WEBSITE_ASSETS_DIR ?= ../felhom.eu/website/assets + +sync-assets: + @echo "Syncing assets from $(WEBSITE_ASSETS_DIR)..." + @if [ ! -d "$(WEBSITE_ASSETS_DIR)" ]; then \ + echo "ERROR: $(WEBSITE_ASSETS_DIR) not found."; \ + echo "Set WEBSITE_ASSETS_DIR to the path containing app logos and screenshots."; \ + exit 1; \ + fi + @cp -v $(WEBSITE_ASSETS_DIR)/*-logo.svg assets/ 2>/dev/null || echo "No SVG logo files found" + @cp -v $(WEBSITE_ASSETS_DIR)/*-logo.png assets/ 2>/dev/null || echo "No PNG logo files found" + @cp -v $(WEBSITE_ASSETS_DIR)/*-screenshot-*.webp assets/ 2>/dev/null || echo "No screenshot files found" + @echo "Assets synced: $$(ls assets/*.svg assets/*.png assets/*.webp 2>/dev/null | wc -l) files" + +# Generate bcrypt password hash (usage: make password PASS=mypassword) +password: + @go run -mod=mod golang.org/x/crypto/bcrypt 2>/dev/null || \ + echo '$$2a$$10$$...' && echo "Install htpasswd or use: go run scripts/hashpass.go $(PASS)" + +# Clean build artifacts +clean: + rm -rf bin/ coverage.out coverage.html diff --git a/controller/README.md b/controller/README.md new file mode 100644 index 0000000..968a5f3 --- /dev/null +++ b/controller/README.md @@ -0,0 +1,31 @@ +# App Assets + +This directory contains logos and screenshots for the dashboard. +They are baked into the Docker image at build time. + +## Naming convention + +Files must follow the felhom.eu website convention: + +- `{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.svg +paperless-ngx-screenshot-1.webp +adventurelog-logo.png +adventurelog-screenshot-1.webp +``` + +## Syncing from felhom.eu website + +Run `make sync-assets` to copy assets from the felhom.eu website repo. +This expects the website files to be available at `../felhom.eu/website/assets/` +(relative to this repo), or set `WEBSITE_ASSETS_DIR` to override. + +Alternatively, copy files manually from FileBrowser at https://felhom.eu. diff --git a/controller/config.go b/controller/config.go new file mode 100644 index 0000000..fada409 --- /dev/null +++ b/controller/config.go @@ -0,0 +1,270 @@ +package config + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// Config is the top-level configuration structure. +// Contains ONLY infrastructure/customer identity. +// App-specific config lives in per-app app.yaml files. +type Config struct { + Customer CustomerConfig `yaml:"customer"` + Infrastructure InfrastructureConfig `yaml:"infrastructure"` + Paths PathsConfig `yaml:"paths"` + Web WebConfig `yaml:"web"` + Git GitConfig `yaml:"git"` + Stacks StacksConfig `yaml:"stacks"` + Backup BackupConfig `yaml:"backup"` + Monitoring MonitoringConfig `yaml:"monitoring"` + SelfUpdate SelfUpdateConfig `yaml:"self_update"` + Notifications NotificationsConfig `yaml:"notifications"` + Logging LoggingConfig `yaml:"logging"` + Assets AssetsConfig `yaml:"assets"` +} + +type CustomerConfig struct { + ID string `yaml:"id"` + Name string `yaml:"name"` + Domain string `yaml:"domain"` + Email string `yaml:"email"` + TelegramChatID string `yaml:"telegram_chat_id"` +} + +type InfrastructureConfig struct { + CFTunnelToken string `yaml:"cf_tunnel_token"` + CFAPIToken string `yaml:"cf_api_token"` +} + +type PathsConfig struct { + StacksDir string `yaml:"stacks_dir"` + DataDir string `yaml:"data_dir"` + BackupDir string `yaml:"backup_dir"` + DBDumpDir string `yaml:"db_dump_dir"` +} + +type WebConfig struct { + Listen string `yaml:"listen"` + PasswordHash string `yaml:"password_hash"` + SessionSecret string `yaml:"session_secret"` +} + +type GitConfig struct { + RepoURL string `yaml:"repo_url"` + Branch string `yaml:"branch"` + SyncInterval string `yaml:"sync_interval"` + Username string `yaml:"username"` + Token string `yaml:"token"` +} + +type StacksConfig struct { + Protected []string `yaml:"protected"` + UpdateWindow string `yaml:"update_window"` + ComposeCommand string `yaml:"compose_command"` +} + +type BackupConfig struct { + Enabled bool `yaml:"enabled"` + ResticRepo string `yaml:"restic_repo"` + ResticPasswordFile string `yaml:"restic_password_file"` + DBDumpSchedule string `yaml:"db_dump_schedule"` + ResticSchedule string `yaml:"restic_schedule"` + Retention RetentionConfig `yaml:"retention"` + PruneSchedule string `yaml:"prune_schedule"` +} + +type RetentionConfig struct { + KeepDaily int `yaml:"keep_daily"` + KeepWeekly int `yaml:"keep_weekly"` + KeepMonthly int `yaml:"keep_monthly"` +} + +type MonitoringConfig struct { + Enabled bool `yaml:"enabled"` + HealthchecksBase string `yaml:"healthchecks_base"` + PingUUIDs PingUUIDsConfig `yaml:"ping_uuids"` + HealthCheckSchedule string `yaml:"health_check_schedule"` + Thresholds ThresholdsConfig `yaml:"thresholds"` +} + +type PingUUIDsConfig struct { + DBDump string `yaml:"db_dump"` + Backup string `yaml:"backup"` + SystemHealth string `yaml:"system_health"` +} + +type ThresholdsConfig struct { + DiskWarnPercent int `yaml:"disk_warn_percent"` + DiskCritPercent int `yaml:"disk_crit_percent"` + BackupMaxAgeHours int `yaml:"backup_max_age_hours"` + CPUWarnPercent int `yaml:"cpu_warn_percent"` + MemoryWarnPercent int `yaml:"memory_warn_percent"` + TemperatureWarnCelsius int `yaml:"temperature_warn_celsius"` +} + +type SelfUpdateConfig struct { + Enabled bool `yaml:"enabled"` + CheckInterval string `yaml:"check_interval"` + Image string `yaml:"image"` + AutoUpdate bool `yaml:"auto_update"` + HealthTimeoutSeconds int `yaml:"health_timeout_seconds"` +} + +type NotificationsConfig struct { + CustomerEvents []string `yaml:"customer_events"` + OperatorEvents []string `yaml:"operator_events"` +} + +type LoggingConfig struct { + Level string `yaml:"level"` + File string `yaml:"file"` + MaxSizeMB int `yaml:"max_size_mb"` + MaxFiles int `yaml:"max_files"` +} + +type AssetsConfig struct { + SourceURL string `yaml:"source_url"` // Only used during build, not runtime +} + +// Load reads and parses the config file, applies defaults, and validates. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config file: %w", err) + } + + // Expand environment variables in the YAML + expanded := os.ExpandEnv(string(data)) + + cfg := &Config{} + if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil { + return nil, fmt.Errorf("parsing config file: %w", err) + } + + applyDefaults(cfg) + applyEnvOverrides(cfg) + + if err := validate(cfg); err != nil { + return nil, fmt.Errorf("config validation: %w", err) + } + + return cfg, nil +} + +func applyDefaults(cfg *Config) { + d := func(val *string, def string) { + if *val == "" { + *val = def + } + } + di := func(val *int, def int) { + if *val == 0 { + *val = def + } + } + + d(&cfg.Paths.StacksDir, "/opt/docker/stacks") + d(&cfg.Paths.DataDir, "/opt/docker/felhom-controller/data") + d(&cfg.Paths.BackupDir, "/srv/backups") + d(&cfg.Paths.DBDumpDir, "/srv/backups/db-dumps") + d(&cfg.Web.Listen, ":8080") + d(&cfg.Git.Branch, "main") + d(&cfg.Git.SyncInterval, "15m") + d(&cfg.Stacks.UpdateWindow, "03:00-05:00") + d(&cfg.Backup.ResticRepo, "/srv/backups/restic-repo") + d(&cfg.Backup.DBDumpSchedule, "02:30") + d(&cfg.Backup.ResticSchedule, "03:00") + d(&cfg.Backup.PruneSchedule, "weekly") + di(&cfg.Backup.Retention.KeepDaily, 7) + di(&cfg.Backup.Retention.KeepWeekly, 4) + di(&cfg.Backup.Retention.KeepMonthly, 6) + d(&cfg.Monitoring.HealthchecksBase, "https://status.felhom.eu") + d(&cfg.Monitoring.HealthCheckSchedule, "06:00") + di(&cfg.Monitoring.Thresholds.DiskWarnPercent, 80) + di(&cfg.Monitoring.Thresholds.DiskCritPercent, 90) + di(&cfg.Monitoring.Thresholds.BackupMaxAgeHours, 36) + di(&cfg.Monitoring.Thresholds.CPUWarnPercent, 90) + di(&cfg.Monitoring.Thresholds.MemoryWarnPercent, 85) + di(&cfg.Monitoring.Thresholds.TemperatureWarnCelsius, 75) + d(&cfg.SelfUpdate.CheckInterval, "6h") + di(&cfg.SelfUpdate.HealthTimeoutSeconds, 60) + d(&cfg.Logging.Level, "info") + di(&cfg.Logging.MaxSizeMB, 10) + di(&cfg.Logging.MaxFiles, 3) + d(&cfg.Assets.SourceURL, "https://felhom.eu") +} + +func applyEnvOverrides(cfg *Config) { + envStr := func(key string, target *string) { + if v := os.Getenv(key); v != "" { + *target = v + } + } + envStr("FELHOM_CUSTOMER_ID", &cfg.Customer.ID) + envStr("FELHOM_CUSTOMER_DOMAIN", &cfg.Customer.Domain) + envStr("FELHOM_WEB_LISTEN", &cfg.Web.Listen) + envStr("FELHOM_WEB_PASSWORD_HASH", &cfg.Web.PasswordHash) + envStr("FELHOM_PATHS_STACKS_DIR", &cfg.Paths.StacksDir) + envStr("FELHOM_LOGGING_LEVEL", &cfg.Logging.Level) +} + +func validate(cfg *Config) error { + var errs []string + + if cfg.Customer.ID == "" { + errs = append(errs, "customer.id is required") + } + if cfg.Customer.Domain == "" { + errs = append(errs, "customer.domain is required") + } + + switch cfg.Logging.Level { + case "debug", "info", "warn", "error": + default: + errs = append(errs, fmt.Sprintf("logging.level must be debug|info|warn|error, got %q", cfg.Logging.Level)) + } + + if cfg.Monitoring.Thresholds.DiskWarnPercent >= cfg.Monitoring.Thresholds.DiskCritPercent { + errs = append(errs, "disk_warn_percent must be less than disk_crit_percent") + } + + if len(errs) > 0 { + return fmt.Errorf("validation errors:\n - %s", strings.Join(errs, "\n - ")) + } + + return nil +} + +// IsProtectedStack checks if a stack name is in the protected list. +func (cfg *Config) IsProtectedStack(name string) bool { + for _, p := range cfg.Stacks.Protected { + if strings.EqualFold(p, name) { + return true + } + } + return false +} + +// AppLogoURL returns the primary logo URL (SVG). Use AppLogoPNGURL as fallback. +func (cfg *Config) AppLogoURL(slug string) string { + return fmt.Sprintf("/static/assets/%s-logo.svg", slug) +} + +// AppLogoPNGURL returns the PNG fallback logo URL. +func (cfg *Config) AppLogoPNGURL(slug string) string { + return fmt.Sprintf("/static/assets/%s-logo.png", slug) +} + +// AppScreenshotURL returns the local URL for an app's screenshot. +func (cfg *Config) AppScreenshotURL(slug string, index int) string { + return fmt.Sprintf("/static/assets/%s-screenshot-%d.webp", slug, index) +} + +// AppPageURL returns the URL for an app's detail page. +// This links to the local controller-hosted app detail page. +func (cfg *Config) AppPageURL(slug string) string { + return fmt.Sprintf("/apps/%s", slug) +} diff --git a/controller/controller.yaml.example b/controller/controller.yaml.example new file mode 100644 index 0000000..32844ec --- /dev/null +++ b/controller/controller.yaml.example @@ -0,0 +1,123 @@ +# ============================================================================= +# Felhom Controller Configuration +# ============================================================================= +# Location: /opt/docker/felhom-controller/controller.yaml +# +# This file contains ONLY infrastructure and customer identity config. +# Application-specific configuration (passwords, paths, etc.) is handled +# interactively during first deployment via the dashboard UI and stored +# per-app in /opt/docker/stacks//app.yaml +# +# Environment variable overrides: FELHOM_
_ +# (e.g., FELHOM_CUSTOMER_DOMAIN=example.hu) +# ============================================================================= + +# --- Customer identity --- +customer: + id: "demo-felhom" # Unique customer identifier + name: "Demo Ügyfél" # Display name (shown on dashboard) + domain: "demo-felhom.eu" # Base domain for all services + email: "" # Customer notification email (optional) + telegram_chat_id: "" # Telegram notifications (optional, future) + +# --- Infrastructure secrets --- +infrastructure: + cf_tunnel_token: "" # Cloudflare Tunnel token + cf_api_token: "" # Cloudflare API token (DNS-01 challenge) + +# --- Paths (system-level only) --- +paths: + stacks_dir: "/opt/docker/stacks" # Where compose files live + data_dir: "/opt/docker/felhom-controller/data" + backup_dir: "/srv/backups" + db_dump_dir: "/srv/backups/db-dumps" + +# --- 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" + update_window: "03:00-05:00" + compose_command: "" + +# --- Backup --- +backup: + enabled: true + restic_repo: "/srv/backups/restic-repo" + restic_password_file: "/opt/docker/felhom-controller/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: + db_dump: "CHANGEME-uuid-for-db-dump" + backup: "CHANGEME-uuid-for-backup" + system_health: "CHANGEME-uuid-for-system-health" + 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 + +# --- Self-update --- +self_update: + enabled: true + check_interval: "6h" + image: "gitea.dooplex.hu/admin/felhom-controller" + auto_update: false + health_timeout_seconds: 60 + +# --- Notifications --- +notifications: + customer_events: + - "disk_warning" + - "backup_failed" + - "update_available" + - "security_update" + operator_events: + - "disk_critical" + - "backup_failed" + - "self_update_failed" + - "container_unhealthy" + +# --- Logging --- +logging: + level: "info" + file: "" + max_size_mb: 10 + max_files: 3 + +# --- Assets --- +assets: + # App logos, screenshots, and descriptions are baked into the container + # image at build time (from the felhom.eu website assets). + # Served locally at /static/assets/ — no external dependency. + # The source URL is only used during image build, not at runtime. + source_url: "https://felhom.eu" diff --git a/controller/deploy.go b/controller/deploy.go new file mode 100644 index 0000000..f290948 --- /dev/null +++ b/controller/deploy.go @@ -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 +} diff --git a/controller/docker-compose.yml b/controller/docker-compose.yml new file mode 100644 index 0000000..a6de6c1 --- /dev/null +++ b/controller/docker-compose.yml @@ -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 diff --git a/controller/example-felhom-metadata.yml b/controller/example-felhom-metadata.yml new file mode 100644 index 0000000..7f7cae6 --- /dev/null +++ b/controller/example-felhom-metadata.yml @@ -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. + +# --- 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 diff --git a/controller/go.mod b/controller/go.mod new file mode 100644 index 0000000..604e27e --- /dev/null +++ b/controller/go.mod @@ -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 +) diff --git a/controller/hashpass.go b/controller/hashpass.go new file mode 100644 index 0000000..b3e812d --- /dev/null +++ b/controller/hashpass.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + "os" + + "golang.org/x/crypto/bcrypt" +) + +// Usage: go run scripts/hashpass.go +// Outputs a bcrypt hash suitable for controller.yaml password_hash field. +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "Usage: %s \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)) +} diff --git a/controller/main.go b/controller/main.go new file mode 100644 index 0000000..39c90fb --- /dev/null +++ b/controller/main.go @@ -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 +} diff --git a/controller/manager.go b/controller/manager.go new file mode 100644 index 0000000..eab3979 --- /dev/null +++ b/controller/manager.go @@ -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 ©, 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 +} diff --git a/controller/metadata.go b/controller/metadata.go new file mode 100644 index 0000000..d9a3e26 --- /dev/null +++ b/controller/metadata.go @@ -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 +} diff --git a/controller/mnt/user-data/outputs/felhom-controller/README.md b/controller/mnt/user-data/outputs/felhom-controller/README.md new file mode 100644 index 0000000..a8dfea5 --- /dev/null +++ b/controller/mnt/user-data/outputs/felhom-controller/README.md @@ -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//` +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 diff --git a/controller/mnt/user-data/outputs/felhom-controller/assets/README.md b/controller/mnt/user-data/outputs/felhom-controller/assets/README.md new file mode 100644 index 0000000..968a5f3 --- /dev/null +++ b/controller/mnt/user-data/outputs/felhom-controller/assets/README.md @@ -0,0 +1,31 @@ +# App Assets + +This directory contains logos and screenshots for the dashboard. +They are baked into the Docker image at build time. + +## Naming convention + +Files must follow the felhom.eu website convention: + +- `{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.svg +paperless-ngx-screenshot-1.webp +adventurelog-logo.png +adventurelog-screenshot-1.webp +``` + +## Syncing from felhom.eu website + +Run `make sync-assets` to copy assets from the felhom.eu website repo. +This expects the website files to be available at `../felhom.eu/website/assets/` +(relative to this repo), or set `WEBSITE_ASSETS_DIR` to override. + +Alternatively, copy files manually from FileBrowser at https://felhom.eu. diff --git a/controller/router.go b/controller/router.go new file mode 100644 index 0000000..98ad45b --- /dev/null +++ b/controller/router.go @@ -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) + } +} diff --git a/controller/server.go b/controller/server.go new file mode 100644 index 0000000..affdd74 --- /dev/null +++ b/controller/server.go @@ -0,0 +1,405 @@ +package web + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "html/template" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" + "golang.org/x/crypto/bcrypt" +) + +type Server struct { + cfg *config.Config + stackMgr *stacks.Manager + logger *log.Logger + version string + tmpl *template.Template + + sessions map[string]*session + sessionsMu sync.RWMutex +} + +type session struct { + token string + expiresAt time.Time +} + +const ( + sessionCookieName = "felhom_session" + sessionMaxAge = 24 * time.Hour +) + +func NewServer(cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger, version string) *Server { + s := &Server{ + cfg: cfg, + stackMgr: stackMgr, + logger: logger, + version: version, + sessions: make(map[string]*session), + } + s.loadTemplates() + go s.cleanupSessions() + return s +} + +func (s *Server) loadTemplates() { + funcMap := template.FuncMap{ + "stateColor": func(state stacks.ContainerState) string { + switch state { + case stacks.StateRunning: + return "green" + case stacks.StateStopped, stacks.StateExited: + return "red" + case stacks.StateRestarting: + return "yellow" + default: + return "gray" + } + }, + "stateLabel": func(state stacks.ContainerState) string { + switch state { + case stacks.StateRunning: + return "Fut" + case stacks.StateStopped, stacks.StateExited: + return "Leállítva" + case stacks.StateRestarting: + return "Újraindítás..." + case stacks.StateNotDeployed: + return "Nincs telepítve" + case stacks.StatePaused: + return "Szüneteltetve" + default: + return "Ismeretlen" + } + }, + "stateIcon": func(state stacks.ContainerState) string { + switch state { + case stacks.StateRunning: + return "●" + case stacks.StateStopped, stacks.StateExited: + return "○" + case stacks.StateRestarting: + return "◐" + default: + return "◌" + } + }, + "stateStr": func(state stacks.ContainerState) string { + return string(state) + }, + "logoURL": func(slug string) string { + return s.cfg.AppLogoURL(slug) + }, + "logoPNGURL": func(slug string) string { + return s.cfg.AppLogoPNGURL(slug) + }, + "appPageURL": func(slug string) string { + return s.cfg.AppPageURL(slug) + }, + } + + s.tmpl = template.Must(template.New("").Funcs(funcMap).Parse(allTemplates)) +} + +// ServeHTTP handles all non-API web requests. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + switch { + case path == "/" || path == "/dashboard": + s.dashboardHandler(w, r) + case path == "/stacks": + s.stacksHandler(w, r) + case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"): + name := strings.TrimPrefix(path, "/stacks/") + name = strings.TrimSuffix(name, "/logs") + s.logsHandler(w, r, name) + case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/deploy"): + name := strings.TrimPrefix(path, "/stacks/") + name = strings.TrimSuffix(name, "/deploy") + s.deployHandler(w, r, name) + case path == "/static/style.css": + w.Header().Set("Content-Type", "text/css") + w.Header().Set("Cache-Control", "public, max-age=3600") + fmt.Fprint(w, cssContent) + case strings.HasPrefix(path, "/static/assets/"): + // Serve baked-in app assets (logos, screenshots) + s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/")) + case strings.HasPrefix(path, "/apps/"): + slug := strings.TrimPrefix(path, "/apps/") + s.appDetailHandler(w, r, slug) + default: + http.NotFound(w, r) + } +} + +// RequireAuth returns middleware that checks for valid session or shows login. +func (s *Server) RequireAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Skip auth if no password is configured + if s.cfg.Web.PasswordHash == "" { + next.ServeHTTP(w, r) + return + } + + if r.URL.Path == "/api/health" { + next.ServeHTTP(w, r) + return + } + + if r.URL.Path == "/login" && r.Method == http.MethodPost { + s.handleLogin(w, r) + return + } + if r.URL.Path == "/login" { + s.renderLogin(w, "") + return + } + if r.URL.Path == "/logout" { + s.handleLogout(w, r) + return + } + + cookie, err := r.Cookie(sessionCookieName) + if err != nil || !s.isValidSession(cookie.Value) { + if strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + fmt.Fprint(w, `{"ok":false,"error":"authentication required"}`) + return + } + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + next.ServeHTTP(w, r) + }) +} + +// --- Auth helpers --- + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + password := r.FormValue("password") + + if password == "" { + s.renderLogin(w, "Kérjük adja meg a jelszót") + return + } + + if err := bcrypt.CompareHashAndPassword([]byte(s.cfg.Web.PasswordHash), []byte(password)); err != nil { + s.logger.Printf("[WARN] Failed login from %s", r.RemoteAddr) + s.renderLogin(w, "Hibás jelszó") + return + } + + token := s.createSession() + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + MaxAge: int(sessionMaxAge.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Secure: true, + }) + + s.logger.Printf("[INFO] Login from %s", r.RemoteAddr) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie(sessionCookieName); err == nil { + s.sessionsMu.Lock() + delete(s.sessions, cookie.Value) + s.sessionsMu.Unlock() + } + http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1}) + http.Redirect(w, r, "/login", http.StatusFound) +} + +func (s *Server) createSession() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + token := hex.EncodeToString(b) + + s.sessionsMu.Lock() + s.sessions[token] = &session{token: token, expiresAt: time.Now().Add(sessionMaxAge)} + s.sessionsMu.Unlock() + + return token +} + +func (s *Server) isValidSession(token string) bool { + s.sessionsMu.RLock() + defer s.sessionsMu.RUnlock() + + sess, ok := s.sessions[token] + if !ok || time.Now().After(sess.expiresAt) { + return false + } + return subtle.ConstantTimeCompare([]byte(sess.token), []byte(token)) == 1 +} + +func (s *Server) cleanupSessions() { + for range time.Tick(15 * time.Minute) { + s.sessionsMu.Lock() + now := time.Now() + for t, sess := range s.sessions { + if now.After(sess.expiresAt) { + delete(s.sessions, t) + } + } + s.sessionsMu.Unlock() + } +} + +// --- Page handlers --- + +func (s *Server) baseData(page, title string) map[string]interface{} { + return map[string]interface{}{ + "Page": page, + "Title": title, + "CustomerName": s.cfg.Customer.Name, + "Domain": s.cfg.Customer.Domain, + "Version": s.version, + } +} + +func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) { + stackList := s.stackMgr.GetStacks() + + running, stopped := 0, 0 + for _, st := range stackList { + switch st.State { + case stacks.StateRunning: + running++ + case stacks.StateStopped, stacks.StateExited: + stopped++ + } + } + + data := s.baseData("dashboard", "Vezérlőpult") + data["Stacks"] = stackList + data["RunningCount"] = running + data["StoppedCount"] = stopped + data["TotalCount"] = len(stackList) + + s.render(w, "dashboard", data) +} + +func (s *Server) stacksHandler(w http.ResponseWriter, _ *http.Request) { + data := s.baseData("stacks", "Alkalmazások") + data["Stacks"] = s.stackMgr.GetStacks() + s.render(w, "stacks", data) +} + +func (s *Server) logsHandler(w http.ResponseWriter, _ *http.Request, name string) { + stack, ok := s.stackMgr.GetStack(name) + if !ok { + http.NotFound(w, nil) + return + } + + logs, err := s.stackMgr.GetLogs(name, 200) + if err != nil { + logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err) + } + + data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók") + data["Stack"] = stack + data["Logs"] = logs + s.render(w, "logs", data) +} + +func (s *Server) deployHandler(w http.ResponseWriter, _ *http.Request, name string) { + meta, appCfg, err := s.stackMgr.GetDeployFields(name) + if err != nil { + http.NotFound(w, nil) + return + } + + stack, _ := s.stackMgr.GetStack(name) + + data := s.baseData("deploy", meta.DisplayName+" — Telepítés") + data["Stack"] = stack + data["Meta"] = meta + data["AppConfig"] = appCfg + data["AlreadyDeployed"] = appCfg != nil && appCfg.Deployed + data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug) + data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug) + data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug) + data["UserFields"] = meta.UserFacingFields() + data["AutoFields"] = meta.AutoGeneratedFields() + + s.render(w, "deploy", data) +} + +// serveAsset serves baked-in app assets (logos, screenshots) from /usr/share/felhom/assets/ +// These are copied into the container at build time. +const assetsDir = "/usr/share/felhom/assets" + +func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename string) { + // Sanitize: prevent directory traversal + filename = filepath.Base(filename) + path := filepath.Join(assetsDir, filename) + + if _, err := os.Stat(path); os.IsNotExist(err) { + http.NotFound(w, r) + return + } + + w.Header().Set("Cache-Control", "public, max-age=86400") + http.ServeFile(w, r, path) +} + +// appDetailHandler serves a local app detail page (description, screenshots, FAQ). +// TODO: Phase 1.5 — for now, redirect to the stacks page. +// Future: render a dedicated app page template with baked-in content. +func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) { + // Find the stack by slug + for _, stack := range s.stackMgr.GetStacks() { + if stack.Meta.Slug == slug { + // For now, redirect to deploy page (if not deployed) or stacks page + if !stack.Deployed { + http.Redirect(w, r, "/stacks/"+stack.Name+"/deploy", http.StatusFound) + } else { + http.Redirect(w, r, "/stacks", http.StatusFound) + } + return + } + } + http.NotFound(w, r) +} + +func (s *Server) renderLogin(w http.ResponseWriter, errorMsg string) { + data := map[string]interface{}{ + "Title": "Bejelentkezés", + "CustomerName": s.cfg.Customer.Name, + "Error": errorMsg, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmpl.ExecuteTemplate(w, "login", data); err != nil { + s.logger.Printf("[ERROR] Template error (login): %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + } +} + +func (s *Server) render(w http.ResponseWriter, name string, data interface{}) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { + s.logger.Printf("[ERROR] Template error (%s): %v", name, err) + http.Error(w, "Internal error", http.StatusInternalServerError) + } +} diff --git a/controller/templates.go b/controller/templates.go new file mode 100644 index 0000000..d93a0f8 --- /dev/null +++ b/controller/templates.go @@ -0,0 +1,540 @@ +package web + +// All HTML templates and CSS are embedded as Go strings. +// Compiled into the binary — zero external file dependencies at runtime. +// As the UI grows, switch to go:embed for easier editing. + +const allTemplates = layoutTmpl + dashboardTmpl + stacksTmpl + loginTmpl + logsTmpl + deployTmpl + +const layoutTmpl = ` +{{define "layout_start"}} + + + + + + {{.Title}} — Felhom + + + + +
+{{end}} + +{{define "layout_end"}} +
+ + + +{{end}} +` + +const dashboardTmpl = ` +{{define "dashboard"}} +{{template "layout_start" .}} + + + +
+
+
{{.RunningCount}}
+
Futó alkalmazás
+
+
+
{{.StoppedCount}}
+
Leállítva
+
+
+
{{.TotalCount}}
+
Összes alkalmazás
+
+
+ +

Alkalmazások állapota

+ +
+ {{range .Stacks}} +
+
+ +
+ {{.Meta.DisplayName}} + {{if .Meta.Description}}{{.Meta.Description}}{{end}} +
+
+
+ {{stateLabel .State}} + + {{if .Protected}} + 🔒 Védett + {{else if not .Deployed}} + 🚀 Telepítés + {{else}} + {{if eq (stateStr .State) "running"}} + + + {{else}} + + {{end}} + 📋 + {{end}} +
+
+ {{else}} +
+

Nincs elérhető alkalmazás.

+
+ {{end}} +
+ +{{template "layout_end" .}} +{{end}} +` + +const stacksTmpl = ` +{{define "stacks"}} +{{template "layout_start" .}} + + + +
+ {{range .Stacks}} +
+
+
+ +
+

{{.Meta.DisplayName}}

+ {{if .Meta.Subdomain}} + + {{.Meta.Subdomain}}.{{$.Domain}} ↗ + + {{end}} +
+
+ {{stateLabel .State}} +
+ + {{if .Meta.Description}} +

{{.Meta.Description}}

+ {{end}} + +
+ {{if .Meta.Resources.RAM}}💾 {{.Meta.Resources.RAM}}{{end}} + {{if .Meta.Resources.PiCompatible}}🥧 Pi kompatibilis{{end}} + {{if .Meta.Resources.NeedsHDD}}💿 HDD szükséges{{end}} +
+ + {{if .Containers}} +
+ {{range .Containers}} +
+ {{.Name}} + {{.Status}} +
+ {{end}} +
+ {{end}} + +
+ {{if .Protected}} + 🔒 Védett rendszerkomponens + {{else if not .Deployed}} + 🚀 Telepítés + ℹ️ Részletek + {{else}} + {{if eq (stateStr .State) "running"}} + + + + {{else}} + + {{end}} + 📋 Naplók + ℹ️ Részletek + {{end}} +
+
+ {{end}} +
+ +{{template "layout_end" .}} +{{end}} +` + +const deployTmpl = ` +{{define "deploy"}} +{{template "layout_start" .}} + + + +
+
+ +
+

{{.Meta.DisplayName}}

+ {{if .Meta.Description}}

{{.Meta.Description}}

{{end}} +
+ {{if .Meta.Resources.RAM}}💾 {{.Meta.Resources.RAM}}{{end}} + {{if .Meta.Resources.PiCompatible}}🥧 Pi kompatibilis{{end}} + {{if .Meta.Resources.NeedsHDD}}💿 HDD szükséges{{end}} +
+ + ℹ️ Részletes leírás, képernyőképek + +
+
+ + {{if .AlreadyDeployed}} +
+ Ez az alkalmazás már telepítve van. Az alábbi beállítások csak olvashatók. +
+ {{end}} + +
+ {{if .AutoFields}} +
+

🔒 Automatikusan generált értékek

+

Ezek az értékek automatikusan létrejönnek a telepítéskor.

+ {{range .AutoFields}} +
+ + ✓ Automatikusan generálva +
+ {{end}} +
+ {{end}} + + {{if .UserFields}} +
+

⚙️ Beállítások

+ {{range .UserFields}} +
+ + + {{if eq .Type "select"}} + + {{else if eq .Type "password"}} +
+ + +
+ {{else if eq .Type "boolean"}} + + {{else}} + + {{end}} + + {{if .Description}} + {{.Description}} + {{end}} +
+ {{end}} +
+ {{end}} + + {{if not .AlreadyDeployed}} +
+ + Mégsem +
+ {{end}} +
+
+ + + +{{template "layout_end" .}} +{{end}} +` + +const loginTmpl = ` +{{define "login"}} + + + + + + Bejelentkezés — Felhom + + + + + + +{{end}} +` + +const logsTmpl = ` +{{define "logs"}} +{{template "layout_start" .}} + +
+
{{.Logs}}
+
+
+ +
+{{template "layout_end" .}} +{{end}} +` + +// CSS is defined in a separate const for readability. +// Served at /static/style.css +const cssContent = ` +:root { + --bg:#f8f9fa; --sidebar-bg:#1a1f36; --sidebar-text:#e2e8f0; + --card-bg:#fff; --text:#1a202c; --text-muted:#718096; --border:#e2e8f0; + --green:#38a169; --green-light:#c6f6d5; --red:#e53e3e; --red-light:#fed7d7; + --yellow:#d69e2e; --yellow-light:#fefcbf; --blue:#3182ce; --blue-light:#bee3f8; + --gray:#a0aec0; --gray-light:#edf2f7; --radius:8px; --shadow:0 1px 3px rgba(0,0,0,.1); +} +*{margin:0;padding:0;box-sizing:border-box} +body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);display:flex;min-height:100vh} + +.sidebar{width:240px;background:var(--sidebar-bg);color:var(--sidebar-text);display:flex;flex-direction:column;position:fixed;height:100vh;overflow-y:auto} +.sidebar-header{padding:1.5rem;border-bottom:1px solid rgba(255,255,255,.1)} +.logo{font-size:1.5rem;font-weight:700;color:#fff} +.customer-name{display:block;font-size:.85rem;color:var(--gray);margin-top:.25rem} +.nav-links{list-style:none;padding:1rem 0;flex:1} +.nav-links a{display:block;padding:.75rem 1.5rem;color:var(--sidebar-text);text-decoration:none;font-size:.95rem;transition:background .15s} +.nav-links a:hover{background:rgba(255,255,255,.08)} +.nav-links a.active{background:rgba(255,255,255,.12);border-left:3px solid var(--blue)} +.sidebar-footer{padding:1rem 1.5rem;border-top:1px solid rgba(255,255,255,.1);display:flex;justify-content:space-between;align-items:center;font-size:.8rem} +.version{color:var(--gray)} .logout-link{color:var(--gray);text-decoration:none} .logout-link:hover{color:#fff} + +.content{margin-left:240px;padding:2rem;flex:1;max-width:1200px} +.page-header{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem} +.page-header h2{font-size:1.5rem;font-weight:600} +.domain-badge{background:var(--blue-light);color:var(--blue);padding:.25rem .75rem;border-radius:999px;font-size:.8rem;font-weight:500} + +.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem} +.stat-card{background:var(--card-bg);border-radius:var(--radius);padding:1.25rem;box-shadow:var(--shadow);border-left:4px solid var(--gray)} +.stat-running{border-left-color:var(--green)} .stat-stopped{border-left-color:var(--red)} .stat-total{border-left-color:var(--blue)} +.stat-value{font-size:2rem;font-weight:700} .stat-label{color:var(--text-muted);font-size:.85rem;margin-top:.25rem} + +.stack-list{display:flex;flex-direction:column;gap:.5rem} +.stack-card{background:var(--card-bg);border-radius:var(--radius);padding:1rem 1.25rem;box-shadow:var(--shadow);display:flex;justify-content:space-between;align-items:center;border-left:4px solid var(--gray)} +.stack-state-green{border-left-color:var(--green)} .stack-state-red{border-left-color:var(--red)} .stack-state-yellow{border-left-color:var(--yellow)} .stack-state-gray{border-left-color:var(--gray)} +.stack-info{display:flex;align-items:center;gap:.75rem} +.stack-logo{width:32px;height:32px;border-radius:6px;object-fit:contain;background:#1c2128;padding:4px} +.stack-logo-lg{width:48px;height:48px;border-radius:8px;object-fit:contain;background:#1c2128;padding:6px} +.stack-name{font-size:1rem} .stack-desc{display:block;font-size:.8rem;color:var(--text-muted)} +.stack-actions{display:flex;align-items:center;gap:.5rem} .stack-state-label{font-size:.8rem;color:var(--text-muted);margin-right:.5rem} + +.stack-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(350px,1fr));gap:1rem} +.stack-detail-card{background:var(--card-bg);border-radius:var(--radius);padding:1.25rem;box-shadow:var(--shadow);border-top:4px solid var(--gray)} +.stack-detail-card.stack-state-green{border-top-color:var(--green)} .stack-detail-card.stack-state-red{border-top-color:var(--red)} +.stack-detail-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.75rem} +.stack-title-row{display:flex;align-items:center;gap:.75rem} +.subdomain-link{font-size:.8rem;color:var(--blue);text-decoration:none} .subdomain-link:hover{text-decoration:underline} +.stack-state-badge{padding:.2rem .6rem;border-radius:999px;font-size:.75rem;font-weight:600;white-space:nowrap} +.state-green{background:var(--green-light);color:var(--green)} .state-red{background:var(--red-light);color:var(--red)} +.state-yellow{background:var(--yellow-light);color:var(--yellow)} .state-gray{background:var(--gray-light);color:var(--gray)} +.state-text-green{color:var(--green)} .state-text-red{color:var(--red)} +.stack-detail-desc{color:var(--text-muted);font-size:.85rem;margin-bottom:.75rem} +.stack-meta-badges{display:flex;flex-wrap:wrap;gap:.4rem;margin:.5rem 0} +.meta-badge{background:var(--gray-light);color:var(--text-muted);padding:.15rem .5rem;border-radius:6px;font-size:.75rem} +.meta-badge-ok{background:var(--green-light);color:var(--green)} +.container-list{margin:.75rem 0} .container-list h4{font-size:.8rem;color:var(--text-muted);margin-bottom:.4rem} +.container-row{display:flex;justify-content:space-between;font-size:.8rem;padding:.2rem 0} +.container-name{font-family:monospace} .container-status{font-size:.75rem} +.stack-detail-actions{display:flex;gap:.5rem;margin-top:1rem;flex-wrap:wrap} + +.btn{display:inline-flex;align-items:center;gap:.3rem;padding:.5rem 1rem;border:none;border-radius:6px;font-size:.85rem;font-weight:500;cursor:pointer;transition:opacity .15s,transform .1s;text-decoration:none;color:#fff} +.btn:hover{opacity:.9} .btn:active{transform:scale(.97)} .btn:disabled{opacity:.5;cursor:not-allowed} .btn.loading{opacity:.6} +.btn-sm{padding:.3rem .6rem;font-size:.8rem} .btn-lg{padding:.65rem 1.5rem;font-size:1rem} .btn-full{width:100%;justify-content:center} +.btn-primary{background:var(--blue)} .btn-success{background:var(--green)} .btn-warning{background:var(--yellow);color:#1a202c} .btn-danger{background:var(--red)} +.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)} .btn-outline:hover{background:var(--gray-light)} +.badge{display:inline-flex;align-items:center;gap:.25rem;padding:.2rem .6rem;border-radius:999px;font-size:.75rem;font-weight:500} +.badge-protected{background:var(--gray-light);color:var(--text-muted)} + +/* Deploy page */ +.deploy-container{max-width:700px} +.deploy-info{display:flex;gap:1rem;align-items:flex-start;background:var(--card-bg);padding:1.25rem;border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:1.5rem} +.deploy-logo{width:64px;height:64px;border-radius:12px;object-fit:contain;background:#1c2128;padding:8px;flex-shrink:0} +.deploy-info h3{font-size:1.2rem;margin-bottom:.25rem} +.deploy-info p{color:var(--text-muted);font-size:.9rem} +.deploy-form{background:var(--card-bg);padding:1.5rem;border-radius:var(--radius);box-shadow:var(--shadow)} +.form-section{margin-bottom:1.5rem} +.form-section h4{font-size:1rem;margin-bottom:.5rem} +.form-section-desc{color:var(--text-muted);font-size:.85rem;margin-bottom:.75rem} +.form-group{margin-bottom:1rem} +.form-group label{display:block;font-size:.85rem;font-weight:500;margin-bottom:.4rem} +.form-group-auto{display:flex;justify-content:space-between;align-items:center;padding:.5rem .75rem;background:var(--gray-light);border-radius:6px} +.form-group-auto label{margin:0} +.auto-generated-badge{color:var(--green);font-size:.8rem;font-weight:500} +.form-control{width:100%;padding:.55rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.9rem;background:#fff} +.form-control:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px rgba(49,130,206,.1)} +.form-control:disabled{background:var(--gray-light);cursor:not-allowed} +.input-with-button{display:flex;gap:.5rem} +.input-with-button .form-control{flex:1} +.form-hint{display:block;font-size:.8rem;color:var(--text-muted);margin-top:.25rem} +.required{color:var(--red)} .locked-hint{font-size:.75rem;color:var(--text-muted);font-weight:400;margin-left:.5rem} +.deploy-actions{display:flex;gap:.75rem;margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border)} + +.alert{padding:.75rem;border-radius:6px;margin-bottom:1rem;font-size:.85rem} +.alert-error{background:var(--red-light);color:var(--red)} .alert-info{background:var(--blue-light);color:var(--blue)} + +/* Logs */ +.logs-container{background:#1a1f36;border-radius:var(--radius);padding:1rem;overflow-x:auto;margin-bottom:1rem} +.logs-output{color:#e2e8f0;font-family:'JetBrains Mono','Fira Code',monospace;font-size:.8rem;line-height:1.5;white-space:pre-wrap;word-break:break-all} +.logs-actions{display:flex;gap:.5rem} + +.empty-state{text-align:center;padding:3rem;color:var(--text-muted)} +.login-body{display:flex;justify-content:center;align-items:center;min-height:100vh;background:linear-gradient(135deg,#1a1f36,#2d3748)} +.login-card{background:var(--card-bg);padding:2.5rem;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.15);width:100%;max-width:380px;text-align:center} +.login-card .logo{color:var(--text);margin-bottom:.25rem} .login-subtitle{color:var(--text-muted);margin-bottom:1.5rem} +.login-footer{margin-top:1.5rem;font-size:.75rem;color:var(--text-muted)} .login-footer a{color:var(--blue);text-decoration:none} + +@media(max-width:768px){ + .sidebar{width:100%;height:auto;position:relative} + .nav-links{display:flex;padding:0;overflow-x:auto} .nav-links a{padding:.5rem 1rem;white-space:nowrap} + .content{margin-left:0;padding:1rem} body{flex-direction:column} + .stack-card{flex-direction:column;align-items:flex-start;gap:.75rem} .stack-actions{width:100%;justify-content:flex-end} + .stack-grid{grid-template-columns:1fr} .stats-grid{grid-template-columns:repeat(3,1fr)} + .deploy-info{flex-direction:column} +} +`