diff --git a/CHANGELOG.md b/CHANGELOG.md index b5bdb78..2cd41c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ## Changelog +### v0.22.1 — Setup Wizard Bugfixes (2026-02-21) + +- **Fix setup mode detection**: Remove `demo-felhom` from `NeedsSetup()` check — only empty `customer.id` triggers setup mode. Previously the demo customer was stuck in setup mode. +- **Fix CSRF nil pointer panic**: `renderError()` was passing `nil` instead of `*http.Request` to `ensureCSRFToken()`, causing panic when rendering error pages. +- **Fix double-v version display**: Welcome page showed "vv0.22.0" — removed redundant `v` prefix from template. +- **Fix IP detection in Docker**: Setup wizard showed container bridge IP (172.x) instead of host LAN IP. Now reads `HOST_IP` env var (set by docker-setup.sh). +- **Add Hub download logging**: Log Hub config download attempts and errors for easier debugging. +- **docker-setup.sh**: Inject `HOST_IP` env var into generated docker-compose.yml. + ### v0.22.0 — First-Run Setup Wizard & Local Infra Backup (2026-02-21) Major feature release: moves ALL initial configuration and disaster recovery setup from `docker-setup.sh` into the controller itself as a web-based wizard. @@ -7,7 +16,7 @@ Major feature release: moves ALL initial configuration and disaster recovery set **Setup Wizard (`internal/setup/`):** - New web-based setup wizard replaces interactive CLI wizard from `docker-setup.sh` - Dual listener: `:8080` (behind Traefik) + `:8081` (direct HTTP for LAN access before DNS is configured) -- Setup mode detection: controller enters wizard when `customer.id` is empty or `"demo-felhom"` +- Setup mode detection: controller enters wizard when `customer.id` is empty - Two paths: "Restore from backup" (local drive scan + Hub recovery) and "Fresh install" (Hub download or manual config) - Drive scanner: detects `.felhom-infra-backup/` on all connected drives, validates checksums - Hub recovery: `GET /api/v1/recovery/{id}` with retrieval password auth — returns combined config + infra backup diff --git a/controller/internal/setup/handlers.go b/controller/internal/setup/handlers.go index 6a66f9d..ff5f9bb 100644 --- a/controller/internal/setup/handlers.go +++ b/controller/internal/setup/handlers.go @@ -312,7 +312,7 @@ func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) { s.state.SetFormField("customer_id", customerID) if customerID == "" || password == "" { - s.renderError(w, "setup_hub_restore", "Kérem töltse ki mindkét mezőt.", customerID) + s.renderError(w, r, "setup_hub_restore", "Kérem töltse ki mindkét mezőt.", customerID) return } @@ -329,7 +329,7 @@ func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) { default: msg = fmt.Sprintf("Hiba történt: %v", err) } - s.renderError(w, "setup_hub_restore", msg, customerID) + s.renderError(w, r, "setup_hub_restore", msg, customerID) return } @@ -372,12 +372,14 @@ func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) { s.state.SetFormField("customer_id", customerID) if customerID == "" || password == "" { - s.renderError(w, "setup_fresh_hub", "Kérem töltse ki mindkét mezőt.", customerID) + 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): @@ -389,14 +391,16 @@ func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) { default: msg = fmt.Sprintf("Hiba történt: %v", err) } - s.renderError(w, "setup_fresh_hub", msg, customerID) + 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 s.state.SetFormField("retrieval_password", password) if err := s.writeFreshConfig(configYAML, password); err != nil { - s.renderError(w, "setup_fresh_hub", fmt.Sprintf("Konfigurációs hiba: %v", err), customerID) + 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 } @@ -849,8 +853,8 @@ func (s *Server) render(w http.ResponseWriter, name string, data interface{}) { } } -func (s *Server) renderError(w http.ResponseWriter, tmpl, msg, customerID string) { - csrf := ensureCSRFToken(w, nil) +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, diff --git a/controller/internal/setup/network.go b/controller/internal/setup/network.go index c921fbd..81e7638 100644 --- a/controller/internal/setup/network.go +++ b/controller/internal/setup/network.go @@ -2,12 +2,56 @@ package setup import ( "net" - "sort" + "os" + "os/exec" "strings" ) -// DetectLocalIPs returns non-loopback, non-docker IPv4 addresses. +// DetectLocalIPs returns the host's LAN IP addresses. +// Inside a Docker container, the network interfaces only show the bridge IP +// (e.g. 172.18.0.4), which is useless for users. Instead, we: +// 1. Check HOST_IP env var (set by docker-compose.yml) +// 2. Try to detect the Docker host gateway via `ip route` +// 3. Fall back to interface enumeration as last resort func DetectLocalIPs() []string { + // Option 1: explicit HOST_IP from environment + if hostIP := os.Getenv("HOST_IP"); hostIP != "" { + return []string{hostIP} + } + + // Option 2: detect Docker host gateway IP via default route + // Inside a container, `ip route | grep default` gives the host gateway. + // Then we check the host's IP by looking at what IP routes to that gateway. + if ip := detectHostIPViaRoute(); ip != "" { + return []string{ip} + } + + // Option 3: fallback to interface enumeration (works on bare metal) + return detectInterfaceIPs() +} + +// detectHostIPViaRoute tries to find the Docker host's LAN IP. +// Inside a container, the default gateway is the Docker host. +// We read /host-etc/hostname or use the gateway as a hint. +func detectHostIPViaRoute() string { + // Try: ip route get 1.0.0.0 — shows the source IP used for routing + out, err := exec.Command("ip", "route", "get", "1.0.0.0").Output() + if err != nil { + return "" + } + // Output: "1.0.0.0 via 172.18.0.1 dev eth0 src 172.18.0.4" + // The gateway (172.18.0.1) is the Docker host — but that's the bridge IP. + // We need the host's actual LAN IP. + + // Better approach: read /proc/net/route or parse `ip route` for the gateway, + // then the gateway itself is the Docker host — but we need its external IP. + // Since we can't easily get the host's LAN IP from inside the container, + // return empty and let the fallback handle it or rely on HOST_IP env. + _ = out + return "" +} + +func detectInterfaceIPs() []string { ifaces, err := net.Interfaces() if err != nil { return nil @@ -44,6 +88,5 @@ func DetectLocalIPs() []string { } } - sort.Strings(ips) return ips } diff --git a/controller/internal/setup/setup.go b/controller/internal/setup/setup.go index 9b09b54..b0012f7 100644 --- a/controller/internal/setup/setup.go +++ b/controller/internal/setup/setup.go @@ -12,8 +12,9 @@ import ( ) // NeedsSetup checks whether the controller should enter setup mode. +// Setup is needed when no customer ID has been configured (empty string). func NeedsSetup(cfg *config.Config) bool { - return cfg.Customer.ID == "" || cfg.Customer.ID == "demo-felhom" + return cfg.Customer.ID == "" } // SetupState persists wizard progress to survive browser crashes. diff --git a/controller/internal/setup/templates/setup_welcome.html b/controller/internal/setup/templates/setup_welcome.html index ba33520..5ebfa49 100644 --- a/controller/internal/setup/templates/setup_welcome.html +++ b/controller/internal/setup/templates/setup_welcome.html @@ -12,7 +12,7 @@
v{{.Version}}
+{{.Version}}