From 1e8a562bd36bb85aeba712c1140c6b1580c7effa Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 26 Feb 2026 13:58:31 +0100 Subject: [PATCH] feat(setup): hub mode triggers setup wizard with infra backup restore docker-setup.sh --hub-customer now generates a minimal controller.yaml (no customer.id) instead of installing full hub config, triggering the setup wizard on first run. Hub credentials are passed via env vars (FELHOM_SETUP_CUSTOMER_ID, FELHOM_SETUP_PASSWORD) so the wizard auto-fills and auto-processes Hub API calls. Welcome page shows three options in hub mode: restore from Hub (primary), restore from local drives, or fresh install. On error, falls back to manual form with error displayed. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 11 ++ controller/README.md | 34 +++- controller/internal/setup/handlers.go | 149 +++++++++++++++++- .../setup/templates/setup_welcome.html | 21 +++ scripts/README.md | 28 +++- scripts/docker-setup.sh | 60 ++++--- 6 files changed, 272 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3506d72..a0a2134 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ ## Changelog +### v0.31.7 — Hub mode: setup wizard with infra backup restore (2026-02-26) + +#### Changed +- **docker-setup.sh hub mode**: `--hub-customer` now generates a minimal `controller.yaml` (no `customer.id`) instead of installing the full hub config — this triggers the setup wizard on first run, giving the user a choice to restore from an infra backup or start fresh +- **docker-setup.sh**: Hub credentials are passed to the controller via `FELHOM_SETUP_CUSTOMER_ID` and `FELHOM_SETUP_PASSWORD` environment variables so the setup wizard auto-fills them + +#### Added +- **Setup wizard hub pre-seeding**: When deployed with `--hub-customer`, the wizard auto-detects pre-seeded credentials and auto-processes Hub API calls (no manual form entry needed) +- **Hub mode welcome page**: Shows three options instead of two — "Visszaállítás a Hub-ról" (auto-connects to Hub), "Helyi mentés keresése" (local drive scan), "Friss telepítés" (fresh config download) +- **Auto-process fallback**: If Hub auto-connect fails, the wizard clears the pre-seeded password and falls back to the manual form with the error displayed + ### v0.31.6 — UI: Brand-consistent button & card styling (2026-02-25) #### Changed diff --git a/controller/README.md b/controller/README.md index 5783743..45517b3 100644 --- a/controller/README.md +++ b/controller/README.md @@ -925,11 +925,11 @@ The hub service (separate Go app in the `felhom.eu` repo) provides: ### 9. First-Run Setup Wizard -When the controller starts with no valid customer configuration (`customer.id` empty or `"demo-felhom"`), it enters **setup mode** — a web-based wizard that handles all initial configuration. This replaces the old interactive shell wizard in `docker-setup.sh`. +When the controller starts with no valid customer configuration (`customer.id` empty), it enters **setup mode** — a web-based wizard that handles all initial configuration. This replaces the old interactive shell wizard in `docker-setup.sh`. #### Setup Mode Detection (`internal/setup/setup.go`) -`NeedsSetup(cfg)` returns true when `customer.id` is empty or `"demo-felhom"`. In setup mode, the controller skips normal startup (no scheduler, no backup, no stacks) and serves only the wizard UI on two listeners: +`NeedsSetup(cfg)` returns true when `customer.id` is empty or a `.needs-setup` marker file exists. In setup mode, the controller skips normal startup (no scheduler, no backup, no stacks) and serves only the wizard UI on two listeners: - `:8080` — behind Traefik (accessible via domain, e.g. `https://felhom.example.com`) - `:8081` — direct HTTP (accessible via LAN IP, e.g. `http://192.168.0.100:8081`) @@ -965,6 +965,24 @@ When the controller starts with no valid customer configuration (`customer.id` e → normal mode ``` +#### Hub Pre-Seeding + +When `docker-setup.sh` is run with `--hub-customer` / `--hub-password`, the controller receives +pre-seeded credentials via environment variables: + +| Env var | Purpose | +|---------|---------| +| `FELHOM_SETUP_CUSTOMER_ID` | Pre-fills customer ID in wizard forms | +| `FELHOM_SETUP_PASSWORD` | Pre-fills retrieval password for auto-processing | + +In hub mode, the welcome page shows three cards instead of two: +1. **"Visszaállítás a Hub-ról"** — auto-calls `PullRecovery()`, shows infra backup details +2. **"Visszaállítás helyi meghajtóról"** — standard drive scan +3. **"Friss telepítés"** — auto-calls `PullConfig()`, downloads config only + +Both hub paths auto-process when credentials are pre-seeded (no form entry needed). +On error, the wizard falls back to the manual form with the error displayed. + #### Key Components | File | Purpose | @@ -994,12 +1012,16 @@ Generates `recovery-info.txt` on the system data partition with customer ID, Hub When a system drive fails and is replaced, the recovery flow uses the setup wizard: ``` -1. docker-setup.sh deploys fresh controller with minimal config (domain + paths only) +1. docker-setup.sh deploys fresh controller with minimal config + - With --hub-customer: credentials pre-seeded via env vars + - Without: user enters credentials manually in wizard 2. Controller detects empty customer.id → enters setup mode 3. User opens wizard at http://:8081 -4. Wizard scans all drives for .felhom-infra-backup/ directories -5. If found: one-click restore (config, settings, passwords, disk layout) -6. If not found: Hub recovery via customer ID + retrieval password +4. Hub mode: welcome page shows Hub restore / local scan / fresh install + Non-hub mode: welcome page shows restore / fresh install +5. Hub restore: auto-connects to Hub, shows infra backup details + Local restore: scans all drives for .felhom-infra-backup/ directories +6. One-click restore: config, settings, passwords, disk layout 7. Controller restarts into normal mode with full config 8. Controller auto-mounts surviving drives by UUID from disk layout 9. Dashboard shows "Visszaállítás" (Restore) page for app-level recovery diff --git a/controller/internal/setup/handlers.go b/controller/internal/setup/handlers.go index dd5a8e5..a8af0fe 100644 --- a/controller/internal/setup/handlers.go +++ b/controller/internal/setup/handlers.go @@ -70,6 +70,20 @@ func NewServer(cfg *config.Config, dataDir string, logger *log.Logger, version s 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 } @@ -78,6 +92,12 @@ 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{ @@ -130,9 +150,11 @@ func (s *Server) handleWelcome(w http.ResponseWriter, r *http.Request) { } data := map[string]interface{}{ - "CSRF": csrf, - "AccessURLs": accessURLs, - "Version": s.version, + "CSRF": csrf, + "AccessURLs": accessURLs, + "Version": s.version, + "HubMode": s.isHubPreseeded(), + "HubCustomerID": s.state.GetFormField("customer_id"), } s.render(w, "setup_welcome", data) } @@ -181,6 +203,14 @@ func (s *Server) handleHubRestore(w http.ResponseWriter, r *http.Request) { 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"), @@ -244,6 +274,14 @@ func (s *Server) handleFreshHub(w http.ResponseWriter, r *http.Request) { 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"), @@ -302,6 +340,111 @@ func (s *Server) handleLogo(w http.ResponseWriter, r *http.Request) { 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", recovery.HasInfraBackup, len(recovery.ConfigYAML)) + } + + // Store recovery data in state for restore execution + 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-confirm") + s.state.Save() + + // Show confirmation page with backup details + csrf := ensureCSRFToken(w, r) + data := map[string]interface{}{ + "CSRF": csrf, + "CustomerID": customerID, + "HasInfraBackup": recovery.HasInfraBackup, + "HasConfig": recovery.ConfigYAML != "", + "Source": "hub", + } + if recovery.HasInfraBackup && recovery.InfraBackup != nil { + data["Timestamp"] = recovery.InfraBackup.Timestamp + data["StackCount"] = len(recovery.InfraBackup.DeployedStacks) + } + 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) { diff --git a/controller/internal/setup/templates/setup_welcome.html b/controller/internal/setup/templates/setup_welcome.html index 5ebfa49..9a1ac55 100644 --- a/controller/internal/setup/templates/setup_welcome.html +++ b/controller/internal/setup/templates/setup_welcome.html @@ -22,6 +22,26 @@ {{end}} + {{if .HubMode}} +
+ Hub-mód: {{.HubCustomerID}} — az azonosító és jelszó automatikusan betöltődik. +
+ + +

Visszaállítás a Hub-ról

+

A Hub-on tárolt infrastruktúra mentés visszaállítása (beállítások, titkosítási kulcsok, lemez-kiosztás).

+
+ + +

Visszaállítás helyi meghajtóról

+

Csatlakoztatott meghajtók keresése infrastruktúra mentésért.

+
+ + +

Friss telepítés

+

Új konfiguráció letöltése a Hub-ról. Korábbi beállítások nem állítódnak vissza.

+
+ {{else}}

Visszaállítás mentésből

Rendszerhiba utáni visszaállítás helyi meghajtóról vagy a Hub-ról. Válassza ezt, ha az operációs rendszert újratelepítette.

@@ -31,6 +51,7 @@

Új telepítés

Új ügyfél beállítása. Konfiguráció letöltése a Hub-ról vagy kézi beállítás.

+ {{end}} diff --git a/scripts/README.md b/scripts/README.md index 3300eaa..568a649 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -94,16 +94,17 @@ The script supports three mutually exclusive TLS modes: ### Hub mode -When both `--hub-customer` and `--hub-password` are provided, the script downloads a -pre-configured `controller.yaml` from the Felhom Hub **before any infra setup begins**, -then extracts the stored values to auto-configure everything — no additional flags needed: +When both `--hub-customer` and `--hub-password` are provided, the script downloads the +customer's config from the Felhom Hub **before any infra setup begins** to extract +infrastructure variables (domain, email, CF tokens), then generates a **minimal** +`controller.yaml` without `customer.id` — triggering the setup wizard on first run. ``` GET https://hub.felhom.eu/api/v1/config/{customer_id} Header: X-Retrieval-Password: {password} ``` -The downloaded config is parsed early in the run and populates: +The downloaded config is parsed early and populates infrastructure variables: | Extracted field | Used for | |-----------------|----------| @@ -114,6 +115,25 @@ The downloaded config is parsed early in the run and populates: CLI flags always take precedence — passing `--domain` overrides the hub value. +The hub credentials are passed to the controller via environment variables +(`FELHOM_SETUP_CUSTOMER_ID`, `FELHOM_SETUP_PASSWORD`) so the setup wizard auto-fills +them. On first access, the wizard offers three choices: + +1. **Restore from Hub** — downloads infra backup (settings, encryption keys, restic + passwords, disk layout) and restores everything. Credentials are auto-processed. +2. **Restore from local drive** — scans connected drives for `.felhom-infra-backup/`. +3. **Fresh install** — downloads config only, starts with clean settings. + +``` +docker-setup.sh --hub-customer demo-felhom --hub-password xxx + → downloads config for infra vars (domain, CF tokens) + → generates minimal controller.yaml (no customer.id) + → passes hub credentials via env vars + → controller starts in setup mode + → user opens http://:8081 + → setup wizard: restore / local scan / fresh install +``` + On failure (wrong credentials, network error): - Script exits immediately with the HTTP status code and the failing URL - Nothing is installed diff --git a/scripts/docker-setup.sh b/scripts/docker-setup.sh index b149fea..24cd6b2 100644 --- a/scripts/docker-setup.sh +++ b/scripts/docker-setup.sh @@ -31,8 +31,8 @@ # --cf-token TOKEN Cloudflare API token for DNS-01 TLS # --cf-tunnel-token TK Cloudflare Tunnel token (optional) # --customer ID Customer identifier (optional, set in web wizard) -# --hub-customer ID Download config from Felhom Hub: customer ID -# --hub-password PW Download config from Felhom Hub: retrieval password +# --hub-customer ID Hub mode: pre-seed setup wizard with customer ID +# --hub-password PW Hub mode: retrieval password for setup wizard # --traefik-password PW Password for Traefik dashboard (default: auto-generated) # --self-signed-cert Generate self-signed wildcard certificate # --skip-filebrowser Skip FileBrowser installation @@ -219,8 +219,8 @@ OPTIONS: --bootstrap Install sudo (run first on fresh Debian) --domain DOMAIN Base domain for services (required) --customer ID Customer identifier (optional, set in web wizard) - --hub-customer ID Download config from Felhom Hub: customer ID - --hub-password PW Download config from Felhom Hub: retrieval password + --hub-customer ID Hub mode: pre-seed setup wizard with customer ID + --hub-password PW Hub mode: retrieval password for setup wizard --ip ADDRESS Static IP address --gateway ADDRESS Gateway (default: 192.168.0.1) --dns ADDRESS DNS servers, comma-separated (default: 1.1.1.1,8.8.8.8) @@ -274,7 +274,7 @@ EXAMPLES: --ip 192.168.0.50 --email certs@felhom.eu --cf-token cf-xxx \ --cf-tunnel-token eyJhIjoi... - # Hub mode — download pre-configured controller.yaml from Felhom Hub + # Hub mode — setup wizard with pre-seeded credentials (offers restore/fresh choice) sudo ./docker-setup.sh --hub-customer demo-felhom --hub-password EOF } @@ -1585,24 +1585,41 @@ generate_minimal_config() { mkdir -p "${CONTROLLER_DIR}" if [[ -n "$HUB_CUSTOMER" ]]; then - log_step "${step_num}/$(get_total_steps) - Installing controller.yaml from Felhom Hub..." + log_step "${step_num}/$(get_total_steps) - Generating minimal controller.yaml (setup wizard will handle full config)..." if [[ "$DRY_RUN" == true ]]; then - echo -e "${CYAN}[DRY-RUN]${NC} Would install hub controller.yaml to ${CONTROLLER_DIR}/controller.yaml" + echo -e "${CYAN}[DRY-RUN]${NC} Would generate minimal controller.yaml for setup wizard" + echo -e "${CYAN}[DRY-RUN]${NC} Hub credentials pre-seeded via env vars (customer: ${HUB_CUSTOMER})" return fi - # Config was already downloaded by apply_hub_config() early in main() + # Discard full hub config — we only needed it for infra vars (domain, CF tokens) if [[ -n "$HUB_CONFIG_TMP" && -f "$HUB_CONFIG_TMP" ]]; then - mv "${HUB_CONFIG_TMP}" "${CONTROLLER_DIR}/controller.yaml" + rm -f "${HUB_CONFIG_TMP}" HUB_CONFIG_TMP="" - else - log_error "Hub config temp file not found — apply_hub_config() may not have run" - exit 1 fi + # Generate minimal config WITHOUT customer.id — triggers setup wizard + # The setup wizard will download full config + offer infra backup restore + cat > "${CONTROLLER_DIR}/controller.yaml" << YAMLEOF +# Auto-generated by docker-setup.sh v${SCRIPT_VERSION} on $(date -u +"%Y-%m-%dT%H:%M:%SZ") +# Hub mode: full configuration via web setup wizard (credentials pre-seeded) +# Setup wizard: https://felhom.${BASE_DOMAIN} or http://:8081 + +customer: + domain: "${BASE_DOMAIN}" + +paths: + data_dir: "/opt/docker/felhom-controller/data" + stacks_dir: "/opt/docker/stacks" + +web: + listen: ":8080" + setup_listen: ":8081" +YAMLEOF + chmod 600 "${CONTROLLER_DIR}/controller.yaml" - log_success "controller.yaml installed from Felhom Hub (customer: ${HUB_CUSTOMER})" + log_success "Minimal controller.yaml generated (setup wizard will offer restore/fresh choice)" return fi @@ -1663,6 +1680,13 @@ install_controller() { ctrl_certresolver_label=' - "traefik.http.routers.controller.tls.certresolver=letsencrypt"' fi + # Hub mode: pass pre-seeded credentials to setup wizard via env vars + local hub_setup_env="" + if [[ -n "$HUB_CUSTOMER" ]]; then + hub_setup_env=" - FELHOM_SETUP_CUSTOMER_ID=${HUB_CUSTOMER} + - FELHOM_SETUP_PASSWORD=${HUB_PASSWORD}" + fi + cat > "${CONTROLLER_DIR}/docker-compose.yml" << EOF # Felhom Controller — Central management dashboard # Domain: felhom.${BASE_DOMAIN} @@ -1697,6 +1721,7 @@ services: environment: - TZ=Europe/Budapest - HOST_IP=$(get_server_ip) +${hub_setup_env} labels: - "traefik.enable=true" - "traefik.http.routers.controller.rule=Host(\`felhom.${BASE_DOMAIN}\`)" @@ -1763,7 +1788,7 @@ print_summary() { echo -e "${BOLD}Server IP:${NC} ${server_ip}" echo -e "${BOLD}Domain:${NC} *.${BASE_DOMAIN}" if [[ -n "$HUB_CUSTOMER" ]]; then - echo -e "${BOLD}Customer:${NC} ${HUB_CUSTOMER} (from Hub)" + echo -e "${BOLD}Customer:${NC} ${HUB_CUSTOMER} (Hub credentials pre-seeded for setup wizard)" elif [[ -n "$CUSTOMER_ID" ]]; then echo -e "${BOLD}Customer:${NC} ${CUSTOMER_ID}" fi @@ -1857,10 +1882,9 @@ main() { if [[ "$SELF_SIGNED_CERT" == true ]]; then echo " 5. Generate self-signed certificate" fi + echo " - Generate minimal controller.yaml" if [[ -n "$HUB_CUSTOMER" ]]; then - echo " - Download controller.yaml from Felhom Hub (customer: ${HUB_CUSTOMER})" - else - echo " - Generate minimal controller.yaml" + echo " (Hub mode: setup wizard pre-seeded for ${HUB_CUSTOMER})" fi echo " - Install Cloudflare Tunnel: $([[ -n "$CF_TUNNEL_TOKEN" ]] && echo "yes" || echo "skip")" echo " - Install FileBrowser: $([[ "$SKIP_FILEBROWSER" == true ]] && echo "skip" || echo "yes (auto-discover drives)")" @@ -1869,7 +1893,7 @@ main() { echo "" echo " Domain: *.${BASE_DOMAIN}" if [[ -n "$HUB_CUSTOMER" ]]; then - echo " Hub customer: ${HUB_CUSTOMER} (config downloaded from Hub)" + echo " Hub customer: ${HUB_CUSTOMER} (setup wizard will offer restore/fresh choice)" else echo " Customer: ${CUSTOMER_ID:-}" fi