v0.308.0 (R-554, held-back-able): recovery-info.txt points at the dashboard's Restore page and the recovery code; the first-boot setup wizard is deleted

Operator ruling 2026-10-12 (S27, 09 §3 decision 222). The operator approves the Hungarian text before
release. A box whose first ingestion failed now serves one bilingual waiting page and retries every
minute instead of the wizard. Own commit so the lead can hold it back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-12 15:38:28 +02:00
parent fd28d70bff
commit faef8f8a91
36 changed files with 421 additions and 1916 deletions
+23 -73
View File
@@ -2,7 +2,6 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
@@ -10,6 +9,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/sockheal"
"io"
"log"
"net"
"net/http"
"os"
"os/signal"
@@ -54,7 +54,6 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/selftest"
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/setup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
catalogsync "gitea.dooplex.hu/admin/felhom-controller/internal/sync"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
@@ -325,11 +324,25 @@ func main() {
system.DebugLogger = logger
}
// --- Setup mode: if no customer ID configured, run setup wizard ---
if setup.NeedsSetup(cfg) {
logger.Printf("[INFO] felhom-controller %s — setup mode", Version)
runSetupMode(cfg, logger)
return
// --- Not configured yet: wait for the hub (R-554, v0.308.0) ---
// The first-boot setup wizard is deleted. A box whose first ingestion failed shows ONE waiting page
// (both languages) and retries every minute; once configured it falls through to the normal start
// below, on the same listener. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.
if bootstrap.Unconfigured(cfg) {
logger.Printf("[INFO] felhom-controller %s — not configured yet: waiting for the hub", Version)
waitCtx, waitStop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
ln, lerr := net.Listen("tcp", cfg.Web.Listen)
if lerr != nil {
logger.Printf("[ERROR] waiting page: cannot listen on %s: %v — retrying the configuration without it", cfg.Web.Listen, lerr)
ln = nil
}
next := bootstrap.WaitForConfig(waitCtx, ln, *configPath, cfg, logger, pull, bootstrap.WaitRetryInterval, Version)
waitStop()
if next == nil {
logger.Printf("[INFO] Received a stop signal while waiting for the configuration — exiting")
return
}
cfg = next
}
// --- Local API connectivity probe (slice 8A) ---
@@ -388,7 +401,7 @@ func main() {
stackMgr.MigrateEncryption()
// --- First-boot base-infrastructure bring-up ---
// We are guaranteed configured here (setup.NeedsSetup returned false above), so deploy the base
// We are guaranteed configured here (bootstrap.Unconfigured returned false above), so deploy the base
// stack (traefik-public network → traefik → cloudflared → filebrowser) the controller needs for
// routing + external access. Runs in a goroutine so a slow first-boot image pull never delays the
// web server; non-fatal (idempotent + single-flight, the health loop re-attempts each tick).
@@ -1713,6 +1726,8 @@ func main() {
HubURL: cfg.Hub.URL,
SupportEmail: "support@felhom.eu",
SupportURL: "https://felhom.eu/kapcsolat",
DashboardURL: "https://felhom." + cfg.Customer.Domain, // R-554: the Restore page, not the wizard
Lang: sett.GetLanguage(),
}
if err := recovery.GenerateRecoveryFile(info, Version, cfg.Paths.DataDir); err != nil {
logger.Printf("[WARN] Failed to generate recovery-info.txt: %v", err)
@@ -3696,71 +3711,6 @@ func probeLocalAPI(cfg *config.Config, logger *log.Logger) {
}
}
// runSetupMode starts the setup wizard on dual listeners and blocks until signal.
func runSetupMode(cfg *config.Config, logger *log.Logger) {
ips := setup.DetectLocalIPs()
setup.LogSetupMode(cfg.Customer.Domain, ips, cfg.Web.SetupListen, logger)
setupSrv := setup.NewServer(cfg, cfg.Paths.DataDir, logger, Version)
handler := setupSrv.Handler()
// Health endpoint wrapper (returns setup_mode: true)
healthHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true, "message": "felhom-controller is healthy",
"setup_mode": true, "version": Version,
})
})
// Mux for both listeners
mux := http.NewServeMux()
mux.HandleFunc("/api/health", healthHandler)
mux.Handle("/", handler)
// Start main listener (:8080, behind Traefik for domain access)
mainServer := &http.Server{
Addr: cfg.Web.Listen,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
logger.Printf("[INFO] Setup wizard (main) listening on %s", cfg.Web.Listen)
if err := mainServer.ListenAndServe(); err != http.ErrServerClosed {
logger.Printf("[ERROR] Main HTTP server error: %v", err)
}
}()
// Start setup-only listener (:8081, direct HTTP for LAN access)
setupServer := &http.Server{
Addr: cfg.Web.SetupListen,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
}
go func() {
logger.Printf("[INFO] Setup wizard (LAN) listening on %s", cfg.Web.SetupListen)
if err := setupServer.ListenAndServe(); err != http.ErrServerClosed {
logger.Printf("[ERROR] Setup HTTP server error: %v", err)
}
}()
// Wait for signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
sig := <-sigCh
logger.Printf("[INFO] Received signal %v, shutting down setup wizard...", sig)
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
mainServer.Shutdown(shutdownCtx)
setupServer.Shutdown(shutdownCtx)
logger.Println("[INFO] Setup wizard stopped")
}
// discoverHDDPaths scans deployed apps' app.yaml for HDD_PATH env values.
func discoverHDDPaths(stacksDir string, logger *log.Logger) []string {
entries, err := os.ReadDir(stacksDir)