v0.22.1: Fix setup wizard bugs (detection, CSRF panic, version display, IP)

- NeedsSetup: only check for empty customer.id (not "demo-felhom")
- renderError: pass *http.Request to ensureCSRFToken (was nil → panic)
- Welcome template: remove redundant "v" prefix from version display
- IP detection: read HOST_IP env var for Docker container awareness
- docker-setup.sh: inject HOST_IP into generated docker-compose.yml
- Add logging for Hub config download in setup wizard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 13:30:32 +01:00
parent 6eb75204b6
commit 296fdbfdcb
6 changed files with 71 additions and 13 deletions
+10 -1
View File
@@ -1,5 +1,14 @@
## Changelog ## 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) ### 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. 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/`):** **Setup Wizard (`internal/setup/`):**
- New web-based setup wizard replaces interactive CLI wizard from `docker-setup.sh` - 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) - 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) - 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 - 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 - Hub recovery: `GET /api/v1/recovery/{id}` with retrieval password auth — returns combined config + infra backup
+11 -7
View File
@@ -312,7 +312,7 @@ func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) {
s.state.SetFormField("customer_id", customerID) s.state.SetFormField("customer_id", customerID)
if customerID == "" || password == "" { 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 return
} }
@@ -329,7 +329,7 @@ func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) {
default: default:
msg = fmt.Sprintf("Hiba történt: %v", err) 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 return
} }
@@ -372,12 +372,14 @@ func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
s.state.SetFormField("customer_id", customerID) s.state.SetFormField("customer_id", customerID)
if customerID == "" || password == "" { 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 return
} }
s.logger.Printf("[INFO] Setup: downloading config from Hub (%s) for customer %s", hubURL, customerID)
configYAML, err := report.PullConfig(hubURL, customerID, password) configYAML, err := report.PullConfig(hubURL, customerID, password)
if err != nil { if err != nil {
s.logger.Printf("[ERROR] Setup: Hub config download failed: %v", err)
var msg string var msg string
switch { switch {
case isError(err, report.ErrHubUnreachable): case isError(err, report.ErrHubUnreachable):
@@ -389,14 +391,16 @@ func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
default: default:
msg = fmt.Sprintf("Hiba történt: %v", err) 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 return
} }
s.logger.Printf("[INFO] Setup: config downloaded (%d bytes), writing config...", len(configYAML))
// Write config and finish setup // Write config and finish setup
s.state.SetFormField("retrieval_password", password) s.state.SetFormField("retrieval_password", password)
if err := s.writeFreshConfig(configYAML, password); err != nil { 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 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) { func (s *Server) renderError(w http.ResponseWriter, r *http.Request, tmpl, msg, customerID string) {
csrf := ensureCSRFToken(w, nil) csrf := ensureCSRFToken(w, r)
data := map[string]interface{}{ data := map[string]interface{}{
"CSRF": csrf, "CSRF": csrf,
"Error": msg, "Error": msg,
+46 -3
View File
@@ -2,12 +2,56 @@ package setup
import ( import (
"net" "net"
"sort" "os"
"os/exec"
"strings" "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 { 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() ifaces, err := net.Interfaces()
if err != nil { if err != nil {
return nil return nil
@@ -44,6 +88,5 @@ func DetectLocalIPs() []string {
} }
} }
sort.Strings(ips)
return ips return ips
} }
+2 -1
View File
@@ -12,8 +12,9 @@ import (
) )
// NeedsSetup checks whether the controller should enter setup mode. // 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 { 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. // SetupState persists wizard progress to survive browser crashes.
@@ -12,7 +12,7 @@
<div class="setup-header"> <div class="setup-header">
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;"> <img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
<h1>Felhom Szerver Beállítás</h1> <h1>Felhom Szerver Beállítás</h1>
<p style="color: var(--text-secondary, #8b949e); font-size: 0.85rem;">v{{.Version}}</p> <p style="color: var(--text-secondary, #8b949e); font-size: 0.85rem;">{{.Version}}</p>
</div> </div>
{{if .AccessURLs}} {{if .AccessURLs}}
+1
View File
@@ -1528,6 +1528,7 @@ services:
- /run/udev:/run/udev:ro - /run/udev:/run/udev:ro
environment: environment:
- TZ=Europe/Budapest - TZ=Europe/Budapest
- HOST_IP=$(get_server_ip)
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.controller.rule=Host(\`felhom.${BASE_DOMAIN}\`)" - "traefik.http.routers.controller.rule=Host(\`felhom.${BASE_DOMAIN}\`)"