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 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 13:58:31 +01:00
parent 5f423b6510
commit 1e8a562bd3
6 changed files with 272 additions and 31 deletions
+11
View File
@@ -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
+28 -6
View File
@@ -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://<LAN-IP>: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
+143
View File
@@ -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{
@@ -133,6 +153,8 @@ func (s *Server) handleWelcome(w http.ResponseWriter, r *http.Request) {
"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) {
@@ -22,6 +22,26 @@
</div>
{{end}}
{{if .HubMode}}
<div class="alert alert-info" style="margin-bottom: 1.5rem;">
Hub-mód: <strong>{{.HubCustomerID}}</strong> &mdash; az azonosító és jelszó automatikusan betöltődik.
</div>
<a href="/setup/hub-restore" class="setup-card" style="display: block; text-decoration: none; color: inherit; border-color: var(--accent-blue, #0088cc);">
<h3>Visszaállítás a Hub-ról</h3>
<p>A Hub-on tárolt infrastruktúra mentés visszaállítása (beállítások, titkosítási kulcsok, lemez-kiosztás).</p>
</a>
<a href="/setup/scan" class="setup-card" style="display: block; text-decoration: none; color: inherit;">
<h3>Visszaállítás helyi meghajtóról</h3>
<p>Csatlakoztatott meghajtók keresése infrastruktúra mentésért.</p>
</a>
<a href="/setup/fresh" class="setup-card" style="display: block; text-decoration: none; color: inherit;">
<h3>Friss telepítés</h3>
<p>Új konfiguráció letöltése a Hub-ról. Korábbi beállítások nem állítódnak vissza.</p>
</a>
{{else}}
<a href="/setup/scan" class="setup-card" style="display: block; text-decoration: none; color: inherit;">
<h3>Visszaállítás mentésből</h3>
<p>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.</p>
@@ -31,6 +51,7 @@
<h3>Új telepítés</h3>
<p>Új ügyfél beállítása. Konfiguráció letöltése a Hub-ról vagy kézi beállítás.</p>
</a>
{{end}}
</div>
</body>
</html>
+24 -4
View File
@@ -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://<ip>: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
+42 -18
View File
@@ -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 <retrieval-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://<ip>: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
if [[ -n "$HUB_CUSTOMER" ]]; then
echo " - Download controller.yaml from Felhom Hub (customer: ${HUB_CUSTOMER})"
else
echo " - Generate minimal controller.yaml"
if [[ -n "$HUB_CUSTOMER" ]]; then
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:-<none — will be set in web setup wizard>}"
fi