added controller
This commit is contained in:
@@ -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
|
||||||
@@ -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 <namespace> 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 <namespace>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify it works:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login to the registry (use your Gitea username + password or access token)
|
||||||
|
docker login gitea.dooplex.hu
|
||||||
|
# Username: admin
|
||||||
|
# Password: <your-gitea-password-or-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
If login succeeds, the registry is working. The image URL pattern is:
|
||||||
|
`gitea.dooplex.hu/<owner>/<image>:<tag>`
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>/app.yaml
|
||||||
|
#
|
||||||
|
# Environment variable overrides: FELHOM_<SECTION>_<KEY>
|
||||||
|
# (e.g., FELHOM_CUSTOMER_DOMAIN=example.hu)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Customer identity ---
|
||||||
|
customer:
|
||||||
|
id: "demo-felhom" # Unique customer identifier
|
||||||
|
name: "Demo Ügyfél" # Display name (shown on dashboard)
|
||||||
|
domain: "demo-felhom.eu" # Base domain for all services
|
||||||
|
email: "" # Customer notification email (optional)
|
||||||
|
telegram_chat_id: "" # Telegram notifications (optional, future)
|
||||||
|
|
||||||
|
# --- Infrastructure secrets ---
|
||||||
|
infrastructure:
|
||||||
|
cf_tunnel_token: "" # Cloudflare Tunnel token
|
||||||
|
cf_api_token: "" # Cloudflare API token (DNS-01 challenge)
|
||||||
|
|
||||||
|
# --- Paths (system-level only) ---
|
||||||
|
paths:
|
||||||
|
stacks_dir: "/opt/docker/stacks" # Where compose files live
|
||||||
|
data_dir: "/opt/docker/felhom-controller/data"
|
||||||
|
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"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# .felhom.yml — App metadata for felhom-controller
|
||||||
|
# =============================================================================
|
||||||
|
# Place alongside docker-compose.yml in each stack directory:
|
||||||
|
# /opt/docker/stacks/paperless-ngx/.felhom.yml
|
||||||
|
#
|
||||||
|
# This file defines:
|
||||||
|
# 1. Display info (name, description, icon)
|
||||||
|
# 2. Deploy fields (what the user fills in during first deployment)
|
||||||
|
# 3. Asset references (logos, screenshots loaded from felhom.eu)
|
||||||
|
# 4. Resource hints (RAM, Pi compatibility)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- Display info (shown on dashboard) ---
|
||||||
|
display_name: "Paperless-ngx"
|
||||||
|
description: "Dokumentumok digitalizálása és rendszerezése"
|
||||||
|
category: "productivity" # productivity, media, finance, security, tools
|
||||||
|
subdomain: "paperless" # -> paperless.<domain>
|
||||||
|
|
||||||
|
# --- Asset slug ---
|
||||||
|
# Used to construct URLs for logo and screenshots from felhom.eu:
|
||||||
|
# Logo: {assets.base_url}/assets/{slug}-logo.webp
|
||||||
|
# Screenshot: {assets.base_url}/assets/{slug}-screenshot-{n}.webp
|
||||||
|
# App page: {assets.base_url}/alkalmazasok#{slug}
|
||||||
|
# Falls back to directory name if not set.
|
||||||
|
slug: "paperless-ngx"
|
||||||
|
|
||||||
|
# --- Resource hints (displayed on deploy screen) ---
|
||||||
|
resources:
|
||||||
|
ram: "~500MB"
|
||||||
|
pi_compatible: true # Runs on Raspberry Pi 3B+
|
||||||
|
needs_hdd: true # Needs external storage for user data
|
||||||
|
|
||||||
|
# --- Deploy fields ---
|
||||||
|
# Shown to the user during first deployment.
|
||||||
|
# After deployment, values are saved to app.yaml in the stack directory.
|
||||||
|
#
|
||||||
|
# Field types:
|
||||||
|
# domain - Auto-filled from controller config, read-only
|
||||||
|
# secret - Auto-generated, hidden (user sees "Generated ✓")
|
||||||
|
# password - Auto-generated but shown, user can override
|
||||||
|
# path - Filesystem path (validated for existence)
|
||||||
|
# text - Free text input
|
||||||
|
# select - Dropdown with predefined options
|
||||||
|
# boolean - Toggle switch
|
||||||
|
#
|
||||||
|
# Generator types (for secret/password):
|
||||||
|
# password:N - N chars alphanumeric
|
||||||
|
# hex:N - N bytes hex-encoded
|
||||||
|
# static:VAL - Fixed value
|
||||||
|
|
||||||
|
deploy_fields:
|
||||||
|
- env_var: DOMAIN
|
||||||
|
label: "Domain"
|
||||||
|
type: domain
|
||||||
|
description: "A szerver domain neve"
|
||||||
|
locked_after_deploy: true
|
||||||
|
|
||||||
|
- env_var: DB_PASSWORD
|
||||||
|
label: "Adatbázis jelszó"
|
||||||
|
type: secret
|
||||||
|
generate: "password:24"
|
||||||
|
locked_after_deploy: true
|
||||||
|
|
||||||
|
- env_var: PAPERLESS_SECRET_KEY
|
||||||
|
label: "Titkosítási kulcs"
|
||||||
|
type: secret
|
||||||
|
generate: "hex:32"
|
||||||
|
locked_after_deploy: true
|
||||||
|
|
||||||
|
- env_var: PAPERLESS_ADMIN_USER
|
||||||
|
label: "Admin felhasználónév"
|
||||||
|
type: text
|
||||||
|
default: "admin"
|
||||||
|
locked_after_deploy: false
|
||||||
|
|
||||||
|
- env_var: PAPERLESS_ADMIN_PASSWORD
|
||||||
|
label: "Admin jelszó"
|
||||||
|
type: password
|
||||||
|
generate: "password:16"
|
||||||
|
description: "Első bejelentkezéshez. Utána a webes felületen módosítható."
|
||||||
|
locked_after_deploy: false
|
||||||
|
|
||||||
|
- env_var: HDD_PATH
|
||||||
|
label: "Adattárolási útvonal"
|
||||||
|
type: path
|
||||||
|
required: true
|
||||||
|
placeholder: "/mnt/hdd_1"
|
||||||
|
description: "A külső merevlemez elérési útja, ahol a dokumentumok tárolódnak"
|
||||||
|
locked_after_deploy: true
|
||||||
|
|
||||||
|
- env_var: PAPERLESS_OCR_LANGUAGE
|
||||||
|
label: "OCR nyelv"
|
||||||
|
type: select
|
||||||
|
default: "hun+eng"
|
||||||
|
options:
|
||||||
|
- value: "hun"
|
||||||
|
label: "Magyar"
|
||||||
|
- value: "eng"
|
||||||
|
label: "Angol"
|
||||||
|
- value: "hun+eng"
|
||||||
|
label: "Magyar + Angol"
|
||||||
|
- value: "deu+eng"
|
||||||
|
label: "Német + Angol"
|
||||||
|
description: "Dokumentum felismerés nyelve"
|
||||||
|
locked_after_deploy: false
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Usage: go run scripts/hashpass.go <password>
|
||||||
|
// Outputs a bcrypt hash suitable for controller.yaml password_hash field.
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Usage: %s <password>\n", os.Args[0])
|
||||||
|
fmt.Fprintf(os.Stderr, "Generates a bcrypt hash for the felhom-controller config.\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
password := os.Args[1]
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error generating hash: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(string(hash))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package stacks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metadata holds app information parsed from .felhom.yml.
|
||||||
|
type Metadata struct {
|
||||||
|
DisplayName string `yaml:"display_name" json:"display_name"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
Category string `yaml:"category" json:"category"`
|
||||||
|
Subdomain string `yaml:"subdomain" json:"subdomain"`
|
||||||
|
Slug string `yaml:"slug" json:"slug"`
|
||||||
|
Resources ResourceHints `yaml:"resources" json:"resources"`
|
||||||
|
DeployFields []DeployField `yaml:"deploy_fields" json:"deploy_fields"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResourceHints describe what the app needs.
|
||||||
|
type ResourceHints struct {
|
||||||
|
RAM string `yaml:"ram" json:"ram"`
|
||||||
|
PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"`
|
||||||
|
NeedsHDD bool `yaml:"needs_hdd" json:"needs_hdd"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployField defines one configuration field shown during first deployment.
|
||||||
|
type DeployField struct {
|
||||||
|
EnvVar string `yaml:"env_var" json:"env_var"`
|
||||||
|
Label string `yaml:"label" json:"label"`
|
||||||
|
Type string `yaml:"type" json:"type"` // domain, secret, password, path, text, select, boolean
|
||||||
|
Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin"
|
||||||
|
Default string `yaml:"default" json:"default"`
|
||||||
|
Required bool `yaml:"required" json:"required"`
|
||||||
|
Placeholder string `yaml:"placeholder" json:"placeholder"`
|
||||||
|
Description string `yaml:"description" json:"description"`
|
||||||
|
LockedAfterDeploy bool `yaml:"locked_after_deploy" json:"locked_after_deploy"`
|
||||||
|
Options []SelectOption `yaml:"options" json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectOption is a choice for "select" type fields.
|
||||||
|
type SelectOption struct {
|
||||||
|
Value string `yaml:"value" json:"value"`
|
||||||
|
Label string `yaml:"label" json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadMetadata reads .felhom.yml from a stack directory.
|
||||||
|
// Returns default metadata if the file doesn't exist.
|
||||||
|
func LoadMetadata(stackDir string) Metadata {
|
||||||
|
meta := Metadata{}
|
||||||
|
|
||||||
|
path := filepath.Join(stackDir, ".felhom.yml")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
// No metadata file — build defaults from directory name
|
||||||
|
dirName := filepath.Base(stackDir)
|
||||||
|
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
||||||
|
meta.Slug = dirName
|
||||||
|
meta.Category = "tools"
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := yaml.Unmarshal(data, &meta); err != nil {
|
||||||
|
// Parse error — still return defaults
|
||||||
|
dirName := filepath.Base(stackDir)
|
||||||
|
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
||||||
|
meta.Slug = dirName
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill in defaults for missing fields
|
||||||
|
dirName := filepath.Base(stackDir)
|
||||||
|
if meta.Slug == "" {
|
||||||
|
meta.Slug = dirName
|
||||||
|
}
|
||||||
|
if meta.DisplayName == "" {
|
||||||
|
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
|
||||||
|
}
|
||||||
|
if meta.Category == "" {
|
||||||
|
meta.Category = "tools"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOMAIN field is always auto-filled — mark it implicitly required
|
||||||
|
for i := range meta.DeployFields {
|
||||||
|
if meta.DeployFields[i].Type == "domain" {
|
||||||
|
meta.DeployFields[i].Required = true
|
||||||
|
meta.DeployFields[i].LockedAfterDeploy = true
|
||||||
|
}
|
||||||
|
// secret fields are always locked after deploy
|
||||||
|
if meta.DeployFields[i].Type == "secret" {
|
||||||
|
meta.DeployFields[i].LockedAfterDeploy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasDeployFields returns true if the app has any user-facing deploy fields
|
||||||
|
// (i.e., fields beyond auto-filled domain and auto-generated secrets).
|
||||||
|
func (m *Metadata) HasDeployFields() bool {
|
||||||
|
for _, f := range m.DeployFields {
|
||||||
|
if f.Type != "domain" && f.Type != "secret" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserFacingFields returns only fields the user needs to interact with.
|
||||||
|
// Excludes auto-filled (domain) and fully hidden (secret) fields.
|
||||||
|
func (m *Metadata) UserFacingFields() []DeployField {
|
||||||
|
var fields []DeployField
|
||||||
|
for _, f := range m.DeployFields {
|
||||||
|
if f.Type != "domain" && f.Type != "secret" {
|
||||||
|
fields = append(fields, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoGeneratedFields returns fields that are generated without user input.
|
||||||
|
func (m *Metadata) AutoGeneratedFields() []DeployField {
|
||||||
|
var fields []DeployField
|
||||||
|
for _, f := range m.DeployFields {
|
||||||
|
if f.Type == "secret" || f.Type == "domain" {
|
||||||
|
fields = append(fields, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
# felhom-controller
|
||||||
|
|
||||||
|
**Central management container for Felhom home servers.**
|
||||||
|
|
||||||
|
Replaces Portainer + scattered systemd scripts with a single, lightweight container that provides:
|
||||||
|
- Hungarian-language web dashboard for customers
|
||||||
|
- Docker Compose stack management (start/stop/update)
|
||||||
|
- Backup orchestration (DB dumps + restic snapshots)
|
||||||
|
- System health monitoring with Healthchecks pings
|
||||||
|
- Git-based stack synchronization with update management
|
||||||
|
- Self-update with automatic rollback on failure
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Customer Hardware (N100 mini PC / Raspberry Pi) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────┐ ┌────────────────────────────────────────────┐ │
|
||||||
|
│ │ Traefik │ │ felhom-controller │ │
|
||||||
|
│ │ (reverse │──▶│ │ │
|
||||||
|
│ │ proxy) │ │ ┌──────────┐ ┌─────────────────────────┐│ │
|
||||||
|
│ └──────────┘ │ │ Web UI │ │ Stack Manager ││ │
|
||||||
|
│ │ │ (HU dash │ │ (compose up/down/pull, ││ │
|
||||||
|
│ ┌──────────┐ │ │ board) │ │ git sync, update mgmt) ││ │
|
||||||
|
│ │cloudflared│ │ └──────────┘ └─────────────────────────┘│ │
|
||||||
|
│ │ (tunnel) │ │ ┌──────────┐ ┌─────────────────────────┐│ │
|
||||||
|
│ └──────────┘ │ │ Backup │ │ Monitor & Pinger ││ │
|
||||||
|
│ │ │ (db dump │ │ (healthchecks pings, ││ │
|
||||||
|
│ ┌──────────┐ │ │ restic) │ │ system metrics) ││ │
|
||||||
|
│ │ App │ │ └──────────┘ └─────────────────────────┘│ │
|
||||||
|
│ │ stacks │ │ ┌──────────┐ ┌─────────────────────────┐│ │
|
||||||
|
│ │ (docker │ │ │Scheduler │ │ REST API ││ │
|
||||||
|
│ │ compose) │ │ │(cron-like│ │ (for UI + remote mgmt) ││ │
|
||||||
|
│ └──────────┘ │ │ jobs) │ └─────────────────────────┘│ │
|
||||||
|
│ │ └──────────┘ │ │
|
||||||
|
│ └────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
│ pings │ git pull
|
||||||
|
▼ ▼
|
||||||
|
status.felhom.eu gitea.dooplex.hu
|
||||||
|
(Healthchecks on k3s) (stack definitions)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Overview
|
||||||
|
|
||||||
|
| Module | Path | Responsibility |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| **Config** | `internal/config/` | Load & validate controller.yaml |
|
||||||
|
| **Stacks** | `internal/stacks/` | Docker Compose operations, catalog, container status |
|
||||||
|
| **Backup** | `internal/backup/` | DB dumps, restic snapshots, restore |
|
||||||
|
| **Monitor** | `internal/monitor/` | Health checks, Healthchecks pings, system metrics |
|
||||||
|
| **Scheduler** | `internal/scheduler/` | Cron-like job runner for all periodic tasks |
|
||||||
|
| **API** | `internal/api/` | REST API endpoints (consumed by web UI + remote mgmt) |
|
||||||
|
| **Web** | `internal/web/` | Dashboard UI, static files, server-side templates |
|
||||||
|
|
||||||
|
## Stack Management
|
||||||
|
|
||||||
|
### How stacks get onto the machine
|
||||||
|
|
||||||
|
1. During initial setup, `deploy-felhom-compose.sh` clones the app catalog
|
||||||
|
2. Compose files + `.felhom.yml` metadata land in `/opt/docker/stacks/<app>/`
|
||||||
|
3. The controller periodically pulls from Git to detect changes
|
||||||
|
|
||||||
|
### First deployment flow (via dashboard)
|
||||||
|
|
||||||
|
1. Customer sees app card with "🚀 Telepítés" (Deploy) button
|
||||||
|
2. Clicks → deploy page shows:
|
||||||
|
- **Auto-filled**: DOMAIN (from controller config), read-only
|
||||||
|
- **Auto-generated**: DB passwords, secret keys (shown as "✓ Generated")
|
||||||
|
- **User input**: HDD path, admin password, language, etc.
|
||||||
|
- **"🎲 Generálás"** button next to password fields
|
||||||
|
3. Clicks "Telepítés" → controller:
|
||||||
|
- Generates all secrets
|
||||||
|
- Validates required fields (checks path exists, etc.)
|
||||||
|
- Saves `app.yaml` (env vars + locked fields list)
|
||||||
|
- Runs `docker compose up -d` with env vars injected
|
||||||
|
4. Post-deploy: locked fields (DB_PASSWORD, etc.) become read-only
|
||||||
|
|
||||||
|
### Update strategy
|
||||||
|
|
||||||
|
Stack updates are classified in the Git repository via markers:
|
||||||
|
|
||||||
|
| Marker | Behavior |
|
||||||
|
|--------|----------|
|
||||||
|
| No marker | Optional update — shown on dashboard, customer clicks "Update" |
|
||||||
|
| `UPDATE_REQUIRED=true` | Mandatory — auto-applied during next update window |
|
||||||
|
| `UPDATE_SECURITY=true` | Critical — applied immediately (within minutes) |
|
||||||
|
|
||||||
|
The update window is configurable per customer (default: 03:00-05:00 local time).
|
||||||
|
|
||||||
|
### Protected stacks
|
||||||
|
|
||||||
|
The following stacks cannot be stopped from the customer UI:
|
||||||
|
- `traefik` (reverse proxy)
|
||||||
|
- `cloudflared` (tunnel)
|
||||||
|
- `felhom-controller` (this container)
|
||||||
|
|
||||||
|
## Backup Strategy
|
||||||
|
|
||||||
|
The controller replaces Backrest and manages backups directly:
|
||||||
|
|
||||||
|
1. **DB dumps** (default 02:30): Discovers running database containers, dumps via pg_dump/mysqldump
|
||||||
|
2. **Restic snapshots** (default 03:00): Backs up `/opt/docker/stacks/` data + DB dumps
|
||||||
|
3. **Verification**: Periodically checks snapshot integrity
|
||||||
|
4. **Pruning**: Configurable retention (default: 7 daily, 4 weekly, 6 monthly)
|
||||||
|
|
||||||
|
Backup status is displayed on the dashboard and reported to Healthchecks.
|
||||||
|
|
||||||
|
## Self-Update Mechanism
|
||||||
|
|
||||||
|
1. Controller checks for new image versions periodically
|
||||||
|
2. Before updating: creates a restic snapshot of its own config
|
||||||
|
3. Pulls new image, recreates container
|
||||||
|
4. Health check timeout (60s) — if new container doesn't become healthy → rollback
|
||||||
|
5. Rollback: restores previous image tag, restarts with old config
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Controller config (infrastructure only)
|
||||||
|
|
||||||
|
Single YAML file per customer: `/opt/docker/felhom-controller/controller.yaml`
|
||||||
|
|
||||||
|
Contains customer identity, infrastructure secrets, backup/monitoring settings.
|
||||||
|
Does **not** contain app-specific config (HDD paths, DB passwords, etc.).
|
||||||
|
|
||||||
|
See `configs/controller.yaml.example` for the full reference.
|
||||||
|
|
||||||
|
### Per-app config (created during deployment)
|
||||||
|
|
||||||
|
Each deployed app gets an `app.yaml` in its stack directory:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# /opt/docker/stacks/paperless-ngx/app.yaml
|
||||||
|
# Auto-generated by felhom-controller — do not edit locked fields manually
|
||||||
|
deployed: true
|
||||||
|
deployed_at: "2026-02-13T14:30:00Z"
|
||||||
|
env:
|
||||||
|
DOMAIN: "demo-felhom.eu"
|
||||||
|
DB_PASSWORD: "a7f2b9c1e4d..." # locked
|
||||||
|
PAPERLESS_SECRET_KEY: "8b3e..." # locked
|
||||||
|
PAPERLESS_ADMIN_USER: "admin" # editable
|
||||||
|
HDD_PATH: "/mnt/hdd_1" # locked
|
||||||
|
locked_fields:
|
||||||
|
- DB_PASSWORD
|
||||||
|
- PAPERLESS_SECRET_KEY
|
||||||
|
- DOMAIN
|
||||||
|
- HDD_PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields are defined in each stack's `.felhom.yml` metadata file. See
|
||||||
|
`configs/example-felhom-metadata.yml` for the full format.
|
||||||
|
|
||||||
|
### App assets (logos, screenshots, descriptions)
|
||||||
|
|
||||||
|
Baked into the container image at build time — no external dependencies at runtime.
|
||||||
|
Assets are synced from the felhom.eu website repo before building:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make sync-assets # copies from ../felhom.eu/website/assets/
|
||||||
|
make sync-assets WEBSITE_ASSETS_DIR=/path # or specify custom path
|
||||||
|
```
|
||||||
|
|
||||||
|
Served locally at `/static/assets/`. Naming convention matches the website:
|
||||||
|
|
||||||
|
| Asset | File pattern | Served at |
|
||||||
|
|-------|-------------|-----------|
|
||||||
|
| Logo (SVG) | `assets/{slug}-logo.svg` | `/static/assets/{slug}-logo.svg` |
|
||||||
|
| Logo (PNG fallback) | `assets/{slug}-logo.png` | `/static/assets/{slug}-logo.png` |
|
||||||
|
| Screenshot | `assets/{slug}-screenshot-{n}.webp` | `/static/assets/{slug}-screenshot-{n}.webp` |
|
||||||
|
|
||||||
|
## Build & Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build for both architectures
|
||||||
|
make build-all
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
make docker-build
|
||||||
|
|
||||||
|
# Push to registry
|
||||||
|
make docker-push
|
||||||
|
|
||||||
|
# Build for specific arch
|
||||||
|
make build-amd64
|
||||||
|
make build-arm64
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run locally (needs Docker socket)
|
||||||
|
go run ./cmd/controller/ --config configs/controller.yaml.example
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
golangci-lint run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
felhom-controller/
|
||||||
|
├── cmd/controller/ # Entry point
|
||||||
|
│ └── main.go
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # Configuration loading
|
||||||
|
│ │ └── config.go
|
||||||
|
│ ├── stacks/ # Docker Compose stack management
|
||||||
|
│ │ ├── manager.go # Core: scan, start, stop, restart, update, logs
|
||||||
|
│ │ ├── metadata.go # Parse .felhom.yml app metadata
|
||||||
|
│ │ └── deploy.go # First-deploy flow: secret gen, app.yaml, compose up
|
||||||
|
│ ├── backup/ # DB dumps + restic operations (Phase 3)
|
||||||
|
│ ├── monitor/ # Health checks + metrics (Phase 2)
|
||||||
|
│ ├── scheduler/ # Periodic job runner (Phase 2)
|
||||||
|
│ ├── api/ # REST API
|
||||||
|
│ │ └── router.go
|
||||||
|
│ └── web/ # Dashboard UI
|
||||||
|
│ ├── server.go # HTTP server, auth, page handlers
|
||||||
|
│ └── templates.go # Embedded HTML templates + CSS (Hungarian)
|
||||||
|
├── configs/ # Example config files
|
||||||
|
│ ├── controller.yaml.example
|
||||||
|
│ └── example-felhom-metadata.yml
|
||||||
|
├── docs/
|
||||||
|
│ └── BUILDING.md # Container image build & registry guide
|
||||||
|
├── scripts/
|
||||||
|
│ └── hashpass.go # Password hash generator
|
||||||
|
├── Dockerfile # Multi-stage build (Go + debian-slim)
|
||||||
|
├── docker-compose.yml # Controller's own compose definition
|
||||||
|
├── Makefile # Build targets (amd64, arm64, docker)
|
||||||
|
├── go.mod
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Status & Roadmap
|
||||||
|
|
||||||
|
### Phase 1 — Stack Manager + Deploy Flow (current)
|
||||||
|
- [x] Project skeleton & config format
|
||||||
|
- [x] .felhom.yml app metadata format with deploy fields
|
||||||
|
- [x] Per-app config persistence (app.yaml)
|
||||||
|
- [x] Secret generation engine (password, hex, static)
|
||||||
|
- [x] Stack catalog (read compose files + metadata from disk)
|
||||||
|
- [x] Docker Compose operations (up/down/pull/ps/logs)
|
||||||
|
- [x] Deploy flow with interactive field input
|
||||||
|
- [x] Basic web dashboard with start/stop/deploy buttons
|
||||||
|
- [x] REST API for stack + deploy operations
|
||||||
|
- [x] Simple web authentication (bcrypt sessions)
|
||||||
|
- [x] App logos + screenshots loaded from felhom.eu
|
||||||
|
- [x] Container image build pipeline (Dockerfile + Makefile)
|
||||||
|
- [ ] First build & test on N100 hardware
|
||||||
|
- [ ] End-to-end test: deploy an app through dashboard
|
||||||
|
|
||||||
|
### Phase 2 — Monitoring & Health
|
||||||
|
- [ ] System metrics collection (CPU, RAM, disk, temperature)
|
||||||
|
- [ ] Healthchecks.io ping integration
|
||||||
|
- [ ] Dashboard system health panel
|
||||||
|
- [ ] Customer notifications (email/Telegram)
|
||||||
|
|
||||||
|
### Phase 3 — Backups
|
||||||
|
- [ ] DB dump engine (PostgreSQL, MariaDB/MySQL, SQLite)
|
||||||
|
- [ ] Restic integration (snapshot, prune, check)
|
||||||
|
- [ ] Backup status on dashboard
|
||||||
|
- [ ] Manual backup trigger from UI
|
||||||
|
- [ ] Restore workflow
|
||||||
|
|
||||||
|
### Phase 4 — Git Sync & Updates
|
||||||
|
- [ ] Periodic git pull for stack definitions
|
||||||
|
- [ ] Update classification (optional/required/security)
|
||||||
|
- [ ] Update window enforcement
|
||||||
|
- [ ] Dashboard update notifications with "Update" button
|
||||||
|
|
||||||
|
### Phase 5 — Self-Update & Resilience
|
||||||
|
- [ ] Self-update check & execution
|
||||||
|
- [ ] Pre-update config backup
|
||||||
|
- [ ] Health-based rollback mechanism
|
||||||
|
- [ ] Config export/import
|
||||||
|
|
||||||
|
### Phase 6 — Central Management (future)
|
||||||
|
- [ ] API authentication for remote management
|
||||||
|
- [ ] Central dashboard on k3s querying all customer controllers
|
||||||
|
- [ ] Fleet-wide update management
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="hu">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Title}} — Felhom</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="sidebar">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h1 class="logo">☁ Felhom</h1>
|
||||||
|
<span class="customer-name">{{.CustomerName}}</span>
|
||||||
|
</div>
|
||||||
|
<ul class="nav-links">
|
||||||
|
<li><a href="/" class="{{if eq .Page "dashboard"}}active{{end}}">📊 Vezérlőpult</a></li>
|
||||||
|
<li><a href="/stacks" class="{{if eq .Page "stacks"}}active{{end}}">📦 Alkalmazások</a></li>
|
||||||
|
</ul>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<span class="version">v{{.Version}}</span>
|
||||||
|
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="content">
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "layout_end"}}
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
async function stackAction(name, action) {
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
const origText = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Folyamatban...';
|
||||||
|
btn.classList.add('loading');
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/stacks/' + name + '/' + action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'}
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||||
|
btn.textContent = origText;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.remove('loading');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Hálózati hiba: ' + err.message);
|
||||||
|
btn.textContent = origText;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.classList.remove('loading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
`
|
||||||
|
|
||||||
|
const dashboardTmpl = `
|
||||||
|
{{define "dashboard"}}
|
||||||
|
{{template "layout_start" .}}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Vezérlőpult</h2>
|
||||||
|
<span class="domain-badge">{{.Domain}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card stat-running">
|
||||||
|
<div class="stat-value">{{.RunningCount}}</div>
|
||||||
|
<div class="stat-label">Futó alkalmazás</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-stopped">
|
||||||
|
<div class="stat-value">{{.StoppedCount}}</div>
|
||||||
|
<div class="stat-label">Leállítva</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-total">
|
||||||
|
<div class="stat-value">{{.TotalCount}}</div>
|
||||||
|
<div class="stat-label">Összes alkalmazás</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Alkalmazások állapota</h3>
|
||||||
|
|
||||||
|
<div class="stack-list">
|
||||||
|
{{range .Stacks}}
|
||||||
|
<div class="stack-card stack-state-{{stateColor .State}}">
|
||||||
|
<div class="stack-info">
|
||||||
|
<img class="stack-logo" src="{{logoURL .Meta.Slug}}"
|
||||||
|
alt="{{.Meta.DisplayName}}" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
||||||
|
<div>
|
||||||
|
<strong class="stack-name">{{.Meta.DisplayName}}</strong>
|
||||||
|
{{if .Meta.Description}}<span class="stack-desc">{{.Meta.Description}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stack-actions">
|
||||||
|
<span class="stack-state-label">{{stateLabel .State}}</span>
|
||||||
|
|
||||||
|
{{if .Protected}}
|
||||||
|
<span class="badge badge-protected">🔒 Védett</span>
|
||||||
|
{{else if not .Deployed}}
|
||||||
|
<a href="/stacks/{{.Name}}/deploy" class="btn btn-sm btn-primary">🚀 Telepítés</a>
|
||||||
|
{{else}}
|
||||||
|
{{if eq (stateStr .State) "running"}}
|
||||||
|
<button class="btn btn-sm btn-warning" onclick="stackAction('{{.Name}}', 'restart')">↻</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="stackAction('{{.Name}}', 'stop')">■</button>
|
||||||
|
{{else}}
|
||||||
|
<button class="btn btn-sm btn-success" onclick="stackAction('{{.Name}}', 'start')">▶</button>
|
||||||
|
{{end}}
|
||||||
|
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">📋</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>Nincs elérhető alkalmazás.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "layout_end" .}}
|
||||||
|
{{end}}
|
||||||
|
`
|
||||||
|
|
||||||
|
const stacksTmpl = `
|
||||||
|
{{define "stacks"}}
|
||||||
|
{{template "layout_start" .}}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<h2>Alkalmazások</h2>
|
||||||
|
<span class="domain-badge">{{.Domain}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack-grid">
|
||||||
|
{{range .Stacks}}
|
||||||
|
<div class="stack-detail-card stack-state-{{stateColor .State}}">
|
||||||
|
<div class="stack-detail-header">
|
||||||
|
<div class="stack-title-row">
|
||||||
|
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}"
|
||||||
|
alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
|
||||||
|
<div>
|
||||||
|
<h3>{{.Meta.DisplayName}}</h3>
|
||||||
|
{{if .Meta.Subdomain}}
|
||||||
|
<a class="subdomain-link" href="https://{{.Meta.Subdomain}}.{{$.Domain}}" target="_blank">
|
||||||
|
{{.Meta.Subdomain}}.{{$.Domain}} ↗
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="stack-state-badge state-{{stateColor .State}}">{{stateLabel .State}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Meta.Description}}
|
||||||
|
<p class="stack-detail-desc">{{.Meta.Description}}</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="stack-meta-badges">
|
||||||
|
{{if .Meta.Resources.RAM}}<span class="meta-badge">💾 {{.Meta.Resources.RAM}}</span>{{end}}
|
||||||
|
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">🥧 Pi kompatibilis</span>{{end}}
|
||||||
|
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge">💿 HDD szükséges</span>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Containers}}
|
||||||
|
<div class="container-list">
|
||||||
|
{{range .Containers}}
|
||||||
|
<div class="container-row">
|
||||||
|
<span class="container-name">{{.Name}}</span>
|
||||||
|
<span class="container-status state-text-{{stateColor .State}}">{{.Status}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="stack-detail-actions">
|
||||||
|
{{if .Protected}}
|
||||||
|
<span class="badge badge-protected">🔒 Védett rendszerkomponens</span>
|
||||||
|
{{else if not .Deployed}}
|
||||||
|
<a href="/stacks/{{.Name}}/deploy" class="btn btn-primary">🚀 Telepítés</a>
|
||||||
|
<a href="{{appPageURL .Meta.Slug}}" target="_blank" class="btn btn-outline">ℹ️ Részletek</a>
|
||||||
|
{{else}}
|
||||||
|
{{if eq (stateStr .State) "running"}}
|
||||||
|
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'update')">⬆ Frissítés</button>
|
||||||
|
<button class="btn btn-warning" onclick="stackAction('{{.Name}}', 'restart')">↻ Újraindítás</button>
|
||||||
|
<button class="btn btn-danger" onclick="stackAction('{{.Name}}', 'stop')">■ Leállítás</button>
|
||||||
|
{{else}}
|
||||||
|
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'start')">▶ Indítás</button>
|
||||||
|
{{end}}
|
||||||
|
<a href="/stacks/{{.Name}}/logs" class="btn btn-outline">📋 Naplók</a>
|
||||||
|
<a href="{{appPageURL .Meta.Slug}}" target="_blank" class="btn btn-outline">ℹ️ Részletek</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{template "layout_end" .}}
|
||||||
|
{{end}}
|
||||||
|
`
|
||||||
|
|
||||||
|
const deployTmpl = `
|
||||||
|
{{define "deploy"}}
|
||||||
|
{{template "layout_start" .}}
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
|
||||||
|
<h2>{{.Meta.DisplayName}} — Telepítés</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="deploy-container">
|
||||||
|
<div class="deploy-info">
|
||||||
|
<img class="deploy-logo" src="{{.LogoURL}}" alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{.LogoPNGURL}}'">
|
||||||
|
<div>
|
||||||
|
<h3>{{.Meta.DisplayName}}</h3>
|
||||||
|
{{if .Meta.Description}}<p>{{.Meta.Description}}</p>{{end}}
|
||||||
|
<div class="stack-meta-badges">
|
||||||
|
{{if .Meta.Resources.RAM}}<span class="meta-badge">💾 {{.Meta.Resources.RAM}}</span>{{end}}
|
||||||
|
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">🥧 Pi kompatibilis</span>{{end}}
|
||||||
|
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge">💿 HDD szükséges</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<a href="{{.AppPageURL}}" target="_blank" class="btn btn-sm btn-outline" style="margin-top:0.5rem">
|
||||||
|
ℹ️ Részletes leírás, képernyőképek
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .AlreadyDeployed}}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Ez az alkalmazás már telepítve van. Az alábbi beállítások csak olvashatók.
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form id="deploy-form" class="deploy-form">
|
||||||
|
{{if .AutoFields}}
|
||||||
|
<div class="form-section">
|
||||||
|
<h4>🔒 Automatikusan generált értékek</h4>
|
||||||
|
<p class="form-section-desc">Ezek az értékek automatikusan létrejönnek a telepítéskor.</p>
|
||||||
|
{{range .AutoFields}}
|
||||||
|
<div class="form-group form-group-auto">
|
||||||
|
<label>{{.Label}}</label>
|
||||||
|
<span class="auto-generated-badge">✓ Automatikusan generálva</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .UserFields}}
|
||||||
|
<div class="form-section">
|
||||||
|
<h4>⚙️ Beállítások</h4>
|
||||||
|
{{range .UserFields}}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="field-{{.EnvVar}}">
|
||||||
|
{{.Label}}
|
||||||
|
{{if .Required}}<span class="required">*</span>{{end}}
|
||||||
|
{{if .LockedAfterDeploy}}<span class="locked-hint">🔒 telepítés után nem módosítható</span>{{end}}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{{if eq .Type "select"}}
|
||||||
|
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
|
||||||
|
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||||
|
{{range .Options}}
|
||||||
|
<option value="{{.Value}}">{{.Label}}</option>
|
||||||
|
{{end}}
|
||||||
|
</select>
|
||||||
|
{{else if eq .Type "password"}}
|
||||||
|
<div class="input-with-button">
|
||||||
|
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
|
||||||
|
class="form-control" value="{{.Default}}"
|
||||||
|
placeholder="{{.Placeholder}}"
|
||||||
|
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline"
|
||||||
|
onclick="generatePassword('field-{{.EnvVar}}')">🎲 Generálás</button>
|
||||||
|
</div>
|
||||||
|
{{else if eq .Type "boolean"}}
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="field-{{.EnvVar}}" name="{{.EnvVar}}" value="true"
|
||||||
|
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||||
|
<span class="toggle-label">Igen</span>
|
||||||
|
</label>
|
||||||
|
{{else}}
|
||||||
|
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
|
||||||
|
class="form-control" value="{{.Default}}"
|
||||||
|
placeholder="{{.Placeholder}}"
|
||||||
|
{{if .Required}}required{{end}}
|
||||||
|
{{if $.AlreadyDeployed}}disabled{{end}}>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Description}}
|
||||||
|
<span class="form-hint">{{.Description}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if not .AlreadyDeployed}}
|
||||||
|
<div class="deploy-actions">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg">🚀 Telepítés indítása</button>
|
||||||
|
<a href="/stacks" class="btn btn-outline">Mégsem</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function generatePassword(fieldId) {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
let pass = '';
|
||||||
|
const arr = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(arr);
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
pass += chars[arr[i] % chars.length];
|
||||||
|
}
|
||||||
|
document.getElementById(fieldId).value = pass;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('deploy-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector('[type=submit]');
|
||||||
|
const origText = btn.textContent;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Telepítés folyamatban...';
|
||||||
|
|
||||||
|
const values = {};
|
||||||
|
const inputs = e.target.querySelectorAll('input, select');
|
||||||
|
inputs.forEach(function(el) {
|
||||||
|
if (el.name && !el.disabled) {
|
||||||
|
if (el.type === 'checkbox') {
|
||||||
|
values[el.name] = el.checked ? 'true' : 'false';
|
||||||
|
} else {
|
||||||
|
values[el.name] = el.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/stacks/{{.Stack.Name}}/deploy', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({values: values})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.ok) {
|
||||||
|
alert('Hiba: ' + data.error);
|
||||||
|
btn.textContent = origText;
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
alert('Sikeres telepítés! ✓');
|
||||||
|
window.location.href = '/stacks';
|
||||||
|
} catch (err) {
|
||||||
|
alert('Hálózati hiba: ' + err.message);
|
||||||
|
btn.textContent = origText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{template "layout_end" .}}
|
||||||
|
{{end}}
|
||||||
|
`
|
||||||
|
|
||||||
|
const loginTmpl = `
|
||||||
|
{{define "login"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="hu">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bejelentkezés — Felhom</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="login-body">
|
||||||
|
<div class="login-card">
|
||||||
|
<h1 class="logo">☁ Felhom</h1>
|
||||||
|
<p class="login-subtitle">{{.CustomerName}}</p>
|
||||||
|
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Jelszó</label>
|
||||||
|
<input type="password" id="password" name="password" required autofocus
|
||||||
|
placeholder="Adja meg a jelszavát" class="form-control">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-full">Bejelentkezés</button>
|
||||||
|
</form>
|
||||||
|
<p class="login-footer">Felhom — Otthoni szerver kezelés<br>
|
||||||
|
<a href="https://felhom.eu" target="_blank">felhom.eu</a></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
`
|
||||||
|
|
||||||
|
const logsTmpl = `
|
||||||
|
{{define "logs"}}
|
||||||
|
{{template "layout_start" .}}
|
||||||
|
<div class="page-header">
|
||||||
|
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
|
||||||
|
<h2>{{.Stack.Meta.DisplayName}} — Naplók</h2>
|
||||||
|
</div>
|
||||||
|
<div class="logs-container">
|
||||||
|
<pre class="logs-output">{{.Logs}}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="logs-actions">
|
||||||
|
<button class="btn btn-outline" onclick="window.location.reload()">🔄 Frissítés</button>
|
||||||
|
</div>
|
||||||
|
{{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}
|
||||||
|
}
|
||||||
|
`
|
||||||
Reference in New Issue
Block a user