80b756f0e4
Restore flow now calls MountDrivesFromLayout() after writing config, which mounts drives by UUID and adds fstab entries. Previously drives from the infra backup were never mounted, causing "Adattároló nem elérhető" warnings. Post-restore redirect now polls until the controller responds instead of using a fixed 5-second timeout that was too short for container restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1183 lines
38 KiB
Go
1183 lines
38 KiB
Go
package setup
|
|
|
|
import (
|
|
"context"
|
|
crand "crypto/rand"
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var templateFS embed.FS
|
|
|
|
// Server handles the setup wizard HTTP routes.
|
|
type Server struct {
|
|
cfg *config.Config
|
|
dataDir string
|
|
logger *log.Logger
|
|
tmpl *template.Template
|
|
state *SetupState
|
|
version string
|
|
|
|
// Scan state for async drive scanning
|
|
scanMu sync.Mutex
|
|
scanRunning bool
|
|
scanResults []DriveBackup
|
|
scanDone bool
|
|
scanError string
|
|
|
|
// Restore progress
|
|
restoreMu sync.Mutex
|
|
restoreRunning bool
|
|
restoreSteps []RestoreStep
|
|
restoreError string
|
|
restoreDone bool
|
|
}
|
|
|
|
// RestoreStep tracks progress of a restore operation.
|
|
type RestoreStep struct {
|
|
Label string `json:"label"`
|
|
Status string `json:"status"` // "pending", "running", "done", "failed"
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// NewServer creates a new setup wizard server.
|
|
func NewServer(cfg *config.Config, dataDir string, logger *log.Logger, version string) *Server {
|
|
s := &Server{
|
|
cfg: cfg,
|
|
dataDir: dataDir,
|
|
logger: logger,
|
|
state: LoadState(dataDir),
|
|
version: version,
|
|
}
|
|
s.loadTemplates()
|
|
|
|
// Pre-seed setup state from env vars (set by docker-setup.sh --hub-customer)
|
|
if cid := os.Getenv("FELHOM_SETUP_CUSTOMER_ID"); cid != "" {
|
|
s.state.SetFormField("customer_id", cid)
|
|
logger.Printf("[INFO] Setup: pre-seeded customer_id from env: %s", cid)
|
|
}
|
|
if pw := os.Getenv("FELHOM_SETUP_PASSWORD"); pw != "" {
|
|
s.state.SetFormField("retrieval_password", pw)
|
|
logger.Printf("[INFO] Setup: pre-seeded retrieval_password from env")
|
|
}
|
|
if s.isHubPreseeded() {
|
|
s.state.Save()
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// isDebug returns true if logging level is "debug".
|
|
func (s *Server) isDebug() bool {
|
|
return s.cfg != nil && s.cfg.Logging.Level == "debug"
|
|
}
|
|
|
|
// isHubPreseeded returns true if both customer_id and retrieval_password are
|
|
// pre-seeded in setup state (set by docker-setup.sh --hub-customer).
|
|
func (s *Server) isHubPreseeded() bool {
|
|
return s.state.GetFormField("customer_id") != "" && s.state.GetFormField("retrieval_password") != ""
|
|
}
|
|
|
|
func (s *Server) loadTemplates() {
|
|
s.tmpl = template.Must(
|
|
template.New("").Funcs(template.FuncMap{
|
|
"timeNow": func() string { return time.Now().Format("2006-01-02 15:04") },
|
|
}).ParseFS(templateFS, "templates/*.html"),
|
|
)
|
|
}
|
|
|
|
// Handler returns the HTTP handler for the setup wizard.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/", s.handleRoot)
|
|
mux.HandleFunc("/setup", s.handleWelcome)
|
|
mux.HandleFunc("/setup/scan", s.handleScan)
|
|
mux.HandleFunc("/setup/scan/status", s.handleScanStatus)
|
|
mux.HandleFunc("/setup/hub-restore", s.handleHubRestore)
|
|
mux.HandleFunc("/setup/hub-restore/select", s.handleHubVersionSelect)
|
|
mux.HandleFunc("/setup/restore", s.handleRestore)
|
|
mux.HandleFunc("/setup/restore/status", s.handleRestoreStatus)
|
|
mux.HandleFunc("/setup/fresh", s.handleFreshHub)
|
|
mux.HandleFunc("/setup/manual", s.handleManual)
|
|
mux.HandleFunc("/setup/failed", s.handleFailed)
|
|
mux.HandleFunc("/static/style.css", s.handleCSS)
|
|
mux.HandleFunc("/static/felhom-logo.svg", s.handleLogo)
|
|
|
|
return mux
|
|
}
|
|
|
|
// --- Route Handlers ---
|
|
|
|
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.Redirect(w, r, "/setup", http.StatusFound)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/setup", http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleWelcome(w http.ResponseWriter, r *http.Request) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
domain := s.cfg.Customer.Domain
|
|
ips := DetectLocalIPs()
|
|
|
|
var accessURLs []string
|
|
if domain != "" {
|
|
accessURLs = append(accessURLs, fmt.Sprintf("https://felhom.%s", domain))
|
|
}
|
|
for _, ip := range ips {
|
|
accessURLs = append(accessURLs, fmt.Sprintf("http://%s%s", ip, s.cfg.Web.SetupListen))
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"AccessURLs": accessURLs,
|
|
"Version": s.version,
|
|
"HubMode": s.isHubPreseeded(),
|
|
"HubCustomerID": s.state.GetFormField("customer_id"),
|
|
}
|
|
s.render(w, "setup_welcome", data)
|
|
}
|
|
|
|
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
|
|
// Start scan if not already running
|
|
s.scanMu.Lock()
|
|
if !s.scanRunning && !s.scanDone {
|
|
s.scanRunning = true
|
|
go s.runDriveScan()
|
|
}
|
|
s.scanMu.Unlock()
|
|
|
|
s.state.SetStep("scan")
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
}
|
|
s.render(w, "setup_scan", data)
|
|
}
|
|
|
|
func (s *Server) handleScanStatus(w http.ResponseWriter, r *http.Request) {
|
|
s.scanMu.Lock()
|
|
defer s.scanMu.Unlock()
|
|
|
|
resp := map[string]interface{}{
|
|
"running": s.scanRunning,
|
|
"done": s.scanDone,
|
|
"results": s.scanResults,
|
|
"error": s.scanError,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func (s *Server) handleHubRestore(w http.ResponseWriter, r *http.Request) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
|
|
if r.Method == http.MethodPost {
|
|
if !validateCSRF(r) {
|
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
s.processHubRestore(w, r)
|
|
return
|
|
}
|
|
|
|
// Auto-process if credentials are pre-seeded (hub mode from docker-setup.sh)
|
|
if s.isHubPreseeded() {
|
|
customerID := s.state.GetFormField("customer_id")
|
|
password := s.state.GetFormField("retrieval_password")
|
|
s.autoProcessHubRestore(w, r, customerID, password)
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"CustomerID": s.state.GetFormField("customer_id"),
|
|
}
|
|
s.render(w, "setup_hub_restore", data)
|
|
}
|
|
|
|
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Redirect(w, r, "/setup", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
if !validateCSRF(r) {
|
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
source := r.FormValue("source")
|
|
switch source {
|
|
case "local":
|
|
drivePath := r.FormValue("drive_path")
|
|
historyFile := r.FormValue("history_file")
|
|
go s.executeLocalRestore(drivePath, historyFile)
|
|
case "hub":
|
|
go s.executeHubRestore()
|
|
default:
|
|
http.Error(w, "Invalid restore source", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
}
|
|
s.render(w, "setup_restore_exec", data)
|
|
}
|
|
|
|
func (s *Server) handleRestoreStatus(w http.ResponseWriter, r *http.Request) {
|
|
s.restoreMu.Lock()
|
|
defer s.restoreMu.Unlock()
|
|
|
|
resp := map[string]interface{}{
|
|
"running": s.restoreRunning,
|
|
"done": s.restoreDone,
|
|
"steps": s.restoreSteps,
|
|
"error": s.restoreError,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func (s *Server) handleFreshHub(w http.ResponseWriter, r *http.Request) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
|
|
if r.Method == http.MethodPost {
|
|
if !validateCSRF(r) {
|
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
s.processFreshHub(w, r)
|
|
return
|
|
}
|
|
|
|
// Auto-process if credentials are pre-seeded (hub mode from docker-setup.sh)
|
|
if s.isHubPreseeded() {
|
|
customerID := s.state.GetFormField("customer_id")
|
|
password := s.state.GetFormField("retrieval_password")
|
|
s.autoProcessFreshHub(w, r, customerID, password)
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"CustomerID": s.state.GetFormField("customer_id"),
|
|
}
|
|
s.render(w, "setup_fresh_hub", data)
|
|
}
|
|
|
|
func (s *Server) handleManual(w http.ResponseWriter, r *http.Request) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
|
|
if r.Method == http.MethodPost {
|
|
if !validateCSRF(r) {
|
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
s.processManual(w, r)
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"FormData": s.state.FormData,
|
|
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
|
}
|
|
s.render(w, "setup_manual", data)
|
|
}
|
|
|
|
func (s *Server) handleFailed(w http.ResponseWriter, r *http.Request) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
}
|
|
s.render(w, "setup_failed", data)
|
|
}
|
|
|
|
// --- Static Assets (reuse from web package embed) ---
|
|
|
|
func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) {
|
|
// Read the main style.css from the web package templates
|
|
cssPath := filepath.Join(filepath.Dir(s.dataDir), "..", "internal", "web", "templates", "style.css")
|
|
data, err := os.ReadFile(cssPath)
|
|
if err != nil {
|
|
// Fallback: serve minimal CSS
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
w.Write([]byte(minimalCSS))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Write(data)
|
|
}
|
|
|
|
func (s *Server) handleLogo(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
fmt.Fprint(w, web.FelhomLogoSVG)
|
|
}
|
|
|
|
// --- Auto-Processing (Hub pre-seeded mode) ---
|
|
|
|
// autoProcessHubRestore calls PullRecovery with pre-seeded credentials and
|
|
// renders the confirmation page directly, skipping the manual form.
|
|
// Falls back to the form with an error message on failure.
|
|
func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, customerID, password string) {
|
|
hubURL := DefaultHubURL
|
|
|
|
s.logger.Printf("[INFO] Setup: auto-processing hub restore for %s (pre-seeded credentials)", customerID)
|
|
|
|
recovery, err := report.PullRecovery(hubURL, customerID, password)
|
|
if err != nil {
|
|
s.logger.Printf("[WARN] Setup: auto hub restore failed: %v — falling back to form", err)
|
|
var msg string
|
|
switch {
|
|
case isError(err, report.ErrHubUnreachable):
|
|
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
|
case isError(err, report.ErrAuthFailed):
|
|
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
|
case isError(err, report.ErrNotFound):
|
|
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
|
default:
|
|
msg = fmt.Sprintf("Hiba történt: %v", err)
|
|
}
|
|
// Clear pre-seeded password so form is shown on next attempt
|
|
s.state.SetFormField("retrieval_password", "")
|
|
s.state.Save()
|
|
s.renderError(w, r, "setup_hub_restore", msg, customerID)
|
|
return
|
|
}
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d, versions=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML), len(recovery.BackupVersions))
|
|
}
|
|
|
|
// If multiple versions available, show picker instead of auto-restoring
|
|
if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup {
|
|
s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions))
|
|
// Store config for later use after version selection
|
|
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
|
s.state.Save()
|
|
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"Versions": recovery.BackupVersions,
|
|
}
|
|
s.render(w, "setup_hub_versions", data)
|
|
return
|
|
}
|
|
|
|
// Single version or no versions — proceed directly
|
|
s.storeRecoveryAndRestore(w, r, recovery, customerID)
|
|
}
|
|
|
|
// storeRecoveryAndRestore stores recovery data in state and starts the restore goroutine.
|
|
func (s *Server) storeRecoveryAndRestore(w http.ResponseWriter, r *http.Request, recovery *report.RecoveryResponse, customerID string) {
|
|
s.state.SelectedBackup = &SelectedBackup{
|
|
Source: "hub",
|
|
CustomerID: customerID,
|
|
}
|
|
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
|
if recovery.HasInfraBackup && recovery.InfraBackup != nil {
|
|
ibJSON, _ := json.Marshal(recovery.InfraBackup)
|
|
s.state.SetFormField("hub_infra_backup", string(ibJSON))
|
|
s.state.SelectedBackup.Timestamp = recovery.InfraBackup.Timestamp
|
|
}
|
|
s.state.SetStep("restore-exec")
|
|
s.state.Save()
|
|
|
|
s.logger.Printf("[INFO] Setup: hub recovery stored (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
|
|
|
|
go s.executeHubRestore()
|
|
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
}
|
|
s.render(w, "setup_restore_exec", data)
|
|
}
|
|
|
|
// autoProcessFreshHub calls PullConfig with pre-seeded credentials and
|
|
// proceeds with fresh install, skipping the manual form.
|
|
func (s *Server) autoProcessFreshHub(w http.ResponseWriter, r *http.Request, customerID, password string) {
|
|
hubURL := DefaultHubURL
|
|
|
|
s.logger.Printf("[INFO] Setup: auto-processing fresh hub install for %s (pre-seeded credentials)", customerID)
|
|
|
|
configYAML, err := report.PullConfig(hubURL, customerID, password)
|
|
if err != nil {
|
|
s.logger.Printf("[WARN] Setup: auto fresh hub failed: %v — falling back to form", err)
|
|
// Clear pre-seeded password so form is shown on next attempt
|
|
s.state.SetFormField("retrieval_password", "")
|
|
s.state.Save()
|
|
var msg string
|
|
switch {
|
|
case isError(err, report.ErrHubUnreachable):
|
|
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
|
case isError(err, report.ErrAuthFailed):
|
|
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
|
case isError(err, report.ErrNotFound):
|
|
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
|
default:
|
|
msg = fmt.Sprintf("Hiba történt: %v", err)
|
|
}
|
|
s.renderError(w, r, "setup_fresh_hub", msg, customerID)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Setup: config downloaded (%d bytes), writing config...", len(configYAML))
|
|
|
|
if err := s.writeFreshConfig(configYAML, password); err != nil {
|
|
s.logger.Printf("[ERROR] Setup: writeFreshConfig failed: %v", err)
|
|
s.renderError(w, r, "setup_fresh_hub", fmt.Sprintf("Konfigurációs hiba: %v", err), customerID)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Setup: fresh install from Hub completed for %s", customerID)
|
|
s.finishSetup()
|
|
}
|
|
|
|
// --- Processing Logic ---
|
|
|
|
func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) {
|
|
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
|
password := r.FormValue("password")
|
|
hubURL := DefaultHubURL
|
|
|
|
s.state.SetFormField("customer_id", customerID)
|
|
|
|
if customerID == "" || password == "" {
|
|
s.renderError(w, r, "setup_hub_restore", "Kérem töltse ki mindkét mezőt.", customerID)
|
|
return
|
|
}
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] Setup: hub restore — pulling recovery from %s for customer %s", hubURL, customerID)
|
|
}
|
|
recovery, err := report.PullRecovery(hubURL, customerID, password)
|
|
if err != nil {
|
|
var msg string
|
|
switch {
|
|
case isError(err, report.ErrHubUnreachable):
|
|
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
|
case isError(err, report.ErrAuthFailed):
|
|
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
|
case isError(err, report.ErrNotFound):
|
|
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
|
default:
|
|
msg = fmt.Sprintf("Hiba történt: %v", err)
|
|
}
|
|
s.renderError(w, r, "setup_hub_restore", msg, customerID)
|
|
return
|
|
}
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d, versions=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML), len(recovery.BackupVersions))
|
|
}
|
|
|
|
s.state.SetFormField("retrieval_password", password)
|
|
|
|
// If multiple versions available, show picker
|
|
if len(recovery.BackupVersions) > 1 && recovery.HasInfraBackup {
|
|
s.logger.Printf("[INFO] Setup: %d backup versions available — showing version picker", len(recovery.BackupVersions))
|
|
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
|
s.state.Save()
|
|
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"Versions": recovery.BackupVersions,
|
|
}
|
|
s.render(w, "setup_hub_versions", data)
|
|
return
|
|
}
|
|
|
|
s.storeRecoveryAndRestore(w, r, recovery, customerID)
|
|
}
|
|
|
|
// handleHubVersionSelect processes the user's version selection from the Hub version picker.
|
|
func (s *Server) handleHubVersionSelect(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Redirect(w, r, "/setup/hub-restore", http.StatusFound)
|
|
return
|
|
}
|
|
if !validateCSRF(r) {
|
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
versionStr := r.FormValue("version_id")
|
|
customerID := s.state.GetFormField("customer_id")
|
|
password := s.state.GetFormField("retrieval_password")
|
|
hubURL := DefaultHubURL
|
|
|
|
if customerID == "" || password == "" {
|
|
http.Redirect(w, r, "/setup/hub-restore", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
var versionID int64
|
|
fmt.Sscanf(versionStr, "%d", &versionID)
|
|
|
|
s.logger.Printf("[INFO] Setup: user selected backup version %d for %s", versionID, customerID)
|
|
|
|
// Fetch the specific version
|
|
recovery, err := report.PullRecoveryVersion(hubURL, customerID, password, versionID)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] Setup: failed to fetch version %d: %v", versionID, err)
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"Error": fmt.Sprintf("Hiba a verzió letöltésekor: %v", err),
|
|
}
|
|
s.render(w, "setup_hub_versions", data)
|
|
return
|
|
}
|
|
|
|
s.storeRecoveryAndRestore(w, r, recovery, customerID)
|
|
}
|
|
|
|
func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
|
|
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
|
password := r.FormValue("password")
|
|
hubURL := DefaultHubURL
|
|
|
|
s.state.SetFormField("customer_id", customerID)
|
|
|
|
if customerID == "" || password == "" {
|
|
s.renderError(w, r, "setup_fresh_hub", "Kérem töltse ki mindkét mezőt.", customerID)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Setup: downloading config from Hub (%s) for customer %s", hubURL, customerID)
|
|
configYAML, err := report.PullConfig(hubURL, customerID, password)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] Setup: Hub config download failed: %v", err)
|
|
var msg string
|
|
switch {
|
|
case isError(err, report.ErrHubUnreachable):
|
|
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
|
case isError(err, report.ErrAuthFailed):
|
|
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
|
case isError(err, report.ErrNotFound):
|
|
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
|
default:
|
|
msg = fmt.Sprintf("Hiba történt: %v", err)
|
|
}
|
|
s.renderError(w, r, "setup_fresh_hub", msg, customerID)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] Setup: config downloaded (%d bytes), writing config...", len(configYAML))
|
|
|
|
// Write config and finish setup
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] Setup: writing fresh config (%d bytes)", len(configYAML))
|
|
}
|
|
s.state.SetFormField("retrieval_password", password)
|
|
if err := s.writeFreshConfig(configYAML, password); err != nil {
|
|
s.logger.Printf("[ERROR] Setup: writeFreshConfig failed: %v", err)
|
|
s.renderError(w, r, "setup_fresh_hub", fmt.Sprintf("Konfigurációs hiba: %v", err), customerID)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Setup: fresh install from Hub completed for %s", customerID)
|
|
s.finishSetup()
|
|
}
|
|
|
|
func (s *Server) processManual(w http.ResponseWriter, r *http.Request) {
|
|
// Save all form fields
|
|
fields := []string{"customer_id", "display_name", "domain", "email",
|
|
"cf_tunnel_token", "cf_api_token", "system_data_path",
|
|
"password", "password_confirm",
|
|
"git_repo_url", "git_username", "git_token"}
|
|
for _, f := range fields {
|
|
s.state.SetFormField(f, r.FormValue(f))
|
|
}
|
|
|
|
// Validate
|
|
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
|
domain := strings.TrimSpace(r.FormValue("domain"))
|
|
password := r.FormValue("password")
|
|
passwordConfirm := r.FormValue("password_confirm")
|
|
|
|
var errs []string
|
|
if customerID == "" {
|
|
errs = append(errs, "Ügyfél-azonosító kötelező")
|
|
}
|
|
if domain == "" || domain == "homeserver.local" {
|
|
errs = append(errs, "Érvényes domain szükséges")
|
|
}
|
|
if password != "" && len(password) < 8 {
|
|
errs = append(errs, "A jelszó legalább 8 karakter legyen")
|
|
}
|
|
if password != passwordConfirm {
|
|
errs = append(errs, "A jelszavak nem egyeznek")
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"FormData": s.state.FormData,
|
|
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
|
"Errors": errs,
|
|
}
|
|
s.render(w, "setup_manual", data)
|
|
return
|
|
}
|
|
|
|
// Generate controller.yaml
|
|
configYAML := s.generateManualConfig()
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] Setup: generated manual config (%d bytes) for customer %s", len(configYAML), customerID)
|
|
}
|
|
if err := s.writeFreshConfig(configYAML, ""); err != nil {
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"FormData": s.state.FormData,
|
|
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
|
"Errors": []string{fmt.Sprintf("Konfigurációs hiba: %v", err)},
|
|
}
|
|
s.render(w, "setup_manual", data)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Setup: manual configuration completed for %s", customerID)
|
|
s.finishSetup()
|
|
}
|
|
|
|
// --- Restore Execution ---
|
|
|
|
func (s *Server) executeLocalRestore(drivePath, historyFile string) {
|
|
s.restoreMu.Lock()
|
|
s.restoreRunning = true
|
|
s.restoreDone = false
|
|
s.restoreError = ""
|
|
s.restoreSteps = []RestoreStep{
|
|
{Label: "Mentés beolvasása...", Status: "running"},
|
|
{Label: "Konfiguráció visszaállítása...", Status: "pending"},
|
|
{Label: "Meghajtók csatolása...", Status: "pending"},
|
|
{Label: "Beállítás befejezése...", Status: "pending"},
|
|
}
|
|
s.restoreMu.Unlock()
|
|
|
|
// Step 1: Read backup (current or historical version)
|
|
var backupData []byte
|
|
var err error
|
|
if historyFile != "" {
|
|
backupData, _, err = backup.ReadLocalInfraBackupFromHistory(drivePath, historyFile)
|
|
} else {
|
|
backupData, _, err = backup.ReadLocalInfraBackup(drivePath)
|
|
}
|
|
if err != nil {
|
|
s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err))
|
|
return
|
|
}
|
|
|
|
var ib report.InfraBackup
|
|
if err := json.Unmarshal(backupData, &ib); err != nil {
|
|
s.setRestoreError(0, fmt.Sprintf("Mentés formátum hiba: %v", err))
|
|
return
|
|
}
|
|
s.setRestoreStepDone(0)
|
|
|
|
// Step 2: Write config files
|
|
s.setRestoreStepRunning(1)
|
|
if err := s.writeRestoredConfig(&ib); err != nil {
|
|
s.setRestoreError(1, fmt.Sprintf("Konfiguráció írási hiba: %v", err))
|
|
return
|
|
}
|
|
s.setRestoreStepDone(1)
|
|
|
|
// Step 3: Mount drives from disk layout
|
|
s.setRestoreStepRunning(2)
|
|
s.mountDrivesFromBackup(&ib)
|
|
s.setRestoreStepDone(2)
|
|
|
|
// Step 4: Finalize
|
|
s.setRestoreStepRunning(3)
|
|
|
|
// Save retrieval password from state if available
|
|
retrievalPw := s.state.GetFormField("retrieval_password")
|
|
if retrievalPw != "" {
|
|
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
|
if err == nil {
|
|
sett.SetRetrievalPassword(retrievalPw)
|
|
}
|
|
}
|
|
|
|
// Queue DR event
|
|
s.queueDREvent("local", ib.Timestamp, len(ib.DeployedStacks))
|
|
|
|
s.setRestoreStepDone(3)
|
|
|
|
s.restoreMu.Lock()
|
|
s.restoreRunning = false
|
|
s.restoreDone = true
|
|
s.restoreMu.Unlock()
|
|
|
|
s.logger.Printf("[INFO] Setup: local restore completed from %s", drivePath)
|
|
|
|
// Wait a moment for the UI to poll, then exit
|
|
time.Sleep(2 * time.Second)
|
|
s.finishSetup()
|
|
}
|
|
|
|
func (s *Server) executeHubRestore() {
|
|
s.restoreMu.Lock()
|
|
s.restoreRunning = true
|
|
s.restoreDone = false
|
|
s.restoreError = ""
|
|
s.restoreSteps = []RestoreStep{
|
|
{Label: "Konfiguráció visszaállítása...", Status: "running"},
|
|
{Label: "Meghajtók csatolása...", Status: "pending"},
|
|
{Label: "Beállítás befejezése...", Status: "pending"},
|
|
}
|
|
s.restoreMu.Unlock()
|
|
|
|
// Get stored data from state
|
|
configYAML := s.state.GetFormField("hub_config_yaml")
|
|
ibJSON := s.state.GetFormField("hub_infra_backup")
|
|
|
|
// Write controller.yaml
|
|
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
|
if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil {
|
|
s.setRestoreError(0, fmt.Sprintf("Konfiguráció írási hiba: %v", err))
|
|
return
|
|
}
|
|
|
|
// Restore settings from infra backup if available
|
|
var restoredIB *report.InfraBackup
|
|
if ibJSON != "" {
|
|
var ib report.InfraBackup
|
|
if err := json.Unmarshal([]byte(ibJSON), &ib); err == nil {
|
|
s.restoreFromInfraBackup(&ib)
|
|
restoredIB = &ib
|
|
}
|
|
}
|
|
s.setRestoreStepDone(0)
|
|
|
|
// Step 2: Mount drives from disk layout
|
|
s.setRestoreStepRunning(1)
|
|
if restoredIB != nil {
|
|
s.mountDrivesFromBackup(restoredIB)
|
|
}
|
|
s.setRestoreStepDone(1)
|
|
|
|
// Step 3: Finalize
|
|
s.setRestoreStepRunning(2)
|
|
|
|
// Save retrieval password
|
|
retrievalPw := s.state.GetFormField("retrieval_password")
|
|
if retrievalPw != "" {
|
|
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
|
if err == nil {
|
|
sett.SetRetrievalPassword(retrievalPw)
|
|
}
|
|
}
|
|
|
|
// Queue DR event
|
|
stackCount := 0
|
|
timestamp := ""
|
|
if restoredIB != nil {
|
|
stackCount = len(restoredIB.DeployedStacks)
|
|
timestamp = restoredIB.Timestamp
|
|
}
|
|
s.queueDREvent("hub", timestamp, stackCount)
|
|
|
|
s.setRestoreStepDone(2)
|
|
|
|
s.restoreMu.Lock()
|
|
s.restoreRunning = false
|
|
s.restoreDone = true
|
|
s.restoreMu.Unlock()
|
|
|
|
s.logger.Printf("[INFO] Setup: Hub restore completed")
|
|
|
|
time.Sleep(2 * time.Second)
|
|
s.finishSetup()
|
|
}
|
|
|
|
// --- Config Writing ---
|
|
|
|
func (s *Server) writeRestoredConfig(ib *report.InfraBackup) error {
|
|
// Decode and write controller.yaml
|
|
if ib.ControllerConfigB64 != "" {
|
|
configData, err := base64.StdEncoding.DecodeString(ib.ControllerConfigB64)
|
|
if err != nil {
|
|
return fmt.Errorf("decoding controller.yaml: %w", err)
|
|
}
|
|
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
|
if err := atomicWriteFile(configPath, configData, 0600); err != nil {
|
|
return fmt.Errorf("writing controller.yaml: %w", err)
|
|
}
|
|
}
|
|
|
|
s.restoreFromInfraBackup(ib)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) restoreFromInfraBackup(ib *report.InfraBackup) {
|
|
// Decode and write settings.json
|
|
if ib.SettingsJSONB64 != "" {
|
|
if data, err := base64.StdEncoding.DecodeString(ib.SettingsJSONB64); err == nil {
|
|
settingsPath := filepath.Join(s.dataDir, "settings.json")
|
|
if err := atomicWriteFile(settingsPath, data, 0644); err != nil {
|
|
s.logger.Printf("[WARN] Setup: failed to restore settings.json: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore restic password
|
|
if ib.ResticPassword != "" {
|
|
if data, err := base64.StdEncoding.DecodeString(ib.ResticPassword); err == nil {
|
|
pwFile := "/opt/docker/felhom-controller/data/restic-password"
|
|
if err := atomicWriteFile(pwFile, data, 0600); err != nil {
|
|
s.logger.Printf("[WARN] Setup: failed to restore restic password: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore encryption key for app.yaml secrets
|
|
if ib.EncryptionKeyB64 != "" {
|
|
if data, err := base64.StdEncoding.DecodeString(ib.EncryptionKeyB64); err == nil {
|
|
keyFile := filepath.Join(s.dataDir, "encryption.key")
|
|
if err := atomicWriteFile(keyFile, data, 0600); err != nil {
|
|
s.logger.Printf("[WARN] Setup: failed to restore encryption key: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// mountDrivesFromBackup mounts drives from the infra backup's disk layout.
|
|
// Best-effort: logs warnings on failure but does not block restore.
|
|
func (s *Server) mountDrivesFromBackup(ib *report.InfraBackup) {
|
|
if len(ib.DiskLayout.Mounts) == 0 {
|
|
s.logger.Printf("[INFO] Setup: no drives in disk layout to mount")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
mounted, err := backup.MountDrivesFromLayout(ctx, ib.DiskLayout, s.logger)
|
|
if err != nil {
|
|
s.logger.Printf("[WARN] Setup: drive mounting error: %v", err)
|
|
}
|
|
if len(mounted) > 0 {
|
|
s.logger.Printf("[INFO] Setup: mounted %d drive(s): %v", len(mounted), mounted)
|
|
}
|
|
}
|
|
|
|
func (s *Server) writeFreshConfig(configYAML, retrievalPassword string) error {
|
|
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
|
if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil {
|
|
return fmt.Errorf("writing controller.yaml: %w", err)
|
|
}
|
|
|
|
// Create initial settings with password hash and retrieval password
|
|
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
|
if err != nil {
|
|
sett = &settings.Settings{}
|
|
}
|
|
|
|
// Hash the dashboard password if provided in form
|
|
if pw := s.state.GetFormField("password"); pw != "" {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
|
if err == nil {
|
|
sett.SetPasswordHash(string(hash))
|
|
}
|
|
}
|
|
|
|
if retrievalPassword != "" {
|
|
sett.SetRetrievalPassword(retrievalPassword)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) generateManualConfig() string {
|
|
fd := s.state.FormData
|
|
|
|
customerID := fd["customer_id"]
|
|
displayName := fd["display_name"]
|
|
if displayName == "" {
|
|
displayName = customerID
|
|
}
|
|
domain := fd["domain"]
|
|
email := fd["email"]
|
|
cfTunnelToken := fd["cf_tunnel_token"]
|
|
cfAPIToken := fd["cf_api_token"]
|
|
systemDataPath := fd["system_data_path"]
|
|
if systemDataPath == "" {
|
|
systemDataPath = "/mnt/sys_drive"
|
|
}
|
|
|
|
// Generate session secret
|
|
secretBytes := make([]byte, 32)
|
|
crand.Read(secretBytes)
|
|
sessionSecret := hex.EncodeToString(secretBytes)
|
|
|
|
// Generate password hash
|
|
passwordHash := ""
|
|
if pw := fd["password"]; pw != "" {
|
|
if hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost); err == nil {
|
|
passwordHash = string(hash)
|
|
}
|
|
}
|
|
|
|
gitRepoURL := fd["git_repo_url"]
|
|
if gitRepoURL == "" {
|
|
gitRepoURL = "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
|
|
}
|
|
gitUsername := fd["git_username"]
|
|
gitToken := fd["git_token"]
|
|
|
|
// Build YAML manually (simple key-value, no templates needed)
|
|
var b strings.Builder
|
|
b.WriteString("# Generated by felhom-controller setup wizard\n")
|
|
b.WriteString("customer:\n")
|
|
fmt.Fprintf(&b, " id: %q\n", customerID)
|
|
fmt.Fprintf(&b, " name: %q\n", displayName)
|
|
fmt.Fprintf(&b, " domain: %q\n", domain)
|
|
if email != "" {
|
|
fmt.Fprintf(&b, " email: %q\n", email)
|
|
}
|
|
b.WriteString("\ninfrastructure:\n")
|
|
if cfTunnelToken != "" {
|
|
fmt.Fprintf(&b, " cf_tunnel_token: %q\n", cfTunnelToken)
|
|
}
|
|
if cfAPIToken != "" {
|
|
fmt.Fprintf(&b, " cf_api_token: %q\n", cfAPIToken)
|
|
}
|
|
b.WriteString("\npaths:\n")
|
|
b.WriteString(" stacks_dir: \"/opt/docker/stacks\"\n")
|
|
b.WriteString(" data_dir: \"/opt/docker/felhom-controller/data\"\n")
|
|
fmt.Fprintf(&b, " system_data_path: %q\n", systemDataPath)
|
|
b.WriteString("\nsystem:\n")
|
|
b.WriteString(" reserved_memory_mb: 384\n")
|
|
b.WriteString("\nweb:\n")
|
|
b.WriteString(" listen: \":8080\"\n")
|
|
b.WriteString(" setup_listen: \":8081\"\n")
|
|
if passwordHash != "" {
|
|
fmt.Fprintf(&b, " password_hash: %q\n", passwordHash)
|
|
}
|
|
fmt.Fprintf(&b, " session_secret: %q\n", sessionSecret)
|
|
b.WriteString("\ngit:\n")
|
|
fmt.Fprintf(&b, " repo_url: %q\n", gitRepoURL)
|
|
b.WriteString(" branch: \"main\"\n")
|
|
b.WriteString(" sync_interval: \"15m\"\n")
|
|
if gitUsername != "" {
|
|
fmt.Fprintf(&b, " username: %q\n", gitUsername)
|
|
}
|
|
if gitToken != "" {
|
|
fmt.Fprintf(&b, " token: %q\n", gitToken)
|
|
}
|
|
b.WriteString("\nstacks:\n")
|
|
b.WriteString(" protected:\n")
|
|
b.WriteString(" - \"traefik\"\n")
|
|
b.WriteString(" - \"cloudflared\"\n")
|
|
b.WriteString(" - \"felhom-controller\"\n")
|
|
b.WriteString(" - \"filebrowser\"\n")
|
|
b.WriteString(" update_window: \"03:00-05:00\"\n")
|
|
b.WriteString("\nbackup:\n")
|
|
b.WriteString(" enabled: true\n")
|
|
b.WriteString(" restic_password_file: \"/opt/docker/felhom-controller/data/restic-password\"\n")
|
|
b.WriteString(" db_dump_schedule: \"02:30\"\n")
|
|
b.WriteString(" restic_schedule: \"03:00\"\n")
|
|
b.WriteString(" retention:\n")
|
|
b.WriteString(" keep_daily: 7\n")
|
|
b.WriteString(" keep_weekly: 4\n")
|
|
b.WriteString(" keep_monthly: 6\n")
|
|
b.WriteString(" prune_schedule: \"weekly\"\n")
|
|
b.WriteString("\nmonitoring:\n")
|
|
b.WriteString(" enabled: true\n")
|
|
b.WriteString(" healthchecks_base: \"https://status.felhom.eu\"\n")
|
|
b.WriteString(" system_health_interval: \"5m\"\n")
|
|
b.WriteString(" health_check_schedule: \"06:00\"\n")
|
|
b.WriteString("\nhub:\n")
|
|
b.WriteString(" enabled: true\n")
|
|
b.WriteString(" url: \"https://hub.felhom.eu\"\n")
|
|
// Generate a Hub API key from customer ID
|
|
apiKeyHash := sha256.Sum256([]byte(customerID + "-" + sessionSecret))
|
|
fmt.Fprintf(&b, " api_key: %q\n", hex.EncodeToString(apiKeyHash[:]))
|
|
b.WriteString(" push_interval: \"15m\"\n")
|
|
b.WriteString("\nself_update:\n")
|
|
b.WriteString(" enabled: true\n")
|
|
b.WriteString(" check_interval: \"6h\"\n")
|
|
b.WriteString(" image: \"gitea.dooplex.hu/admin/felhom-controller\"\n")
|
|
b.WriteString(" auto_update: false\n")
|
|
b.WriteString(" health_timeout_seconds: 60\n")
|
|
b.WriteString("\nlogging:\n")
|
|
b.WriteString(" level: \"info\"\n")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func (s *Server) finishSetup() {
|
|
s.state.Remove()
|
|
s.logger.Printf("[INFO] Setup complete — restarting controller")
|
|
os.Exit(0) // Docker restart policy will restart us
|
|
}
|
|
|
|
func (s *Server) queueDREvent(source, backupTimestamp string, stackCount int) {
|
|
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
|
if err != nil {
|
|
s.logger.Printf("[WARN] Setup: failed to load settings for DR event: %v", err)
|
|
return
|
|
}
|
|
|
|
details, _ := json.Marshal(map[string]interface{}{
|
|
"source": source,
|
|
"backup_timestamp": backupTimestamp,
|
|
"stacks_count": stackCount,
|
|
"controller_version": s.version,
|
|
})
|
|
|
|
sett.AddPendingEvent(settings.PendingEvent{
|
|
EventType: "disaster_recovery_completed",
|
|
Severity: "warning",
|
|
Message: "System restored from backup",
|
|
Details: string(details),
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (s *Server) setRestoreStepDone(idx int) {
|
|
s.restoreMu.Lock()
|
|
defer s.restoreMu.Unlock()
|
|
if idx < len(s.restoreSteps) {
|
|
s.restoreSteps[idx].Status = "done"
|
|
}
|
|
}
|
|
|
|
func (s *Server) setRestoreStepRunning(idx int) {
|
|
s.restoreMu.Lock()
|
|
defer s.restoreMu.Unlock()
|
|
if idx < len(s.restoreSteps) {
|
|
s.restoreSteps[idx].Status = "running"
|
|
}
|
|
}
|
|
|
|
func (s *Server) setRestoreError(idx int, msg string) {
|
|
s.restoreMu.Lock()
|
|
defer s.restoreMu.Unlock()
|
|
if idx < len(s.restoreSteps) {
|
|
s.restoreSteps[idx].Status = "failed"
|
|
s.restoreSteps[idx].Error = msg
|
|
}
|
|
s.restoreRunning = false
|
|
s.restoreError = msg
|
|
}
|
|
|
|
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 %s render error: %v", name, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, tmpl, msg, customerID string) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"Error": msg,
|
|
"CustomerID": customerID,
|
|
}
|
|
s.render(w, tmpl, data)
|
|
}
|
|
|
|
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, perm); err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
os.Remove(tmp)
|
|
// Rename fails on bind-mounted files (EBUSY). Fall back to direct write.
|
|
return os.WriteFile(path, data, perm)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isError(err, target error) bool {
|
|
return err != nil && strings.Contains(err.Error(), target.Error())
|
|
}
|
|
|
|
// Minimal CSS for when the main stylesheet can't be loaded
|
|
const minimalCSS = `
|
|
:root { --bg-primary: #0d1117; --bg-card: #1c2128; --text-primary: #e6edf3; --text-secondary: #8b949e; --accent-blue: #0088cc; --border: #30363d; --green: #238636; --red: #da3633; }
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|
.setup-container { max-width: 700px; margin: 0 auto; padding: 2rem 1.5rem; }
|
|
.setup-header { text-align: center; margin-bottom: 2rem; }
|
|
.setup-header img { width: 120px; margin-bottom: 1rem; }
|
|
.setup-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
|
.setup-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; cursor: pointer; transition: border-color 0.2s; }
|
|
.setup-card:hover { border-color: var(--accent-blue); }
|
|
.setup-card h3 { margin-bottom: 0.5rem; }
|
|
.setup-card p { color: var(--text-secondary); font-size: 0.9rem; }
|
|
.form-group { margin-bottom: 1rem; }
|
|
.form-group label { display: block; margin-bottom: 0.25rem; font-size: 0.9rem; color: var(--text-secondary); }
|
|
.form-control { width: 100%; padding: 0.5rem 0.75rem; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); font-size: 0.9rem; }
|
|
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.6rem 1.5rem; border-radius: 6px; border: none; font-size: 0.9rem; font-weight: 500; cursor: pointer; text-decoration: none; }
|
|
.btn-primary { background: var(--green); color: #fff; }
|
|
.btn-primary:hover { background: #2ea043; }
|
|
.btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border); }
|
|
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
|
|
.alert-error { background: rgba(218,54,51,0.15); color: #f85149; border: 1px solid rgba(218,54,51,0.3); }
|
|
.alert-info { background: rgba(0,136,204,0.15); color: #58a6ff; border: 1px solid rgba(0,136,204,0.3); }
|
|
.info-box { background: rgba(0,136,204,0.1); border: 1px solid rgba(0,136,204,0.2); border-radius: 6px; padding: 0.75rem 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: var(--text-secondary); }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th { text-align: left; padding: 0.5rem 0.75rem; color: var(--text-secondary); font-size: 0.85rem; border-bottom: 1px solid var(--border); }
|
|
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
|
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; }
|
|
.badge-ok { background: rgba(63,185,80,0.15); color: var(--green); }
|
|
.badge-error { background: rgba(218,54,51,0.15); color: var(--red); }
|
|
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent-blue); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.section { margin-bottom: 1.5rem; }
|
|
.section-header { cursor: pointer; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; display: flex; justify-content: space-between; align-items: center; }
|
|
.section-body { padding: 1rem; border: 1px solid var(--border); border-top: none; border-radius: 0 0 6px 6px; }
|
|
.step-list { list-style: none; }
|
|
.step-list li { padding: 0.5rem 0; display: flex; align-items: center; gap: 0.75rem; }
|
|
.step-done { color: var(--green); }
|
|
.step-running { color: var(--accent-blue); }
|
|
.step-failed { color: var(--red); }
|
|
`
|