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:
+24
-104
@@ -112,7 +112,7 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated
|
||||
component; state chips are `.tag`; informational pills are `.metarow`. **Fonts (Plus Jakarta Sans +
|
||||
JetBrains Mono, variable woff2, latin+latin-ext) and a 30-icon Lucide sprite are vendored in the
|
||||
binary** (`internal/web/static/fonts/` served at `/static/fonts/`; `templates/icons.html`) — no CDN,
|
||||
no emoji. The setup wizard serves the same embedded stylesheet via `web.StyleCSS()`.
|
||||
no emoji.
|
||||
- **Guest RAM resize (v0.143.0, R-24; MinAgent 0.90.0)** — the Rendszer page's "Szerver memória (RAM)"
|
||||
card shows the guest's current/used memory + the allowed range and lets the customer resize it. The
|
||||
controller only proxies + maps the agent's machine `code` to Hungarian; **the agent (felhom-agent
|
||||
@@ -3364,77 +3364,18 @@ The hub service (separate Go app in the `felhom.eu` repo) provides:
|
||||
- Color coding: green (<30min), yellow (30-60min), red (>60min since last report)
|
||||
- 90-day report + event retention with daily prune at 04:30 Budapest time
|
||||
|
||||
### 10. First-Run Setup Wizard
|
||||
### 10. Not configured yet — the waiting page (the setup wizard is deleted, v0.308.0, R-554)
|
||||
|
||||
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 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`)
|
||||
|
||||
#### Wizard Flow
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ 1. Welcome │
|
||||
│ Choose: Restore / Fresh install │
|
||||
└─────────┬───────────┬────────────┘
|
||||
│ │
|
||||
┌─────▼─────┐ ┌──▼───────────────┐
|
||||
│ 2a. Scan │ │ 2b. Hub download │
|
||||
│ drives for│ │ (customer ID + │
|
||||
│ local │ │ password) │
|
||||
│ backups │ │ │
|
||||
└─────┬─────┘ └──────┬────────────┘
|
||||
│ │
|
||||
┌─────▼─────┐ │
|
||||
│ 2a.2 Hub │ │
|
||||
│ recovery │ │
|
||||
│ (fallback)│ │
|
||||
└─────┬─────┘ │
|
||||
│ │
|
||||
┌─────▼─────┐ ┌──────▼───────────┐
|
||||
│ Execute │ │ Execute fresh │
|
||||
│ restore │ │ install │
|
||||
└─────┬─────┘ └──────┬───────────┘
|
||||
│ │
|
||||
└───────┬───────┘
|
||||
▼
|
||||
os.Exit(0) → Docker restarts
|
||||
→ 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 |
|
||||
|------|---------|
|
||||
| `setup/setup.go` | `NeedsSetup()` detection, `SetupState` persistence to `setup-state.json` |
|
||||
| `setup/handlers.go` | HTTP handlers for each wizard step (welcome, scan, hub-restore, fresh, manual) |
|
||||
| `setup/scanner.go` | Scans all block devices for `.felhom-infra-backup/` directories (current + `history/`) via `lsblk` + temp mounts; returns rich info (app names, disk count) |
|
||||
| `setup/hub.go` | Hub recovery pull (`GET /api/v1/recovery/{id}`) and config download |
|
||||
| `setup/csrf.go` | Lightweight CSRF protection (cookie + hidden field, `SameSite=Strict`) |
|
||||
| `setup/network.go` | Detects local IPs for LAN access URL display |
|
||||
| `setup/templates/` | 8 embedded HTML templates (Hungarian, dark theme matching main UI) — includes `setup_hub_versions.html` for Hub backup version picker |
|
||||
The host agent provisions every box: at first boot the controller ingests its configuration from the hub
|
||||
(`internal/bootstrap`, `MaybeIngest`). The first-boot **setup wizard** (`internal/setup/`, the docker-setup.sh era,
|
||||
port 8081) was obsolete by design (`02-controller-module-map.md`) and is **deleted** (`09` §3 decision 222). A box whose
|
||||
ingestion failed (no or bad `bootstrap.json`, a hub it cannot reach, a failed write) — `bootstrap.Unconfigured(cfg)`,
|
||||
i.e. `customer.id` empty — now serves ONE page on its listener, in both languages („A Felhom még nem érte el a
|
||||
központot" / "Felhom has not reached the central service yet"), answers `/api/health` with
|
||||
`waiting_for_hub: true`, and retries the ingestion every minute (`bootstrap.WaitForConfig`). Once configured it closes
|
||||
the page and starts normally on the same listener. The `.needs-setup` marker and the debug page's „DR / Telepítő
|
||||
varázsló" trigger are gone; `web.setup_listen` is still read so an old `controller.yaml` loads, and is unused.
|
||||
Pinned by `TestWaitForConfig_ServesWaitingPageThenStarts`.
|
||||
|
||||
#### Local Infra Backup (`internal/backup/local_infra.go`)
|
||||
|
||||
@@ -3445,11 +3386,15 @@ The controller writes infrastructure snapshots to **every connected drive** afte
|
||||
- `{timestamp}-backup.json` + `{timestamp}-metadata.json` pairs (timestamp format: `20060102T150405Z`)
|
||||
- Oldest entries pruned when count exceeds 5
|
||||
|
||||
During setup wizard drive scan, both current and historical backups are discovered, integrity-verified, and offered for one-click restore. The scan results table shows app names/count, disk count, and a "korábbi" badge for historical versions.
|
||||
(The setup wizard's drive scan that offered these for one-click restore is deleted, v0.308.0, R-554.)
|
||||
|
||||
#### Recovery Info (`internal/recovery/info.go`)
|
||||
|
||||
Generates `recovery-info.txt` on the system data partition with customer ID, Hub URL, retrieval password, and recovery instructions in Hungarian. Updated on startup and after config changes. Also displayed on the Settings page in a "Vészhelyzeti információk" section.
|
||||
Generates `recovery-info.txt` (0600) in the data directory at every start when a retrieval password is set: customer
|
||||
ID, Hub URL, retrieval password, support contacts, and — since v0.308.0 (R-554) — what to do: the dashboard's
|
||||
**Biztonsági mentés → Visszaállítás** page, and after a reinstall the **Adatok visszaszerzése** page with the
|
||||
household's recovery code (ten words). Written in the box's language from the bundle key `recovery_info.body`; it no
|
||||
longer points at the deleted setup wizard. Pinned by `TestRecoveryInfo_PointsAtDashboardNotWizard`.
|
||||
|
||||
**No secret is rendered into a page's response body (v0.207.0 + v0.208.0, R-249/R-254).** Three endpoints implement one rule — the page carries a BOOLEAN, the value comes from an explicit authenticated POST with `Cache-Control: no-store`, and the reveal is LOGGED (reading a value off markup left no trace at all):
|
||||
|
||||
@@ -3465,29 +3410,11 @@ They are deliberately **per-secret**, not one generic "reveal any named secret"
|
||||
|
||||
### 11. Disaster Recovery
|
||||
|
||||
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
|
||||
- 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. 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 version picker if multiple versions
|
||||
Local restore: scans all drives for .felhom-infra-backup/ directories (current + history/)
|
||||
6. User selects backup version → 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
|
||||
10. User confirms → sequential restore: rsync first, restic fallback, DB import
|
||||
```
|
||||
|
||||
**Backup sources (priority order):**
|
||||
1. **Local infra backup** (`.felhom-infra-backup/` on surviving drives) — fastest, no network needed
|
||||
2. **Hub recovery endpoint** (`GET /api/v1/recovery/{id}`) — requires retrieval password, supports `?version=ID` for specific versions; Hub retains ~14 versions via GFS pruning (7 daily / 4 weekly / 3 monthly)
|
||||
3. **Manual config** (wizard form) — enter all details manually as last resort
|
||||
Whole-guest recovery is the host agent's and the hub's (PBS restore, provisioning a replacement guest); the controller
|
||||
has no recovery wizard any more (R-554, v0.308.0). On a box that is running, an app is restored on the dashboard's
|
||||
**Biztonsági mentés → Visszaállítás** page. After a reinstall the earlier off-site backups are reopened on the
|
||||
**Adatok visszaszerzése** page (`/recovery`) with the household's recovery code. A box that cannot reach the hub at its
|
||||
first boot waits on the page described in §10.
|
||||
|
||||
**Hub verification:** After setup, the controller periodically verifies customer standing via the Hub report push response (`customer_blocked` field). If blocked or Hub unreachable for >7 days, the controller enters limited mode (no new deployments).
|
||||
|
||||
@@ -4259,13 +4186,6 @@ controller/
|
||||
│ │ ├── builder.go # Hub report builder (all subsystems → JSON)
|
||||
│ │ ├── pusher.go # HTTP POST to hub (retry, Bearer auth, parses customer_blocked)
|
||||
│ │ └── infra_pull.go # DR: pull recovery/config from Hub (retrieval password auth)
|
||||
│ ├── setup/ # First-run setup wizard (web-based, replaces docker-setup.sh wizard)
|
||||
│ │ ├── setup.go # NeedsSetup() detection, state persistence
|
||||
│ │ ├── handlers.go # HTTP handlers for all wizard steps
|
||||
│ │ ├── scanner.go # Drive scanner for local infra backups
|
||||
│ │ ├── csrf.go # Lightweight CSRF (cookie + hidden field)
|
||||
│ │ ├── network.go # Local IP detection for LAN access URLs
|
||||
│ │ └── templates/ # 7 wizard HTML templates (Hungarian)
|
||||
│ ├── recovery/info.go # Recovery info file generator (recovery-info.txt)
|
||||
│ └── web/
|
||||
│ ├── server.go # HTTP server, routing, static files, catch-all middleware, executeTemplate wrapper
|
||||
@@ -4363,7 +4283,7 @@ monitoring:
|
||||
|
||||
web:
|
||||
listen: ":8080"
|
||||
setup_listen: ":8081" # Plain HTTP for setup wizard LAN access
|
||||
setup_listen: ":8081" # UNUSED since v0.308.0 (the setup wizard is deleted, R-554)
|
||||
|
||||
hub:
|
||||
enabled: true
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// uses the passphrase to PULL its full controller.yaml from the hub (which mints the customer-scoped
|
||||
// hub api_key + identity + assets + backup + CF config), MERGES in the per-guest local_api block (the
|
||||
// only thing the hub yaml lacks, because the hub must not know per-guest Proxmox internals), writes
|
||||
// it, and comes up CONFIGURED — skipping the setup wizard.
|
||||
// it, and comes up CONFIGURED (an unconfigured box waits and retries — the setup wizard is deleted, R-554).
|
||||
//
|
||||
// This replaces the old "seed a configured yaml from the agent's HOST key" path, which made the
|
||||
// controller's hub reports 401 (the hub's /report needs the customer-scoped key, not the host key).
|
||||
@@ -33,7 +33,7 @@ const DefaultMountPath = "/etc/felhom-bootstrap/bootstrap.json"
|
||||
// SchemaV2 is the stable contract version the agent emits and the controller ingests. v2 changed the
|
||||
// contract's MEANING (the controller pulls its config from the hub rather than seeding it from the
|
||||
// agent's host key) and its field set, so it is a clean version bump with no v1 back-compat
|
||||
// (pre-launch; zero v1 guests deployed). A non-v2 schema is rejected → setup mode.
|
||||
// (pre-launch; zero v1 guests deployed). A non-v2 schema is rejected → the box waits (R-554).
|
||||
const SchemaV2 = "felhom.bootstrap/v2"
|
||||
|
||||
// ErrPullTransient marks a pull failure as retryable (a boot-time network race reaching the hub).
|
||||
@@ -95,7 +95,7 @@ func Path() string {
|
||||
// - Idempotent: if cfg is already configured (customer.id set), the existing controller.yaml is
|
||||
// NEVER clobbered and the hub is NEVER pulled — returns cfg unchanged.
|
||||
// - Fail-safe: an absent/malformed bootstrap, a non-v2 schema, a missing required field, or a hub
|
||||
// pull that ultimately fails leaves cfg unchanged (the caller proceeds to setup mode). It logs
|
||||
// pull that ultimately fails leaves cfg unchanged (the caller serves the waiting page and retries, R-554). It logs
|
||||
// and NEVER crashes — a hub outage at first boot must not brick the guest.
|
||||
// - On success: writes controller.yaml (0600, atomic), reloads it, and returns the reloaded cfg.
|
||||
func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull PullFunc) *config.Config {
|
||||
@@ -111,37 +111,37 @@ func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull
|
||||
data, err := os.ReadFile(bpath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
logger.Printf("[WARN] bootstrap: cannot read %s: %v — staying in setup", bpath, err)
|
||||
logger.Printf("[WARN] bootstrap: cannot read %s: %v — waiting for the configuration", bpath, err)
|
||||
}
|
||||
return cfg // no bootstrap → normal setup
|
||||
return cfg // no bootstrap → the caller waits (WaitForConfig)
|
||||
}
|
||||
|
||||
var b Bootstrap
|
||||
if err := json.Unmarshal(data, &b); err != nil {
|
||||
logger.Printf("[WARN] bootstrap: %s is not valid JSON: %v — staying in setup", bpath, err)
|
||||
logger.Printf("[WARN] bootstrap: %s is not valid JSON: %v — waiting for the configuration", bpath, err)
|
||||
return cfg
|
||||
}
|
||||
if b.Schema != SchemaV2 {
|
||||
logger.Printf("[WARN] bootstrap: unsupported schema %q (want %q) — staying in setup", b.Schema, SchemaV2)
|
||||
logger.Printf("[WARN] bootstrap: unsupported schema %q (want %q) — waiting for the configuration", b.Schema, SchemaV2)
|
||||
return cfg
|
||||
}
|
||||
if b.Customer.ID == "" || b.Hub.URL == "" || b.Hub.RetrievalPassword == "" {
|
||||
logger.Printf("[WARN] bootstrap: %s missing customer.id / hub.url / hub.retrieval_password — staying in setup", bpath)
|
||||
logger.Printf("[WARN] bootstrap: %s missing customer.id / hub.url / hub.retrieval_password — waiting for the configuration", bpath)
|
||||
return cfg
|
||||
}
|
||||
if b.LocalAPI.Endpoint == "" || b.LocalAPI.Fingerprint == "" || b.LocalAPI.Token == "" {
|
||||
logger.Printf("[WARN] bootstrap: %s missing local_api.{endpoint,fingerprint,token} — staying in setup", bpath)
|
||||
logger.Printf("[WARN] bootstrap: %s missing local_api.{endpoint,fingerprint,token} — waiting for the configuration", bpath)
|
||||
return cfg
|
||||
}
|
||||
if pull == nil {
|
||||
logger.Printf("[WARN] bootstrap: no pull function wired — staying in setup")
|
||||
logger.Printf("[WARN] bootstrap: no pull function wired — waiting for the configuration")
|
||||
return cfg
|
||||
}
|
||||
|
||||
// --- Pull the full controller.yaml from the hub, with bounded retry on transient errors only. ---
|
||||
pulled, err := pullWithRetry(pull, b.Hub.URL, b.Customer.ID, b.Hub.RetrievalPassword, logger)
|
||||
if err != nil {
|
||||
logger.Printf("[WARN] bootstrap: hub config pull failed for customer %s from %s: %v — staying in setup (manual setup wizard remains the fallback)",
|
||||
logger.Printf("[WARN] bootstrap: hub config pull failed for customer %s from %s: %v — waiting for the configuration (the waiting page retries every minute)",
|
||||
b.Customer.ID, b.Hub.URL, err)
|
||||
return cfg
|
||||
}
|
||||
@@ -150,16 +150,16 @@ func MaybeIngest(configPath string, cfg *config.Config, logger *log.Logger, pull
|
||||
// every field the hub emits is preserved (forward-compat with hub template changes). ---
|
||||
merged, err := mergeLocalAPI(pulled, b.LocalAPI)
|
||||
if err != nil {
|
||||
logger.Printf("[WARN] bootstrap: merging local_api into pulled config failed: %v — staying in setup", err)
|
||||
logger.Printf("[WARN] bootstrap: merging local_api into pulled config failed: %v — waiting for the configuration", err)
|
||||
return cfg
|
||||
}
|
||||
if err := writeFileAtomic(configPath, merged); err != nil {
|
||||
logger.Printf("[WARN] bootstrap: could not write %s: %v — staying in setup", configPath, err)
|
||||
logger.Printf("[WARN] bootstrap: could not write %s: %v — waiting for the configuration", configPath, err)
|
||||
return cfg
|
||||
}
|
||||
reloaded, err := config.LoadPermissive(configPath)
|
||||
if err != nil {
|
||||
logger.Printf("[WARN] bootstrap: wrote %s but reload failed: %v — staying in setup", configPath, err)
|
||||
logger.Printf("[WARN] bootstrap: wrote %s but reload failed: %v — waiting for the configuration", configPath, err)
|
||||
return cfg
|
||||
}
|
||||
logger.Printf("[INFO] bootstrap: pulled config from %s for %s, merged local_api (%s) — coming up configured",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// ── Not configured yet: wait for the hub (R-554, v0.308.0, `09` §3 decision 222) ───────────────
|
||||
//
|
||||
// Until v0.308.0 a box whose first boot could not ingest its configuration (no or bad bootstrap.json, a
|
||||
// hub it could not reach, a failed write) entered the first-boot SETUP WIZARD — eight pages built for the
|
||||
// retired docker-setup.sh install. The agent provisions every box now; the wizard is deleted. Such a box
|
||||
// shows ONE page instead — „Felhom has not reached the central service yet", in both languages — and
|
||||
// retries the ingestion every minute. When it succeeds the page goes away and the controller starts
|
||||
// normally on the same listener. Pinned by TestWaitForConfig_ServesWaitingPageThenStarts.
|
||||
|
||||
// WaitRetryInterval is how often an unconfigured box retries the ingestion.
|
||||
const WaitRetryInterval = time.Minute
|
||||
|
||||
// Unconfigured reports whether this controller has no customer id — the only state that waits.
|
||||
func Unconfigured(cfg *config.Config) bool {
|
||||
return cfg == nil || cfg.Customer.ID == ""
|
||||
}
|
||||
|
||||
// WaitingHandler serves the waiting page on every path, and a health answer on /api/health (the
|
||||
// container's health check must keep passing while the box waits — it is not broken, it is early).
|
||||
func WaitingHandler(version string) http.Handler {
|
||||
page := waitingPage()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/health" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": true, "message": "felhom-controller is waiting for its configuration",
|
||||
"waiting_for_hub": true, "setup_mode": true, "version": version,
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(page))
|
||||
})
|
||||
}
|
||||
|
||||
// waitingPage is the page in BOTH languages: an unconfigured box has no household setting to choose by.
|
||||
func waitingPage() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("<!DOCTYPE html>\n<html lang=\"hu\">\n<head>\n<meta charset=\"UTF-8\">\n")
|
||||
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n")
|
||||
b.WriteString("<meta http-equiv=\"refresh\" content=\"60\">\n<title>Felhom</title>\n")
|
||||
b.WriteString("<style>body{font-family:system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem;line-height:1.5}section{margin-bottom:2rem}</style>\n")
|
||||
b.WriteString("</head>\n<body>\n")
|
||||
bundle, err := i18n.Shared()
|
||||
for _, lang := range i18n.Supported {
|
||||
title, body := "Felhom", ""
|
||||
if err == nil {
|
||||
title, body = bundle.Msg(lang, "waiting.title"), bundle.Msg(lang, "waiting.body")
|
||||
}
|
||||
b.WriteString("<section lang=\"" + lang + "\">\n<h1>" + html.EscapeString(title) + "</h1>\n<p>" + html.EscapeString(body) + "</p>\n</section>\n")
|
||||
}
|
||||
b.WriteString("</body>\n</html>\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// WaitForConfig serves the waiting page on ln and retries MaybeIngest every `every` until the box is
|
||||
// configured, then closes the page and returns the configured config. It returns nil when ctx ends first
|
||||
// (the process is stopping). ln may be nil (the port could not be bound): the retries still run.
|
||||
func WaitForConfig(ctx context.Context, ln net.Listener, configPath string, cfg *config.Config, logger *log.Logger, pull PullFunc, every time.Duration, version string) *config.Config {
|
||||
var srv *http.Server
|
||||
if ln != nil {
|
||||
srv = &http.Server{Handler: WaitingHandler(version), ReadTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second}
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
|
||||
logger.Printf("[ERROR] bootstrap: waiting page server: %v", err)
|
||||
}
|
||||
}()
|
||||
logger.Printf("[INFO] bootstrap: not configured yet — serving the waiting page on %s, retrying every %s", ln.Addr(), every)
|
||||
}
|
||||
stop := func() {
|
||||
if srv == nil {
|
||||
return
|
||||
}
|
||||
sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(sctx)
|
||||
}
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for attempt := 1; ; attempt++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
stop()
|
||||
return nil
|
||||
case <-t.C:
|
||||
}
|
||||
next := MaybeIngest(configPath, cfg, logger, pull)
|
||||
if !Unconfigured(next) {
|
||||
logger.Printf("[INFO] bootstrap: configured after %d retries — closing the waiting page and starting", attempt)
|
||||
stop()
|
||||
return next
|
||||
}
|
||||
cfg = next
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// R-554 (`09` §3 decision 222): the setup wizard is deleted. A box whose first ingestion FAILED serves the
|
||||
// waiting page — in both languages, on every path, with a passing health answer — and not a 404 or a
|
||||
// wizard; it retries, and once the hub answers it returns the configured config and closes the page.
|
||||
//
|
||||
// RED-PROOF (REPORT): make WaitingHandler answer http.NotFound for "/" → "GET / = 404"; make the loop
|
||||
// return the unchanged cfg on the first tick → "the box did not come up configured".
|
||||
func TestWaitForConfig_ServesWaitingPageThenStarts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, cfgPath := writeBootstrap(t, dir, goodBootstrapV2)
|
||||
|
||||
var calls atomic.Int32
|
||||
pull := func(_, _, _ string) (string, error) {
|
||||
if calls.Add(1) <= 5 {
|
||||
return "", errors.New("hub answered 503") // permanent: no in-call retry, the loop retries
|
||||
}
|
||||
return hubYAML, nil
|
||||
}
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := "http://" + ln.Addr().String()
|
||||
|
||||
// The first ingestion failed: the caller holds an unconfigured config.
|
||||
start := MaybeIngest(cfgPath, config.Default(), testLogger(), pull)
|
||||
if !Unconfigured(start) {
|
||||
t.Fatal("setup: the first ingestion should have failed")
|
||||
}
|
||||
|
||||
done := make(chan *config.Config, 1)
|
||||
go func() {
|
||||
done <- WaitForConfig(context.Background(), ln, cfgPath, start, testLogger(), pull, 150*time.Millisecond, "0.308.0")
|
||||
}()
|
||||
|
||||
get := func(path string) (int, string) {
|
||||
t.Helper()
|
||||
resp, err := http.Get(addr + path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return resp.StatusCode, string(b)
|
||||
}
|
||||
code, body := get("/")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("GET / = %d — an unconfigured box must show the waiting page, not a 404", code)
|
||||
}
|
||||
for _, want := range []string{`lang="hu"`, "nem érte el a központot", `lang="en"`, "has not reached the central service yet", "support@felhom.eu"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the waiting page does not carry %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(strings.ToLower(body), "varázsló") || strings.Contains(body, "8081") {
|
||||
t.Error("the waiting page still points at the setup wizard")
|
||||
}
|
||||
if code, _ := get("/backups/restore"); code != http.StatusOK {
|
||||
t.Errorf("GET /backups/restore = %d — every path shows the waiting page", code)
|
||||
}
|
||||
if code, body := get("/api/health"); code != http.StatusOK || !strings.Contains(body, `"waiting_for_hub":true`) {
|
||||
t.Errorf("health while waiting: %d %s", code, body)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-done:
|
||||
if got == nil || Unconfigured(got) || got.Customer.ID != "cust-8200" {
|
||||
t.Fatalf("the box did not come up configured: %+v", got)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("WaitForConfig never returned although the hub answered")
|
||||
}
|
||||
if _, err := http.Get(addr + "/"); err == nil {
|
||||
t.Error("the waiting page is still served after the box was configured")
|
||||
}
|
||||
}
|
||||
|
||||
// A stop signal while waiting returns nil (the process exits); it never pretends to be configured.
|
||||
func TestWaitForConfig_StopReturnsNil(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if got := WaitForConfig(ctx, nil, "", config.Default(), testLogger(), nil, time.Hour, "x"); got != nil {
|
||||
t.Fatalf("a stopped wait returned a config: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -168,7 +168,7 @@ type PathsConfig struct {
|
||||
|
||||
type WebConfig struct {
|
||||
Listen string `yaml:"listen"`
|
||||
SetupListen string `yaml:"setup_listen"` // Plain HTTP listener for setup wizard (only active during setup mode)
|
||||
SetupListen string `yaml:"setup_listen"` // UNUSED since v0.308.0 (R-554: the setup wizard is deleted); kept so an existing controller.yaml still loads
|
||||
PasswordHash string `yaml:"password_hash"`
|
||||
SessionSecret string `yaml:"session_secret"`
|
||||
|
||||
|
||||
@@ -928,7 +928,6 @@
|
||||
"debug.bejegyzes": "entries",
|
||||
"debug.betoltes": "Loading...",
|
||||
"debug.cimke": "Label",
|
||||
"debug.controller_ujraindulasa": "Controller restarting...",
|
||||
"debug.cpu_atlag": "CPU (avg)",
|
||||
"debug.csak_cross_drive": "Cross-drive only",
|
||||
"debug.csak_db_dump": "DB dump only",
|
||||
@@ -938,7 +937,6 @@
|
||||
"debug.db": "items",
|
||||
"debug.debug_mod": "Debug mode",
|
||||
"debug.debug_mod_aktiv_ez_az": "Debug mode on — this page is only for development and troubleshooting",
|
||||
"debug.dr_telepito_varazslo": "DR / Setup wizard",
|
||||
"debug.dry_run_frissites": "Dry-run update",
|
||||
"debug.elavult_temp_fajlok": "Stale temp files",
|
||||
"debug.eleres": "Path",
|
||||
@@ -947,8 +945,6 @@
|
||||
"debug.ertesites_teszt": "Notification test",
|
||||
"debug.esemeny_tipus": "Event type:",
|
||||
"debug.export_konyvtarak": "Export folders",
|
||||
"debug.ez_torli_az_ugyfel_azonositot": "This deletes the customer ID and restarts the controller in setup mode. The configuration files stay on the drives.",
|
||||
"debug.figyelem_ez_ujrainditja_a_controllert": "WARNING! This restarts the controller in setup mode. Continue?",
|
||||
"debug.figyelmeztetesek": "Warnings",
|
||||
"debug.folyamatban": "In progress...",
|
||||
"debug.frissites": "Refresh",
|
||||
@@ -972,8 +968,6 @@
|
||||
"debug.igen": "Yes",
|
||||
"debug.igen_2": "yes",
|
||||
"debug.inaktiv": "Inactive",
|
||||
"debug.irja_be_reset": "Type: RESET",
|
||||
"debug.irja_be_reset_a_megerositeshez": "Type \"RESET\" to confirm:",
|
||||
"debug.jelenlegi": "Current:",
|
||||
"debug.katalogus_limit": "Catalog limit",
|
||||
"debug.kattintson_a_gombra_a_telemetria": "Click the button to run telemetry.",
|
||||
@@ -1028,7 +1022,6 @@
|
||||
"debug.titkositott": "encrypted",
|
||||
"debug.torles": "Clear",
|
||||
"debug.ugynok": "Agent",
|
||||
"debug.ujrainditas_setup_modban": "Restart in setup mode",
|
||||
"debug.utemezo": "Scheduler",
|
||||
"debug.utolso_db_dump": "Last DB dump:",
|
||||
"debug.utolso_esemenyek": "Recent events",
|
||||
@@ -1039,7 +1032,6 @@
|
||||
"debug.utvonal": "Path",
|
||||
"debug.varakozik": "Waiting",
|
||||
"debug.verzio": "Version",
|
||||
"debug.veszhelyzet_szimulacio": "Emergency simulation",
|
||||
"debug.vezerlo": "Controller",
|
||||
"debug.api.invalid_request": "Invalid request",
|
||||
"debug.api.notifier_unconfigured": "Notifier is not configured",
|
||||
@@ -1063,9 +1055,6 @@
|
||||
"debug.api.gitea_reachable": "Gitea reachable (HTTP %d, %dms)",
|
||||
"debug.api.telemetry_collected": "Telemetry collected: apps %d, errors %d, warnings %d (%dms)",
|
||||
"debug.api.selfupdate_unconfigured": "Self-update is not configured",
|
||||
"debug.api.reset_confirm_invalid": "Invalid confirmation — type: RESET",
|
||||
"debug.api.marker_write_failed": "Marker file write error: %v",
|
||||
"debug.api.restarting_to_setup": "Restarting the controller into setup mode...",
|
||||
"debug.api.agent_unconfigured": "The agent is not configured on this system.",
|
||||
"debug.api.agent_logs_unsupported": "The agent’s log view becomes available after the agent’s next update.",
|
||||
"debug.api.bundles_found": "Bundles found: %d",
|
||||
@@ -1912,6 +1901,7 @@
|
||||
"recovery.ugyhogy_tartsd_keznel": "so keep it at hand.",
|
||||
"recovery.vissza_a_kezdolapra": "Back to the home page",
|
||||
"recovery.visszaszerzese": "your data",
|
||||
"recovery_info.body": "Felhom — Emergency information\n==============================\n\nCustomer ID: %s\nHub URL: %s\nRetrieval password: %s\n\nSupport:\n E-mail: %s\n Web: %s\n\nIf you need to restore something:\n 1. Open your dashboard in your browser: %s\n 2. Restore the data of an app on the Backup → Restore page:\n %s\n 3. If your machine was reinstalled, the dashboard offers to bring back\n your earlier off-site backups (Retrieve data: %s).\n For this you need your recovery code: the ten words you received\n at installation. Nobody can replace this code, so keep it in a\n safe place.\n 4. If you get stuck, write to us at the address above.\n\nGenerated: %s\nController version: %s\n",
|
||||
"restore.refuse.files.head": "This backup does not hold the files of the app, so we do not restore the database over them — the files stay where they are. ",
|
||||
"restore.refuse.files.none": "There is no copy of these files at the moment — turn on the remote backup, or connect a second drive.",
|
||||
"restore.refuse.files.offsite": "The files can be restored from the remote copy: Backup → Restore, “Full restore (files + database)”.",
|
||||
@@ -2661,5 +2651,7 @@
|
||||
"family_gate.pw_once": "The password shows only now. Write it down, or give it to the family member:",
|
||||
"family_gate.confirm_remove": "Remove them? Their sign-ins end at once.",
|
||||
"family_gate.confirm_reset": "Give a new password? The old one stops at once.",
|
||||
"family_gate.error": "That did not work. Try again."
|
||||
"family_gate.error": "That did not work. Try again.",
|
||||
"waiting.body": "This machine has just started for the first time, but it could not download its settings from the Felhom central service yet. It tries again every minute; once it succeeds, this page turns into your dashboard by itself. If you still see this in an hour, write to us: support@felhom.eu",
|
||||
"waiting.title": "Felhom has not reached the central service yet"
|
||||
}
|
||||
|
||||
@@ -923,7 +923,6 @@
|
||||
"debug.bejegyzes": "bejegyzés",
|
||||
"debug.betoltes": "Betöltés...",
|
||||
"debug.cimke": "Cimke",
|
||||
"debug.controller_ujraindulasa": "Controller újraindulása...",
|
||||
"debug.cpu_atlag": "CPU (átlag)",
|
||||
"debug.csak_cross_drive": "Csak cross-drive",
|
||||
"debug.csak_db_dump": "Csak DB dump",
|
||||
@@ -933,7 +932,6 @@
|
||||
"debug.db": "db",
|
||||
"debug.debug_mod": "Debug mód",
|
||||
"debug.debug_mod_aktiv_ez_az": "Debug mód aktív — ez az oldal csak fejlesztéshez és hibaelhárításhoz",
|
||||
"debug.dr_telepito_varazslo": "DR / Telepítő varázsló",
|
||||
"debug.dry_run_frissites": "Dry-run frissítés",
|
||||
"debug.elavult_temp_fajlok": "Elavult temp fájlok",
|
||||
"debug.eleres": "Elérés",
|
||||
@@ -942,8 +940,6 @@
|
||||
"debug.ertesites_teszt": "Értesítés teszt",
|
||||
"debug.esemeny_tipus": "Esemény típus:",
|
||||
"debug.export_konyvtarak": "Export könyvtárak",
|
||||
"debug.ez_torli_az_ugyfel_azonositot": "Ez törli az ügyfél azonosítót és újraindítja a controllert setup módba. A konfigurációs fájlok megmaradnak a meghajtókon.",
|
||||
"debug.figyelem_ez_ujrainditja_a_controllert": "FIGYELEM! Ez újraindítja a controllert setup módba. Biztosan folytatod?",
|
||||
"debug.figyelmeztetesek": "Figyelmeztetések",
|
||||
"debug.folyamatban": "Folyamatban...",
|
||||
"debug.frissites": "Frissítés",
|
||||
@@ -967,8 +963,6 @@
|
||||
"debug.igen": "Igen",
|
||||
"debug.igen_2": "igen",
|
||||
"debug.inaktiv": "Inaktív",
|
||||
"debug.irja_be_reset": "Írd be: RESET",
|
||||
"debug.irja_be_reset_a_megerositeshez": "Írd be \"RESET\" a megerősítéshez:",
|
||||
"debug.jelenlegi": "Jelenlegi:",
|
||||
"debug.katalogus_limit": "Katalógus limit",
|
||||
"debug.kattintson_a_gombra_a_telemetria": "Kattints a gombra a telemetria futtatásához.",
|
||||
@@ -1023,7 +1017,6 @@
|
||||
"debug.titkositott": "titkosított",
|
||||
"debug.torles": "Törlés",
|
||||
"debug.ugynok": "Ügynök",
|
||||
"debug.ujrainditas_setup_modban": "Újraindítás setup módban",
|
||||
"debug.utemezo": "Ütemező",
|
||||
"debug.utolso_db_dump": "Utolsó DB dump:",
|
||||
"debug.utolso_esemenyek": "Utolsó események",
|
||||
@@ -1034,7 +1027,6 @@
|
||||
"debug.utvonal": "Útvonal",
|
||||
"debug.varakozik": "Várakozik",
|
||||
"debug.verzio": "Verzió",
|
||||
"debug.veszhelyzet_szimulacio": "Vészhelyzet szimuláció",
|
||||
"debug.vezerlo": "Vezérlő",
|
||||
"debug.api.invalid_request": "Érvénytelen kérés",
|
||||
"debug.api.notifier_unconfigured": "Notifier nincs konfigurálva",
|
||||
@@ -1058,9 +1050,6 @@
|
||||
"debug.api.gitea_reachable": "Gitea elérhető (HTTP %d, %dms)",
|
||||
"debug.api.telemetry_collected": "Telemetria összegyűjtve: %d app, %d hiba, %d figyelmeztetés (%dms)",
|
||||
"debug.api.selfupdate_unconfigured": "Self-update nincs konfigurálva",
|
||||
"debug.api.reset_confirm_invalid": "Érvénytelen megerősítés — írd be: RESET",
|
||||
"debug.api.marker_write_failed": "Marker fájl írási hiba: %v",
|
||||
"debug.api.restarting_to_setup": "Controller újraindítása setup módba...",
|
||||
"debug.api.agent_unconfigured": "Az ügynök nincs konfigurálva ezen a rendszeren.",
|
||||
"debug.api.agent_logs_unsupported": "Az ügynök naplónézete az ügynök következő frissítése után érhető el.",
|
||||
"debug.api.bundles_found": "%d csomag található",
|
||||
@@ -1900,6 +1889,7 @@
|
||||
"recovery.ugyhogy_tartsd_keznel": "úgyhogy tartsd kéznél.",
|
||||
"recovery.vissza_a_kezdolapra": "Vissza a kezdőlapra",
|
||||
"recovery.visszaszerzese": "visszaszerzése",
|
||||
"recovery_info.body": "Felhom — Vészhelyzeti információk\n=================================\n\nÜgyfél azonosító: %s\nHub URL: %s\nVisszaállítási jelszó: %s\n\nTámogatás:\n E-mail: %s\n Web: %s\n\nHa vissza kell állítanod valamit:\n 1. Nyisd meg a vezérlőpultot a böngésződben: %s\n 2. Egy alkalmazás adatait a Biztonsági mentés → Visszaállítás oldalon\n állíthatod vissza: %s\n 3. Ha a gépedet újratelepítettük, a vezérlőpult felajánlja a korábbi,\n házon kívüli mentéseid visszaszerzését (Adatok visszaszerzése: %s).\n Ehhez a helyreállítási kódodra lesz szükséged: a tíz szóra, amelyet\n a telepítéskor kaptál. Ezt a kódot senki nem tudja pótolni, ezért\n őrizd meg biztonságos helyen.\n 4. Ha elakadsz, írj nekünk a fenti címre.\n\nGenerálva: %s\nController verzió: %s\n",
|
||||
"restore.refuse.files.head": "Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. ",
|
||||
"restore.refuse.files.none": "Ezekről a fájlokról jelenleg nincs másolat — kapcsold be a távoli mentést, vagy csatlakoztass egy második meghajtót.",
|
||||
"restore.refuse.files.offsite": "A fájlok a távoli másolatból állíthatók vissza: Biztonsági mentés → Visszaállítás, „Teljes visszaállítás (fájlok + adatbázis)”.",
|
||||
@@ -2649,5 +2639,7 @@
|
||||
"family_gate.pw_once": "A jelszó csak most látszik. Írd fel, vagy add át a családtagnak:",
|
||||
"family_gate.confirm_remove": "Eltávolítod? A belépései azonnal megszűnnek.",
|
||||
"family_gate.confirm_reset": "Új jelszót adsz? A régi azonnal megszűnik.",
|
||||
"family_gate.error": "Nem sikerült. Próbáld újra."
|
||||
"family_gate.error": "Nem sikerült. Próbáld újra.",
|
||||
"waiting.body": "Ez a gép most indult el először, de még nem tudta letölteni a beállításait a Felhom központi rendszeréből. Percenként újrapróbálkozik; amint sikerül, ez az oldal magától a vezérlőpultra vált. Ha egy óra múlva is ezt látod, írj nekünk: support@felhom.eu",
|
||||
"waiting.title": "A Felhom még nem érte el a központot"
|
||||
}
|
||||
|
||||
@@ -4,55 +4,64 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// Info holds the data needed for the recovery info file and settings UI.
|
||||
// Info holds the data needed for the recovery info file.
|
||||
type Info struct {
|
||||
CustomerID string
|
||||
RetrievalPassword string
|
||||
HubURL string
|
||||
SupportEmail string
|
||||
SupportURL string
|
||||
// DashboardURL is the household's dashboard, e.g. https://felhom.<domain> (R-554, v0.308.0): the file
|
||||
// points at its Restore page and its „Adatok visszaszerzése" page, not at the deleted setup wizard.
|
||||
DashboardURL string
|
||||
// Lang is the box's language; the file is written in it (Hungarian when empty or unknown).
|
||||
Lang string
|
||||
}
|
||||
|
||||
// GenerateRecoveryFile writes a plain text recovery-info.txt to the given directory.
|
||||
func GenerateRecoveryFile(info Info, version, outputDir string) error {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating recovery info directory: %w", err)
|
||||
// recoveryInfoKey is the file's text in the bundle (hu.json / en.json), with ten %s, in order:
|
||||
// customer id, hub URL, retrieval password, support e-mail, support web, dashboard, restore page,
|
||||
// recovery page, generated-at, controller version.
|
||||
const recoveryInfoKey = "recovery_info.body"
|
||||
|
||||
// Render returns the text of recovery-info.txt (R-554, `09` §3 decision 222): what to do when something must
|
||||
// be restored — the dashboard's Backup → Restore page, and after a reinstall the recovery code on the
|
||||
// „Adatok visszaszerzése" page. Pinned by TestRecoveryInfo_PointsAtDashboardNotWizard.
|
||||
func Render(info Info, version string, now time.Time) (string, error) {
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("loading message bundle: %w", err)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf(`Felhom Controller — Vészhelyzeti információk
|
||||
=============================================
|
||||
|
||||
Ügyfél azonosító: %s
|
||||
Hub URL: %s
|
||||
Visszaállítási jelszó: %s
|
||||
|
||||
Támogatás:
|
||||
Email: %s
|
||||
Web: %s
|
||||
|
||||
Visszaállítási útmutató:
|
||||
1. Telepítse az operációs rendszert (Debian 13)
|
||||
2. Futtassa a docker-setup.sh szkriptet:
|
||||
sudo ./docker-setup.sh --domain <domain>
|
||||
3. Nyissa meg a böngészőben: http://<ip>:8081
|
||||
4. Válassza a "Visszaállítás mentésből" opciót
|
||||
5. Adja meg az ügyfél azonosítót és a visszaállítási jelszót
|
||||
6. Kövesse a varázsló utasításait
|
||||
|
||||
Generálva: %s
|
||||
Controller verzió: %s
|
||||
`,
|
||||
dash := strings.TrimRight(info.DashboardURL, "/")
|
||||
return b.Msgf(i18n.Normalize(info.Lang), recoveryInfoKey,
|
||||
info.CustomerID,
|
||||
info.HubURL,
|
||||
info.RetrievalPassword,
|
||||
info.SupportEmail,
|
||||
info.SupportURL,
|
||||
time.Now().Format("2006-01-02 15:04:05"),
|
||||
dash,
|
||||
dash+"/backups/restore",
|
||||
dash+"/recovery",
|
||||
now.Format("2006-01-02 15:04:05"),
|
||||
version,
|
||||
)
|
||||
), nil
|
||||
}
|
||||
|
||||
// GenerateRecoveryFile writes a plain text recovery-info.txt to the given directory (0600: it holds the
|
||||
// retrieval password).
|
||||
func GenerateRecoveryFile(info Info, version, outputDir string) error {
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating recovery info directory: %w", err)
|
||||
}
|
||||
content, err := Render(info, version, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(outputDir, "recovery-info.txt")
|
||||
tmp := path + ".tmp"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-554 (`09` §3 decision 222): recovery-info.txt points at the dashboard's Restore page and the recovery
|
||||
// code — never at the deleted first-boot wizard (docker-setup.sh, :8081, „Visszaállítás mentésből").
|
||||
//
|
||||
// RED-PROOF (REPORT): the old text (docker-setup.sh … http://<ip>:8081 … varázsló) fails every
|
||||
// "must carry" line and every "must not carry" line.
|
||||
func TestRecoveryInfo_PointsAtDashboardNotWizard(t *testing.T) {
|
||||
info := Info{
|
||||
CustomerID: "c-123", RetrievalPassword: "TESTONLY-retrieval-pw", HubURL: "https://hub.example",
|
||||
SupportEmail: "support@felhom.eu", SupportURL: "https://felhom.eu/kapcsolat",
|
||||
DashboardURL: "https://felhom.example.hu/",
|
||||
}
|
||||
at := time.Date(2026, 10, 12, 15, 4, 5, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
lang string
|
||||
must []string
|
||||
}{
|
||||
{"hu", []string{"Biztonsági mentés → Visszaállítás", "helyreállítási kód", "Adatok visszaszerzése"}},
|
||||
{"en", []string{"Backup → Restore", "recovery code", "Retrieve data"}},
|
||||
} {
|
||||
info.Lang = tc.lang
|
||||
got, err := Render(info, "0.308.0", at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range append(tc.must,
|
||||
"https://felhom.example.hu/backups/restore", "https://felhom.example.hu/recovery",
|
||||
"c-123", "https://hub.example", "TESTONLY-retrieval-pw", "2026-10-12 15:04:05", "0.308.0") {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("%s: the file does not carry %q", tc.lang, want)
|
||||
}
|
||||
}
|
||||
for _, gone := range []string{"docker-setup.sh", ":8081", "Visszaállítás mentésből", "varázsló", "wizard", "%!"} {
|
||||
if strings.Contains(got, gone) {
|
||||
t.Errorf("%s: the file still carries %q", tc.lang, gone)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The file is written 0600 (it holds the retrieval password) and in the box's language.
|
||||
func TestGenerateRecoveryFile_WritesPrivateFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := GenerateRecoveryFile(Info{CustomerID: "c-1", DashboardURL: "https://felhom.x.hu", Lang: "en"}, "0.308.0", dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := filepath.Join(dir, "recovery-info.txt")
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0o600 {
|
||||
t.Errorf("mode = %v, want 0600", fi.Mode().Perm())
|
||||
}
|
||||
b, _ := os.ReadFile(p)
|
||||
if !strings.Contains(string(b), "Emergency information") {
|
||||
t.Errorf("an English box's file is not in English")
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const csrfCookieName = "felhom_csrf"
|
||||
const csrfFormField = "_csrf"
|
||||
|
||||
// generateCSRFToken creates a random 32-byte hex token.
|
||||
func generateCSRFToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic("crypto/rand.Read failed: " + err.Error())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// setCSRFCookie sets the CSRF cookie on the response.
|
||||
func setCSRFCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
HttpOnly: false, // JavaScript needs to read it for AJAX if needed
|
||||
})
|
||||
}
|
||||
|
||||
// validateCSRF checks that the form field matches the cookie.
|
||||
func validateCSRF(r *http.Request) bool {
|
||||
cookie, err := r.Cookie(csrfCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return false
|
||||
}
|
||||
formToken := r.FormValue(csrfFormField)
|
||||
if formToken == "" {
|
||||
return false
|
||||
}
|
||||
return cookie.Value == formToken
|
||||
}
|
||||
|
||||
// ensureCSRFToken returns the existing CSRF token from the cookie, or generates a new one.
|
||||
func ensureCSRFToken(w http.ResponseWriter, r *http.Request) string {
|
||||
if cookie, err := r.Cookie(csrfCookieName); err == nil && cookie.Value != "" {
|
||||
return cookie.Value
|
||||
}
|
||||
token := generateCSRFToken()
|
||||
setCSRFCookie(w, token)
|
||||
return token
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
|
||||
)
|
||||
|
||||
// TestSetupCSSServesEmbedded (Scenario E, TASK-D0): the setup wizard must serve
|
||||
// the web package's EMBEDDED stylesheet. The pre-fix handleCSS read a filesystem
|
||||
// path derived from dataDir, which does not exist inside the container image, so
|
||||
// production setup mode silently served the minimalCSS fallback.
|
||||
func TestSetupCSSServesEmbedded(t *testing.T) {
|
||||
// dataDir whose derived legacy path (internal/web/templates/style.css
|
||||
// relative to its grandparent) cannot exist.
|
||||
dataDir := filepath.Join(t.TempDir(), "nonexistent-nested", "data")
|
||||
logger := log.New(io.Discard, "", 0)
|
||||
s := NewServer(&config.Config{}, dataDir, logger, "test")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/static/style.css", nil)
|
||||
s.Handler().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.Bytes()
|
||||
if len(body) <= 10000 {
|
||||
t.Fatalf("body length = %d, want > 10000 (minimalCSS fallback served?)", len(body))
|
||||
}
|
||||
if !strings.Contains(string(body), "--bg-0: #0A1220") {
|
||||
t.Error("served CSS is missing the v2 token block (--bg-0)")
|
||||
}
|
||||
embedded, err := web.StyleCSS()
|
||||
if err != nil {
|
||||
t.Fatalf("web.StyleCSS() error: %v", err)
|
||||
}
|
||||
if !bytes.Equal(body, embedded) {
|
||||
t.Error("served CSS differs from the embedded web stylesheet")
|
||||
}
|
||||
if strings.Contains(string(body), ".setup-container") {
|
||||
t.Error("minimalCSS fallback was served instead of the embedded stylesheet")
|
||||
}
|
||||
}
|
||||
@@ -1,603 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
crand "crypto/rand"
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templateFS embed.FS
|
||||
|
||||
// Server handles the setup wizard HTTP routes.
|
||||
type Server struct {
|
||||
cfg *config.Config
|
||||
dataDir string
|
||||
logger *log.Logger
|
||||
tmpl *template.Template
|
||||
state *SetupState
|
||||
version string
|
||||
}
|
||||
|
||||
// NewServer creates a new setup wizard server.
|
||||
func NewServer(cfg *config.Config, dataDir string, logger *log.Logger, version string) *Server {
|
||||
s := &Server{
|
||||
cfg: cfg,
|
||||
dataDir: dataDir,
|
||||
logger: logger,
|
||||
state: LoadState(dataDir),
|
||||
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
|
||||
}
|
||||
|
||||
// isDebug returns true if logging level is "debug".
|
||||
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{
|
||||
"timeNow": func() string { return time.Now().Format("2006-01-02 15:04") },
|
||||
}).ParseFS(templateFS, "templates/*.html"),
|
||||
)
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler for the setup wizard.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Disk-recovery setup paths (drive scan, infra-backup restore) have moved to the
|
||||
// host agent (slice 8C). The wizard now offers fresh install (from Hub) + manual.
|
||||
mux.HandleFunc("/", s.handleRoot)
|
||||
mux.HandleFunc("/setup", s.handleWelcome)
|
||||
mux.HandleFunc("/setup/fresh", s.handleFreshHub)
|
||||
mux.HandleFunc("/setup/manual", s.handleManual)
|
||||
mux.HandleFunc("/setup/failed", s.handleFailed)
|
||||
mux.HandleFunc("/static/style.css", s.handleCSS)
|
||||
mux.HandleFunc("/static/felhom-logo.svg", s.handleLogo)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// --- Route Handlers ---
|
||||
|
||||
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/setup", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleWelcome(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
domain := s.cfg.Customer.Domain
|
||||
ips := DetectLocalIPs()
|
||||
|
||||
var accessURLs []string
|
||||
if domain != "" {
|
||||
accessURLs = append(accessURLs, fmt.Sprintf("https://felhom.%s", domain))
|
||||
}
|
||||
for _, ip := range ips {
|
||||
accessURLs = append(accessURLs, fmt.Sprintf("http://%s%s", ip, s.cfg.Web.SetupListen))
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"AccessURLs": accessURLs,
|
||||
"Version": s.version,
|
||||
"HubMode": s.isHubPreseeded(),
|
||||
"HubCustomerID": s.state.GetFormField("customer_id"),
|
||||
}
|
||||
s.render(w, "setup_welcome", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleFreshHub(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.processFreshHub(w, r)
|
||||
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"),
|
||||
}
|
||||
s.render(w, "setup_fresh_hub", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleManual(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !validateCSRF(r) {
|
||||
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
s.processManual(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"FormData": s.state.FormData,
|
||||
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
||||
}
|
||||
s.render(w, "setup_manual", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleFailed(w http.ResponseWriter, r *http.Request) {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
}
|
||||
s.render(w, "setup_failed", data)
|
||||
}
|
||||
|
||||
// --- Static Assets (reuse from web package embed) ---
|
||||
|
||||
func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) {
|
||||
// Serve the web package's embedded stylesheet. The previous implementation
|
||||
// read a filesystem path derived from dataDir, which does not exist inside
|
||||
// the container image — setup mode silently fell back to minimalCSS.
|
||||
data, err := web.StyleCSS()
|
||||
if err != nil {
|
||||
s.logger.Printf("[WARN] Setup: embedded style.css unavailable (%v) — serving minimal fallback CSS", err)
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Write([]byte(minimalCSS))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogo(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
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.
|
||||
// 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) processFreshHub(w http.ResponseWriter, r *http.Request) {
|
||||
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
||||
password := r.FormValue("password")
|
||||
hubURL := DefaultHubURL
|
||||
|
||||
s.state.SetFormField("customer_id", customerID)
|
||||
|
||||
if customerID == "" || password == "" {
|
||||
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):
|
||||
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))
|
||||
|
||||
// Write config and finish setup
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] Setup: writing fresh config (%d bytes)", len(configYAML))
|
||||
}
|
||||
s.state.SetFormField("retrieval_password", password)
|
||||
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()
|
||||
}
|
||||
|
||||
func (s *Server) processManual(w http.ResponseWriter, r *http.Request) {
|
||||
// Save all form fields
|
||||
fields := []string{"customer_id", "display_name", "domain", "email",
|
||||
"cf_tunnel_token", "cf_api_token", "system_data_path",
|
||||
"password", "password_confirm",
|
||||
"git_repo_url", "git_username", "git_token"}
|
||||
for _, f := range fields {
|
||||
s.state.SetFormField(f, r.FormValue(f))
|
||||
}
|
||||
|
||||
// Validate
|
||||
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
||||
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||
password := r.FormValue("password")
|
||||
passwordConfirm := r.FormValue("password_confirm")
|
||||
|
||||
var errs []string
|
||||
if customerID == "" {
|
||||
errs = append(errs, "Ügyfél-azonosító kötelező")
|
||||
}
|
||||
if domain == "" || domain == "homeserver.local" {
|
||||
errs = append(errs, "Érvényes domain szükséges")
|
||||
}
|
||||
if password != "" && len(password) < 8 {
|
||||
errs = append(errs, "A jelszó legalább 8 karakter legyen")
|
||||
}
|
||||
if password != passwordConfirm {
|
||||
errs = append(errs, "A jelszavak nem egyeznek")
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"FormData": s.state.FormData,
|
||||
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
||||
"Errors": errs,
|
||||
}
|
||||
s.render(w, "setup_manual", data)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate controller.yaml
|
||||
configYAML := s.generateManualConfig()
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] Setup: generated manual config (%d bytes) for customer %s", len(configYAML), customerID)
|
||||
}
|
||||
if err := s.writeFreshConfig(configYAML, ""); err != nil {
|
||||
csrf := ensureCSRFToken(w, r)
|
||||
data := map[string]interface{}{
|
||||
"CSRF": csrf,
|
||||
"FormData": s.state.FormData,
|
||||
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
||||
"Errors": []string{fmt.Sprintf("Konfigurációs hiba: %v", err)},
|
||||
}
|
||||
s.render(w, "setup_manual", data)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] Setup: manual configuration completed for %s", customerID)
|
||||
s.finishSetup()
|
||||
}
|
||||
|
||||
// --- Restore Execution ---
|
||||
|
||||
func (s *Server) writeFreshConfig(configYAML, retrievalPassword string) error {
|
||||
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
||||
if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil {
|
||||
return fmt.Errorf("writing controller.yaml: %w", err)
|
||||
}
|
||||
|
||||
// Create initial settings with password hash and retrieval password
|
||||
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
||||
if err != nil {
|
||||
sett = &settings.Settings{}
|
||||
}
|
||||
|
||||
// Hash the dashboard password if provided in form
|
||||
if pw := s.state.GetFormField("password"); pw != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
||||
if err == nil {
|
||||
sett.SetPasswordHash(string(hash))
|
||||
}
|
||||
}
|
||||
|
||||
if retrievalPassword != "" {
|
||||
sett.SetRetrievalPassword(retrievalPassword)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) generateManualConfig() string {
|
||||
fd := s.state.FormData
|
||||
|
||||
customerID := fd["customer_id"]
|
||||
displayName := fd["display_name"]
|
||||
if displayName == "" {
|
||||
displayName = customerID
|
||||
}
|
||||
domain := fd["domain"]
|
||||
email := fd["email"]
|
||||
cfTunnelToken := fd["cf_tunnel_token"]
|
||||
cfAPIToken := fd["cf_api_token"]
|
||||
systemDataPath := fd["system_data_path"]
|
||||
if systemDataPath == "" {
|
||||
systemDataPath = "/mnt/sys_drive"
|
||||
}
|
||||
|
||||
// Generate session secret
|
||||
secretBytes := make([]byte, 32)
|
||||
crand.Read(secretBytes)
|
||||
sessionSecret := hex.EncodeToString(secretBytes)
|
||||
|
||||
// Generate password hash
|
||||
passwordHash := ""
|
||||
if pw := fd["password"]; pw != "" {
|
||||
if hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost); err == nil {
|
||||
passwordHash = string(hash)
|
||||
}
|
||||
}
|
||||
|
||||
gitRepoURL := fd["git_repo_url"]
|
||||
if gitRepoURL == "" {
|
||||
gitRepoURL = "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
|
||||
}
|
||||
gitUsername := fd["git_username"]
|
||||
gitToken := fd["git_token"]
|
||||
|
||||
// Build YAML manually (simple key-value, no templates needed)
|
||||
var b strings.Builder
|
||||
b.WriteString("# Generated by felhom-controller setup wizard\n")
|
||||
b.WriteString("customer:\n")
|
||||
fmt.Fprintf(&b, " id: %q\n", customerID)
|
||||
fmt.Fprintf(&b, " name: %q\n", displayName)
|
||||
fmt.Fprintf(&b, " domain: %q\n", domain)
|
||||
if email != "" {
|
||||
fmt.Fprintf(&b, " email: %q\n", email)
|
||||
}
|
||||
b.WriteString("\ninfrastructure:\n")
|
||||
if cfTunnelToken != "" {
|
||||
fmt.Fprintf(&b, " cf_tunnel_token: %q\n", cfTunnelToken)
|
||||
}
|
||||
if cfAPIToken != "" {
|
||||
fmt.Fprintf(&b, " cf_api_token: %q\n", cfAPIToken)
|
||||
}
|
||||
b.WriteString("\npaths:\n")
|
||||
b.WriteString(" stacks_dir: \"/opt/docker/stacks\"\n")
|
||||
b.WriteString(" data_dir: \"/opt/docker/felhom-controller/data\"\n")
|
||||
fmt.Fprintf(&b, " system_data_path: %q\n", systemDataPath)
|
||||
b.WriteString("\nsystem:\n")
|
||||
b.WriteString(" reserved_memory_mb: 384\n")
|
||||
b.WriteString("\nweb:\n")
|
||||
b.WriteString(" listen: \":8080\"\n")
|
||||
b.WriteString(" setup_listen: \":8081\"\n")
|
||||
if passwordHash != "" {
|
||||
fmt.Fprintf(&b, " password_hash: %q\n", passwordHash)
|
||||
}
|
||||
fmt.Fprintf(&b, " session_secret: %q\n", sessionSecret)
|
||||
b.WriteString("\ngit:\n")
|
||||
fmt.Fprintf(&b, " repo_url: %q\n", gitRepoURL)
|
||||
b.WriteString(" branch: \"main\"\n")
|
||||
b.WriteString(" sync_interval: \"15m\"\n")
|
||||
if gitUsername != "" {
|
||||
fmt.Fprintf(&b, " username: %q\n", gitUsername)
|
||||
}
|
||||
if gitToken != "" {
|
||||
fmt.Fprintf(&b, " token: %q\n", gitToken)
|
||||
}
|
||||
b.WriteString("\nstacks:\n")
|
||||
b.WriteString(" protected:\n")
|
||||
b.WriteString(" - \"traefik\"\n")
|
||||
b.WriteString(" - \"cloudflared\"\n")
|
||||
b.WriteString(" - \"felhom-controller\"\n")
|
||||
b.WriteString(" - \"filebrowser\"\n")
|
||||
b.WriteString("\nbackup:\n")
|
||||
b.WriteString(" enabled: true\n")
|
||||
b.WriteString(" restic_password_file: \"/opt/docker/felhom-controller/data/restic-password\"\n")
|
||||
b.WriteString(" db_dump_schedule: \"02:30\"\n")
|
||||
b.WriteString(" restic_schedule: \"03:00\"\n")
|
||||
b.WriteString(" retention:\n")
|
||||
b.WriteString(" keep_daily: 7\n")
|
||||
b.WriteString(" keep_weekly: 4\n")
|
||||
b.WriteString(" keep_monthly: 6\n")
|
||||
b.WriteString(" prune_schedule: \"weekly\"\n")
|
||||
b.WriteString("\nmonitoring:\n")
|
||||
b.WriteString(" enabled: true\n")
|
||||
b.WriteString(" healthchecks_base: \"https://status.felhom.eu\"\n")
|
||||
b.WriteString(" system_health_interval: \"5m\"\n")
|
||||
b.WriteString(" health_check_schedule: \"06:00\"\n")
|
||||
b.WriteString("\nhub:\n")
|
||||
b.WriteString(" enabled: true\n")
|
||||
b.WriteString(" url: \"https://hub.felhom.eu\"\n")
|
||||
// Generate a Hub API key from customer ID
|
||||
apiKeyHash := sha256.Sum256([]byte(customerID + "-" + sessionSecret))
|
||||
fmt.Fprintf(&b, " api_key: %q\n", hex.EncodeToString(apiKeyHash[:]))
|
||||
b.WriteString(" push_interval: \"15m\"\n")
|
||||
b.WriteString("\nself_update:\n")
|
||||
b.WriteString(" enabled: true\n")
|
||||
b.WriteString(" check_interval: \"6h\"\n")
|
||||
b.WriteString(" image: \"gitea.dooplex.hu/admin/felhom-controller\"\n")
|
||||
b.WriteString(" auto_update: false\n")
|
||||
b.WriteString(" health_timeout_seconds: 60\n")
|
||||
b.WriteString("\nlogging:\n")
|
||||
b.WriteString(" level: \"info\"\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func (s *Server) finishSetup() {
|
||||
s.state.Remove()
|
||||
s.logger.Printf("[INFO] Setup complete — restarting controller")
|
||||
os.Exit(0) // Docker restart policy will restart us
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
s.logger.Printf("[ERROR] Template %s render error: %v", name, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
"CustomerID": customerID,
|
||||
}
|
||||
s.render(w, tmpl, data)
|
||||
}
|
||||
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, perm); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
// Rename fails on bind-mounted files (EBUSY). Fall back to direct write.
|
||||
return os.WriteFile(path, data, perm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isError(err, target error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), target.Error())
|
||||
}
|
||||
|
||||
// Minimal CSS for when the main stylesheet can't be loaded (v2 tokens)
|
||||
const minimalCSS = `
|
||||
:root { --bg-primary: #0A1220; --bg-card: #0F1B2E; --text-primary: #EDF2F9; --text-secondary: #94A6BF; --accent-blue: #0083D8; --border: #22344F; --green: #0083D8; --red: #E5534B; }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
.setup-container { max-width: 700px; margin: 0 auto; padding: 2rem 1.5rem; }
|
||||
.setup-header { text-align: center; margin-bottom: 2rem; }
|
||||
.setup-header img { width: 120px; margin-bottom: 1rem; }
|
||||
.setup-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||
.setup-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 2px; padding: 1.5rem; margin-bottom: 1rem; cursor: pointer; transition: border-color 0.2s; }
|
||||
.setup-card:hover { border-color: var(--accent-blue); }
|
||||
.setup-card h3 { margin-bottom: 0.5rem; }
|
||||
.setup-card p { color: var(--text-secondary); font-size: 0.9rem; }
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { display: block; margin-bottom: 0.25rem; font-size: 0.9rem; color: var(--text-secondary); }
|
||||
.form-control { width: 100%; padding: 0.5rem 0.75rem; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 2px; color: var(--text-primary); font-size: 0.9rem; }
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.6rem 1.5rem; border-radius: 2px; border: none; font-size: 0.9rem; font-weight: 500; cursor: pointer; text-decoration: none; }
|
||||
.btn-primary { background: var(--green); color: #fff; }
|
||||
.btn-primary:hover { background: #2EA8F5; }
|
||||
.btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border); }
|
||||
.alert { padding: 0.75rem 1rem; border-radius: 2px; margin-bottom: 1rem; font-size: 0.9rem; }
|
||||
.alert-error { background: rgba(229,83,75,.12); color: #E5534B; border: 1px solid rgba(229,83,75,.3); }
|
||||
.alert-info { background: rgba(0,131,216,.13); color: #2EA8F5; border: 1px solid rgba(0,131,216,.3); }
|
||||
.info-box { background: rgba(0,131,216,.13); border: 1px solid rgba(0,131,216,.2); border-radius: 2px; padding: 0.75rem 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: var(--text-secondary); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; padding: 0.5rem 0.75rem; color: var(--text-secondary); font-size: 0.85rem; border-bottom: 1px solid var(--border); }
|
||||
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; }
|
||||
.badge-ok { background: rgba(63,185,80,0.15); color: var(--green); }
|
||||
.badge-error { background: rgba(229,83,75,.12); color: var(--red); }
|
||||
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent-blue); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.section { margin-bottom: 1.5rem; }
|
||||
.section-header { cursor: pointer; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 2px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.section-body { padding: 1rem; border: 1px solid var(--border); border-top: none; border-radius: 0 0 6px 6px; }
|
||||
.step-list { list-style: none; }
|
||||
.step-list li { padding: 0.5rem 0; display: flex; align-items: center; gap: 0.75rem; }
|
||||
.step-done { color: var(--green); }
|
||||
.step-running { color: var(--accent-blue); }
|
||||
.step-failed { color: var(--red); }
|
||||
`
|
||||
@@ -1,62 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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. 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: fallback to interface enumeration (works on bare metal)
|
||||
return detectInterfaceIPs()
|
||||
}
|
||||
|
||||
func detectInterfaceIPs() []string {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ips []string
|
||||
for _, iface := range ifaces {
|
||||
// Skip down, loopback, and Docker/container interfaces
|
||||
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(iface.Name)
|
||||
if strings.HasPrefix(name, "docker") || strings.HasPrefix(name, "br-") ||
|
||||
strings.HasPrefix(name, "veth") || strings.HasPrefix(name, "lo") {
|
||||
continue
|
||||
}
|
||||
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
var ip net.IP
|
||||
switch v := addr.(type) {
|
||||
case *net.IPNet:
|
||||
ip = v.IP
|
||||
case *net.IPAddr:
|
||||
ip = v.IP
|
||||
}
|
||||
if ip == nil || ip.IsLoopback() || ip.To4() == nil {
|
||||
continue // skip non-IPv4
|
||||
}
|
||||
ips = append(ips, ip.String())
|
||||
}
|
||||
}
|
||||
|
||||
return ips
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// NeedsSetup checks whether the controller should enter setup mode.
|
||||
// Setup is needed when no customer ID has been configured (empty string)
|
||||
// or when a debug-triggered setup marker file exists.
|
||||
func NeedsSetup(cfg *config.Config) bool {
|
||||
if cfg.Customer.ID == "" {
|
||||
return true
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.Paths.DataDir, ".needs-setup")); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ClearSetupMarker removes the debug-triggered setup marker file.
|
||||
func ClearSetupMarker(dataDir string) {
|
||||
os.Remove(filepath.Join(dataDir, ".needs-setup"))
|
||||
}
|
||||
|
||||
// SetupState persists wizard progress to survive browser crashes.
|
||||
type SetupState struct {
|
||||
mu sync.Mutex `json:"-"`
|
||||
path string `json:"-"`
|
||||
|
||||
Step string `json:"step"` // "welcome", "scan", "hub-restore", "restore-exec", "fresh-hub", "fresh-manual", "done"
|
||||
Mode string `json:"mode"` // "restore" or "fresh"
|
||||
FormData map[string]string `json:"form_data"` // partially filled form fields
|
||||
|
||||
SelectedBackup *SelectedBackup `json:"selected_backup,omitempty"`
|
||||
}
|
||||
|
||||
// SelectedBackup tracks which backup the user chose.
|
||||
type SelectedBackup struct {
|
||||
Source string `json:"source"` // "local" or "hub"
|
||||
DrivePath string `json:"drive_path"` // for local
|
||||
CustomerID string `json:"customer_id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
// LoadState loads or creates setup state from the data directory.
|
||||
func LoadState(dataDir string) *SetupState {
|
||||
path := filepath.Join(dataDir, "setup-state.json")
|
||||
s := &SetupState{path: path, Step: "welcome"}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return s // fresh state
|
||||
}
|
||||
if err := json.Unmarshal(data, s); err != nil {
|
||||
return &SetupState{path: path, Step: "welcome"}
|
||||
}
|
||||
s.path = path
|
||||
return s
|
||||
}
|
||||
|
||||
// Save persists the setup state atomically.
|
||||
func (s *SetupState) Save() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.FormData == nil {
|
||||
s.FormData = make(map[string]string)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling setup state: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetStep updates the current step and saves.
|
||||
func (s *SetupState) SetStep(step string) {
|
||||
s.mu.Lock()
|
||||
s.Step = step
|
||||
s.mu.Unlock()
|
||||
if err := s.Save(); err != nil {
|
||||
log.Printf("[WARN] [setup] Failed to save setup step %q: %v", step, err)
|
||||
}
|
||||
}
|
||||
|
||||
// SetFormField saves a form field for state persistence.
|
||||
func (s *SetupState) SetFormField(key, value string) {
|
||||
s.mu.Lock()
|
||||
if s.FormData == nil {
|
||||
s.FormData = make(map[string]string)
|
||||
}
|
||||
s.FormData[key] = value
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// GetFormField retrieves a saved form field.
|
||||
func (s *SetupState) GetFormField(key string) string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.FormData == nil {
|
||||
return ""
|
||||
}
|
||||
return s.FormData[key]
|
||||
}
|
||||
|
||||
// Remove deletes the setup state file.
|
||||
func (s *SetupState) Remove() {
|
||||
os.Remove(s.path)
|
||||
}
|
||||
|
||||
// DefaultHubURL is the default Hub URL.
|
||||
const DefaultHubURL = "https://hub.felhom.eu"
|
||||
|
||||
// LogSetupMode logs the setup mode startup message.
|
||||
func LogSetupMode(domain string, ips []string, setupListen string, logger *log.Logger) {
|
||||
logger.Printf("[INFO] Controller in setup mode — waiting for configuration via web UI")
|
||||
if domain != "" {
|
||||
logger.Printf("[INFO] Setup wizard available at: https://felhom.%s", domain)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
logger.Printf("[INFO] Setup wizard available at: http://%s%s", ip, setupListen)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{{define "setup_failed"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Visszaállítás sikertelen — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>A visszaállítás nem sikerült</h1>
|
||||
</div>
|
||||
|
||||
<div class="setup-card">
|
||||
<p>Kérjük, vegye fel a kapcsolatot a támogatással:</p>
|
||||
<div style="margin-top: 1rem;">
|
||||
<p><strong>Email:</strong> <a href="mailto:support@felhom.eu" style="color: var(--accent-blue, #0088cc);">support@felhom.eu</a></p>
|
||||
<p><strong>Web:</strong> <a href="https://felhom.eu/kapcsolat" target="_blank" style="color: var(--accent-blue, #0088cc);">felhom.eu/kapcsolat</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1.5rem;">
|
||||
<a href="/setup/fresh" class="btn btn-primary">Új telepítés</a>
|
||||
<a href="/setup" class="btn btn-outline">Vissza a kezdőlapra</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,46 +0,0 @@
|
||||
{{define "setup_fresh_hub"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Új telepítés — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Új telepítés</h1>
|
||||
<p style="color: var(--text-secondary, #8b949e);">Konfiguráció letöltése a Hub-ról.</p>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="setup-card">
|
||||
<form method="POST" action="/setup/fresh">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div class="form-group">
|
||||
<label for="customer_id">Ügyfél-azonosító</label>
|
||||
<input type="text" id="customer_id" name="customer_id" class="form-control"
|
||||
value="{{.CustomerID}}" required autofocus placeholder="pl. kiscsalad-bp">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Visszaállítási jelszó</label>
|
||||
<input type="password" id="password" name="password" class="form-control"
|
||||
required placeholder="A Hub-on beállított jelszó">
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary">Letöltés</button>
|
||||
<a href="/setup" class="btn btn-outline">Vissza</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p style="text-align: center; margin-top: 1rem;">
|
||||
<a href="/setup/manual" style="color: var(--text-secondary, #8b949e); font-size: 0.85rem;">Nincs Hub hozzáférés? Kézi beállítás →</a>
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,42 +0,0 @@
|
||||
{{define "setup_hub_restore"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hub visszaállítás — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Visszaállítás a Hub-ról</h1>
|
||||
<p style="color: var(--text-secondary, #8b949e);">Adja meg az ügyfél-azonosítót és jelszót a mentés letöltéséhez.</p>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="setup-card">
|
||||
<form method="POST" action="/setup/hub-restore">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<div class="form-group">
|
||||
<label for="customer_id">Ügyfél-azonosító</label>
|
||||
<input type="text" id="customer_id" name="customer_id" class="form-control"
|
||||
value="{{.CustomerID}}" required autofocus placeholder="pl. kiscsalad-bp">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Visszaállítási jelszó</label>
|
||||
<input type="password" id="password" name="password" class="form-control"
|
||||
required placeholder="A Hub-on beállított jelszó">
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary">Kapcsolódás</button>
|
||||
<a href="/setup" class="btn btn-outline">Vissza</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,56 +0,0 @@
|
||||
{{define "setup_hub_versions"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mentés kiválasztása — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Visszaállítás a Hub-ról</h1>
|
||||
<p style="color: var(--text-secondary, #8b949e);">Válasszon a mentés-verziók közül.</p>
|
||||
</div>
|
||||
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="setup-card">
|
||||
<form method="POST" action="/setup/hub-restore/select" id="version-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 2rem;"></th>
|
||||
<th style="text-align: left; padding: 0.5rem;">Dátum</th>
|
||||
<th style="text-align: left; padding: 0.5rem;">Alkalmazások</th>
|
||||
<th style="text-align: right; padding: 0.5rem;">Lemezek</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range $i, $v := .Versions}}
|
||||
<tr style="border-top: 1px solid var(--line);">
|
||||
<td style="padding: 0.5rem;">
|
||||
<input type="radio" name="version_id" value="{{$v.ID}}" {{if eq $i 0}}checked{{end}}>
|
||||
</td>
|
||||
<td style="padding: 0.5rem;">{{$v.CreatedAt}}{{if eq $i 0}} <span class="badge badge-ok" style="font-size: 0.75em;">legújabb</span>{{end}}</td>
|
||||
<td style="padding: 0.5rem;">
|
||||
{{$v.StackCount}}{{if $v.StackNames}}: {{range $j, $n := $v.StackNames}}{{if $j}}, {{end}}{{$n}}{{end}}{{end}}
|
||||
</td>
|
||||
<td style="text-align: right; padding: 0.5rem;">{{$v.DiskCount}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary">Visszaállítás</button>
|
||||
<a href="/setup" class="btn btn-outline">Vissza</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,111 +0,0 @@
|
||||
{{define "setup_manual"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Kézi beállítás — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Kézi beállítás</h1>
|
||||
</div>
|
||||
|
||||
{{if .Errors}}
|
||||
<div class="alert alert-error">
|
||||
{{range .Errors}}<div>{{.}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/setup/manual">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
|
||||
<div class="setup-card">
|
||||
<h3>Ügyfél azonosítás</h3>
|
||||
<div class="form-group">
|
||||
<label for="customer_id">Ügyfél-azonosító *</label>
|
||||
<input type="text" id="customer_id" name="customer_id" class="form-control"
|
||||
value="{{index .FormData "customer_id"}}" required placeholder="pl. kiscsalad-bp"
|
||||
pattern="[a-zA-Z0-9_-]+" title="Csak betűk, számok, kötőjel és aláhúzás">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="display_name">Megjelenítési név</label>
|
||||
<input type="text" id="display_name" name="display_name" class="form-control"
|
||||
value="{{index .FormData "display_name"}}" placeholder="pl. Kis Család">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="domain">Domain *</label>
|
||||
<input type="text" id="domain" name="domain" class="form-control"
|
||||
value="{{index .FormData "domain"}}" required placeholder="pl. kiscsalad.hu">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" class="form-control"
|
||||
value="{{index .FormData "email"}}" placeholder="Opcionális">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-card">
|
||||
<h3>Infrastruktúra</h3>
|
||||
<div class="form-group">
|
||||
<label for="cf_tunnel_token">Cloudflare Tunnel token</label>
|
||||
<input type="password" id="cf_tunnel_token" name="cf_tunnel_token" class="form-control"
|
||||
value="{{index .FormData "cf_tunnel_token"}}" placeholder="Opcionális">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cf_api_token">Cloudflare API token</label>
|
||||
<input type="password" id="cf_api_token" name="cf_api_token" class="form-control"
|
||||
value="{{index .FormData "cf_api_token"}}" placeholder="Opcionális — DNS-01 TLS-hez">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="system_data_path">Rendszer adatpartíció útvonala</label>
|
||||
<input type="text" id="system_data_path" name="system_data_path" class="form-control"
|
||||
value="{{index .FormData "system_data_path"}}" placeholder="Alapértelmezett: /mnt/sys_drive">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-card">
|
||||
<h3>Dashboard jelszó</h3>
|
||||
<div class="form-group">
|
||||
<label for="password">Jelszó (min. 8 karakter)</label>
|
||||
<input type="password" id="password" name="password" class="form-control"
|
||||
placeholder="Hagyja üresen, ha később szeretné beállítani" minlength="8">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password_confirm">Jelszó megerősítés</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm" class="form-control"
|
||||
placeholder="Adja meg újra a jelszót">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-card">
|
||||
<h3>Alkalmazás-katalógus</h3>
|
||||
<div class="form-group">
|
||||
<label for="git_repo_url">Git repo URL</label>
|
||||
<input type="text" id="git_repo_url" name="git_repo_url" class="form-control"
|
||||
value="{{index .FormData "git_repo_url"}}" placeholder="{{.DefaultGit}}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="git_username">Git felhasználónév</label>
|
||||
<input type="text" id="git_username" name="git_username" class="form-control"
|
||||
value="{{index .FormData "git_username"}}" placeholder="Opcionális">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="git_token">Git token</label>
|
||||
<input type="password" id="git_token" name="git_token" class="form-control"
|
||||
value="{{index .FormData "git_token"}}" placeholder="Opcionális">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem;">
|
||||
<button type="submit" class="btn btn-primary">Mentés és indítás</button>
|
||||
<a href="/setup/fresh" class="btn btn-outline">Vissza</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,90 +0,0 @@
|
||||
{{define "setup_restore_exec"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Visszaállítás folyamatban — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Visszaállítás</h1>
|
||||
</div>
|
||||
|
||||
<div class="setup-card">
|
||||
<ul class="step-list" id="steps">
|
||||
<li><span class="spinner"></span> Indítás...</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div id="done-msg" style="display: none;">
|
||||
<div class="alert alert-info">Visszaállítás sikeres! A vezérlőpult újraindul...</div>
|
||||
</div>
|
||||
<div id="error-msg" style="display: none;">
|
||||
<div class="alert alert-error" id="error-text"></div>
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1rem;">
|
||||
<a href="/setup/failed" class="btn btn-outline">Tovább</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// After restore, the setup server (port 8081) exits and the main
|
||||
// controller restarts on port 8080. Poll port 8080 until it responds.
|
||||
var mainURL = window.location.protocol + '//' + window.location.hostname + ':8080/';
|
||||
function waitForRestart() {
|
||||
fetch(mainURL, {redirect: 'follow', mode: 'no-cors'})
|
||||
.then(function() {
|
||||
window.location.href = mainURL;
|
||||
})
|
||||
.catch(function() {
|
||||
setTimeout(waitForRestart, 2000);
|
||||
});
|
||||
}
|
||||
function poll() {
|
||||
fetch('/setup/restore/status')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
var list = document.getElementById('steps');
|
||||
if (data.steps && data.steps.length > 0) {
|
||||
list.innerHTML = '';
|
||||
data.steps.forEach(function(step) {
|
||||
var li = document.createElement('li');
|
||||
var icon = '';
|
||||
if (step.status === 'done') icon = '<span class="step-done">✓</span>';
|
||||
else if (step.status === 'running') icon = '<span class="spinner"></span>';
|
||||
else if (step.status === 'failed') icon = '<span class="step-failed">✗</span>';
|
||||
else icon = '<span style="color: var(--text-2);">○</span>';
|
||||
li.innerHTML = icon + ' ' + step.label;
|
||||
if (step.error) li.innerHTML += '<br><small style="color: var(--red, #f85149);">' + step.error + '</small>';
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
if (data.error) {
|
||||
document.getElementById('error-msg').style.display = 'block';
|
||||
document.getElementById('error-text').textContent = data.error;
|
||||
return;
|
||||
}
|
||||
if (data.done) {
|
||||
document.getElementById('done-msg').style.display = 'block';
|
||||
setTimeout(waitForRestart, 3000);
|
||||
return;
|
||||
}
|
||||
setTimeout(poll, 1500);
|
||||
})
|
||||
.catch(function() {
|
||||
// Connection lost — controller is restarting
|
||||
document.getElementById('done-msg').style.display = 'block';
|
||||
setTimeout(waitForRestart, 3000);
|
||||
});
|
||||
}
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,145 +0,0 @@
|
||||
{{define "setup_scan"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Meghajtók keresése — Felhom</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Visszaállítás mentésből</h1>
|
||||
</div>
|
||||
|
||||
<div class="setup-card" id="scan-status">
|
||||
<h3>Külső meghajtók keresése...</h3>
|
||||
<p style="color: var(--text-secondary, #8b949e);">Ha vannak külső meghajtók csatlakoztatva a szerverhez, győződjön meg róla, hogy most csatlakoztatva vannak.</p>
|
||||
<div style="margin-top: 1rem; text-align: center;">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="results" style="display: none;">
|
||||
<div class="setup-card">
|
||||
<h3>Találatok</h3>
|
||||
<table id="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Meghajtó</th>
|
||||
<th>Ügyfél</th>
|
||||
<th>Dátum</th>
|
||||
<th>Verzió</th>
|
||||
<th>Alkalmazások</th>
|
||||
<th>Lemezek</th>
|
||||
<th>Állapot</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem;">
|
||||
<form method="POST" action="/setup/restore" id="restore-form">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="source" value="local">
|
||||
<input type="hidden" name="drive_path" id="selected-drive" value="">
|
||||
<input type="hidden" name="history_file" id="selected-history" value="">
|
||||
<button type="submit" class="btn btn-primary" id="restore-btn" disabled>Visszaállítás</button>
|
||||
</form>
|
||||
<a href="/setup/hub-restore" class="btn btn-outline">Tovább a Hub-hoz</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="no-results" style="display: none;">
|
||||
<div class="setup-card">
|
||||
<h3>Nem található helyi mentés.</h3>
|
||||
<p style="color: var(--text-secondary, #8b949e);">A csatlakoztatott meghajtókon nem található Felhom infra mentés.</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.75rem; justify-content: center; margin-top: 1rem;">
|
||||
<a href="/setup/hub-restore" class="btn btn-primary">Tovább a Hub-hoz</a>
|
||||
<a href="/setup" class="btn btn-outline">Vissza</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="scan-error" style="display: none;">
|
||||
<div class="alert alert-error" id="scan-error-msg"></div>
|
||||
<a href="/setup" class="btn btn-outline">Vissza</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
function poll() {
|
||||
fetch('/setup/scan/status')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.error) {
|
||||
document.getElementById('scan-status').style.display = 'none';
|
||||
document.getElementById('scan-error').style.display = 'block';
|
||||
document.getElementById('scan-error-msg').textContent = data.error;
|
||||
return;
|
||||
}
|
||||
if (!data.done) {
|
||||
setTimeout(poll, 1000);
|
||||
return;
|
||||
}
|
||||
document.getElementById('scan-status').style.display = 'none';
|
||||
if (!data.results || data.results.length === 0) {
|
||||
document.getElementById('no-results').style.display = 'block';
|
||||
return;
|
||||
}
|
||||
document.getElementById('results').style.display = 'block';
|
||||
var tbody = document.querySelector('#results-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
var validCount = 0;
|
||||
data.results.forEach(function(r, i) {
|
||||
var tr = document.createElement('tr');
|
||||
var driveVal = r.mount_point + '|' + (r.history_file || '');
|
||||
var radio = r.integrity_ok ? '<input type="radio" name="backup" value="' + driveVal + '" onclick="selectDrive(this)">' : '';
|
||||
var apps = '-';
|
||||
if (r.stack_count > 0) {
|
||||
apps = r.stack_count.toString();
|
||||
if (r.stack_names && r.stack_names.length > 0) {
|
||||
var names = r.stack_names.slice(0, 3).join(', ');
|
||||
if (r.stack_names.length > 3) names += ', ...';
|
||||
apps += ': ' + names;
|
||||
}
|
||||
}
|
||||
var disks = r.disk_count > 0 ? r.disk_count.toString() : '-';
|
||||
var dateBadge = '';
|
||||
if (r.is_history) dateBadge = ' <span class="badge" style="font-size:0.7em;background:#6e4000;color:#ffd080;">korábbi</span>';
|
||||
var statusCol = r.integrity_ok
|
||||
? '<span class="badge badge-ok">OK</span>'
|
||||
: '<span class="badge badge-error">' + (r.error || 'Hiba') + '</span>';
|
||||
tr.innerHTML = '<td>' + radio + '</td>' +
|
||||
'<td>' + (r.device || '') + (r.label ? ' (' + r.label + ')' : '') + '</td>' +
|
||||
'<td>' + (r.customer_id || '-') + '</td>' +
|
||||
'<td>' + (r.timestamp ? r.timestamp.substring(0, 10) : '-') + dateBadge + '</td>' +
|
||||
'<td>' + (r.controller_version || '-') + '</td>' +
|
||||
'<td>' + apps + '</td>' +
|
||||
'<td>' + disks + '</td>' +
|
||||
'<td>' + statusCol + '</td>';
|
||||
tbody.appendChild(tr);
|
||||
if (r.integrity_ok) validCount++;
|
||||
});
|
||||
if (validCount === 1) {
|
||||
var radio = tbody.querySelector('input[type="radio"]');
|
||||
if (radio) { radio.checked = true; selectDrive(radio); }
|
||||
}
|
||||
});
|
||||
}
|
||||
window.selectDrive = function(el) {
|
||||
var parts = el.value.split('|');
|
||||
document.getElementById('selected-drive').value = parts[0];
|
||||
document.getElementById('selected-history').value = parts[1] || '';
|
||||
document.getElementById('restore-btn').disabled = false;
|
||||
};
|
||||
poll();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,58 +0,0 @@
|
||||
{{define "setup_welcome"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Felhom Szerver Beállítás</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="login-body">
|
||||
<div class="setup-container">
|
||||
<div class="setup-header">
|
||||
<img src="/static/felhom-logo.svg" alt="Felhom.eu" style="width: 120px;">
|
||||
<h1>Felhom Szerver Beállítás</h1>
|
||||
<p style="color: var(--text-secondary, #8b949e); font-size: 0.85rem;">{{.Version}}</p>
|
||||
</div>
|
||||
|
||||
{{if .AccessURLs}}
|
||||
<div class="info-box">
|
||||
Ez az oldal elérhető:
|
||||
{{range .AccessURLs}}<br>{{.}}{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .HubMode}}
|
||||
<div class="alert alert-info" style="margin-bottom: 1.5rem;">
|
||||
Hub-mód: <strong>{{.HubCustomerID}}</strong> — 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>
|
||||
</a>
|
||||
|
||||
<a href="/setup/fresh" class="setup-card" style="display: block; text-decoration: none; color: inherit;">
|
||||
<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>
|
||||
{{end}}
|
||||
@@ -108,10 +108,6 @@ func (s *Server) handleDebugAPI(w http.ResponseWriter, r *http.Request) {
|
||||
case subpath == "selfupdate/dry-run" && r.Method == http.MethodPost:
|
||||
s.debugSelfUpdateDryRun(w, r)
|
||||
|
||||
// Section 7: DR / Setup
|
||||
case subpath == "dr/trigger-setup" && r.Method == http.MethodPost:
|
||||
s.debugTriggerSetupWizard(w, r)
|
||||
|
||||
// Section 8: Log viewer
|
||||
case subpath == "logs" && r.Method == http.MethodGet:
|
||||
s.debugLogBuffer(w, r)
|
||||
@@ -686,38 +682,6 @@ func (s *Server) debugSelfUpdateDryRun(w http.ResponseWriter, r *http.Request) {
|
||||
writeDebugJSON(w, http.StatusOK, true, "", result)
|
||||
}
|
||||
|
||||
// ── Section 7: DR / Setup ───────────────────────────────────────────
|
||||
|
||||
func (s *Server) debugTriggerSetupWizard(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Confirm string `json:"confirm"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false, s.msg(r, "debug.api.invalid_request"), nil)
|
||||
return
|
||||
}
|
||||
if req.Confirm != "RESET" {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false, s.msg(r, "debug.api.reset_confirm_invalid"), nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Write marker file
|
||||
markerPath := filepath.Join(s.cfg.Paths.DataDir, ".needs-setup")
|
||||
if err := os.WriteFile(markerPath, []byte("debug-triggered\n"), 0644); err != nil {
|
||||
writeDebugJSON(w, http.StatusInternalServerError, false,
|
||||
s.msg(r, "debug.api.marker_write_failed", err), nil)
|
||||
return
|
||||
}
|
||||
|
||||
writeDebugJSON(w, http.StatusOK, true, s.msg(r, "debug.api.restarting_to_setup"), nil)
|
||||
|
||||
// Exit after response is sent so the container restarts into setup mode
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}()
|
||||
}
|
||||
|
||||
// ── Section 8: Log viewer ───────────────────────────────────────────
|
||||
|
||||
func (s *Server) debugLogBuffer(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -51,6 +51,8 @@ func TestR400_DeletedControlsAreGoneFromTheTemplate(t *testing.T) {
|
||||
{"/api/debug/storage/simulate-disconnect",
|
||||
"no backing capability, and it WRITES storage state — a button that fakes a drive disconnect on a customer's machine is a foot-gun"},
|
||||
{"/api/debug/storage/simulate-reconnect", "same as simulate-disconnect"},
|
||||
{"/api/debug/dr/trigger-setup",
|
||||
"R-554 (v0.308.0): it wrote .needs-setup and restarted the box into the first-boot setup wizard, which is deleted"},
|
||||
}
|
||||
for _, d := range deleted {
|
||||
if strings.Contains(src, d.ref) {
|
||||
@@ -60,7 +62,7 @@ func TestR400_DeletedControlsAreGoneFromTheTemplate(t *testing.T) {
|
||||
|
||||
// POSITIVE CONTROL. Without it this test passes against a template it failed to read, or one that
|
||||
// was emptied — the exact shape of an assertion that proves nothing.
|
||||
for _, kept := range []string{"/api/debug/backup/integrity", "/api/debug/dr/trigger-setup"} {
|
||||
for _, kept := range []string{"/api/debug/backup/integrity", "/api/debug/hub/push"} {
|
||||
if !strings.Contains(src, kept) {
|
||||
t.Fatalf("positive control failed: %s is missing too, so the assertions above prove nothing "+
|
||||
"about what was deliberately deleted", kept)
|
||||
@@ -85,6 +87,9 @@ func TestR400_DeletedControlsLeftNoPanelOrScript(t *testing.T) {
|
||||
"btn-infra-backup",
|
||||
"btn-hub-infra",
|
||||
"section-storage",
|
||||
"btn-dr-trigger", // R-554: the setup-wizard trigger and its panel
|
||||
"triggerDR",
|
||||
"section-dr",
|
||||
} {
|
||||
if strings.Contains(src, orphan) {
|
||||
t.Errorf("%q survived the deletion — an orphaned panel or handler renders nothing forever "+
|
||||
@@ -92,7 +97,7 @@ func TestR400_DeletedControlsLeftNoPanelOrScript(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// POSITIVE CONTROL: the neighbours that must stay.
|
||||
for _, kept := range []string{"btn-dr-trigger", "triggerDR", "section-dr", "btn-crossdrive"} {
|
||||
for _, kept := range []string{"section-appexport", "btn-crossdrive"} {
|
||||
if !strings.Contains(src, kept) {
|
||||
t.Fatalf("positive control failed: %q is gone too — the deletion took a neighbour with it", kept)
|
||||
}
|
||||
|
||||
@@ -163,8 +163,6 @@ func TestR516_WidenedFormalFormsAreGone(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]string{
|
||||
"debug.irja_be_reset": "Írd be: RESET",
|
||||
"debug.api.reset_confirm_invalid": "Érvénytelen megerősítés — írd be: RESET",
|
||||
"settings_security.adja_meg_a_jelenlegi_jelszavat": "Add meg a jelenlegi jelszavad",
|
||||
"storage.biztosan_eltavolitja_adattarolot": "Biztosan eltávolítod a(z) {{.Path}} adattárolót?",
|
||||
"storage_network.engedelyezze_a_felhom_gep_ip": "Engedélyezd a Felhom gép IP-címét a megosztáson. Proxmox-fürt esetén minden csomópont IP-címét.",
|
||||
|
||||
@@ -149,28 +149,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 7: DR / Setup Wizard -->
|
||||
<div class="card debug-section" id="section-dr">
|
||||
<div class="card-header debug-section-header" onclick="toggleSection('dr')">
|
||||
<h3>{{T "debug.dr_telepito_varazslo"}}</h3>
|
||||
<span class="section-toggle">▶</span>
|
||||
</div>
|
||||
<div class="card-body debug-section-body" style="display:none">
|
||||
<div class="debug-actions" style="margin-top:1rem">
|
||||
<div class="debug-dr-danger">
|
||||
<p style="color:var(--crit);font-weight:600">{{T "debug.veszhelyzet_szimulacio"}}</p>
|
||||
<p class="text-muted" style="font-size:.85rem;margin-bottom:.5rem">{{T "debug.ez_torli_az_ugyfel_azonositot"}}</p>
|
||||
<div class="debug-form-row">
|
||||
<label>{{T "debug.irja_be_reset_a_megerositeshez"}}</label>
|
||||
<input type="text" id="dr-confirm-input" class="form-control debug-confirm-input" placeholder="RESET" autocomplete="off">
|
||||
<button class="btn btn-danger btn-sm" id="btn-dr-trigger" data-label="{{T "debug.ujrainditas_setup_modban"}}" onclick="triggerDR()">{{T "debug.ujrainditas_setup_modban"}}</button>
|
||||
<span class="debug-result" id="btn-dr-trigger-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 9: App Export/Import -->
|
||||
<div class="card debug-section" id="section-appexport">
|
||||
<div class="card-header debug-section-header" onclick="toggleSection('appexport')">
|
||||
@@ -452,43 +430,6 @@ function loadSelfUpdateStatus() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Section 7: DR ──
|
||||
function triggerDR() {
|
||||
var input = document.getElementById('dr-confirm-input');
|
||||
if (input.value !== 'RESET') {
|
||||
var result = document.getElementById('btn-dr-trigger-result');
|
||||
result.className = 'debug-result debug-result-error';
|
||||
result.textContent = '{{T "debug.irja_be_reset"}}';
|
||||
return;
|
||||
}
|
||||
var btn = document.getElementById('btn-dr-trigger');
|
||||
felhomConfirm(btn, '{{T "debug.figyelem_ez_ujrainditja_a_controllert"}}', function () {
|
||||
var result = document.getElementById('btn-dr-trigger-result');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '{{T "debug.folyamatban"}}';
|
||||
fetch('/api/debug/dr/trigger-setup', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({confirm: 'RESET'})
|
||||
}).then(function(r){return r.json()}).then(function(data) {
|
||||
if (data.ok) {
|
||||
result.className = 'debug-result debug-result-ok';
|
||||
result.textContent = '{{T "debug.controller_ujraindulasa"}}';
|
||||
} else {
|
||||
result.className = 'debug-result debug-result-error';
|
||||
result.textContent = data.error || '{{T "debug.hiba"}}';
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.label;
|
||||
}
|
||||
}).catch(function(e) {
|
||||
result.className = 'debug-result debug-result-error';
|
||||
result.textContent = '{{T "debug.hiba_2"}} ' + e.message;
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.label;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Section 8: Log viewer (Vezérlő | Ügynök tabs — v0.116.0) ──
|
||||
var currentLogLevel = 'DEBUG';
|
||||
var currentLogSource = 'controller';
|
||||
|
||||
@@ -321,28 +321,6 @@
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card debug-section" id="section-dr">
|
||||
<div class="card-header debug-section-header" onclick="toggleSection('dr')">
|
||||
<h3>DR / Telepítő varázsló</h3>
|
||||
<span class="section-toggle">▶</span>
|
||||
</div>
|
||||
<div class="card-body debug-section-body" style="display:none">
|
||||
<div class="debug-actions" style="margin-top:1rem">
|
||||
<div class="debug-dr-danger">
|
||||
<p style="color:var(--crit);font-weight:600">Vészhelyzet szimuláció</p>
|
||||
<p class="text-muted" style="font-size:.85rem;margin-bottom:.5rem">Ez törli az ügyfél azonosítót és újraindítja a controllert setup módba. A konfigurációs fájlok megmaradnak a meghajtókon.</p>
|
||||
<div class="debug-form-row">
|
||||
<label>Írd be "RESET" a megerősítéshez:</label>
|
||||
<input type="text" id="dr-confirm-input" class="form-control debug-confirm-input" placeholder="RESET" autocomplete="off">
|
||||
<button class="btn btn-danger btn-sm" id="btn-dr-trigger" data-label="Újraindítás setup módban" onclick="triggerDR()">Újraindítás setup módban</button>
|
||||
<span class="debug-result" id="btn-dr-trigger-result"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card debug-section" id="section-appexport">
|
||||
<div class="card-header debug-section-header" onclick="toggleSection('appexport')">
|
||||
<h3>Alkalmazás Export/Import</h3>
|
||||
@@ -624,43 +602,6 @@ function loadSelfUpdateStatus() {
|
||||
}
|
||||
|
||||
|
||||
function triggerDR() {
|
||||
var input = document.getElementById('dr-confirm-input');
|
||||
if (input.value !== 'RESET') {
|
||||
var result = document.getElementById('btn-dr-trigger-result');
|
||||
result.className = 'debug-result debug-result-error';
|
||||
result.textContent = 'Írd be: RESET';
|
||||
return;
|
||||
}
|
||||
var btn = document.getElementById('btn-dr-trigger');
|
||||
felhomConfirm(btn, 'FIGYELEM! Ez újraindítja a controllert setup módba. Biztosan folytatod?', function () {
|
||||
var result = document.getElementById('btn-dr-trigger-result');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Folyamatban...';
|
||||
fetch('/api/debug/dr/trigger-setup', {
|
||||
method: 'POST',
|
||||
headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()),
|
||||
body: JSON.stringify({confirm: 'RESET'})
|
||||
}).then(function(r){return r.json()}).then(function(data) {
|
||||
if (data.ok) {
|
||||
result.className = 'debug-result debug-result-ok';
|
||||
result.textContent = 'Controller újraindulása...';
|
||||
} else {
|
||||
result.className = 'debug-result debug-result-error';
|
||||
result.textContent = data.error || 'Hiba';
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.label;
|
||||
}
|
||||
}).catch(function(e) {
|
||||
result.className = 'debug-result debug-result-error';
|
||||
result.textContent = 'Hiba: ' + e.message;
|
||||
btn.disabled = false;
|
||||
btn.textContent = btn.dataset.label;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
var currentLogLevel = 'DEBUG';
|
||||
var currentLogSource = 'controller';
|
||||
var lastLogTimestamp = '';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
grep-gate zero was a false negative). This scans templates by Unicode codepoint.
|
||||
|
||||
Run from controller/: python scripts/emoji_gate.py
|
||||
Exit 1 if any emoji/pictograph remains in web+setup templates.
|
||||
Exit 1 if any emoji/pictograph remains in the web templates.
|
||||
"""
|
||||
import io, os, sys, unicodedata
|
||||
|
||||
@@ -12,7 +12,6 @@ import i18n_bundle # noqa: E402
|
||||
|
||||
ROOTS = [
|
||||
os.path.join("internal", "web", "templates"),
|
||||
os.path.join("internal", "setup", "templates"),
|
||||
]
|
||||
|
||||
# Codepoint ranges that count as "emoji / pictographs / dingbats" for UI-copy purposes.
|
||||
@@ -88,7 +87,7 @@ def main():
|
||||
if total:
|
||||
print("EMOJI GATE FAILED: %d emoji found" % total)
|
||||
sys.exit(1)
|
||||
print("emoji gate OK — no emoji in web/setup templates")
|
||||
print("emoji gate OK — no emoji in web templates")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"_comment": "Localisation slice 2 (R-557). key -> the base-commit Go literal it replaced, or the ORDERED list of literals a concatenation joined. Checked by scripts/i18n_go_parity.py against scripts/i18n_go_base.json, which is frozen at 736f54b49610 (the base commit of release v0.252.0). A key whose Hungarian text is not byte-identical to what the Go code said fails the gate.",
|
||||
"_preexisting": {
|
||||
"waiting.title": "BORN AS A KEY (R-554, 2026-10-12) -- the title of the page an unconfigured box shows while it retries its configuration, replacing the deleted setup wizard. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.",
|
||||
"waiting.body": "BORN AS A KEY (R-554, 2026-10-12) -- the body of that waiting page. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.",
|
||||
"recovery_info.body": "BORN AS A KEY (R-554, 2026-10-12, `09` §3 decision 222) -- the NEW text of recovery-info.txt, pointing at the dashboard's Restore page and the recovery code instead of the deleted setup wizard. The operator approves the Hungarian before release. Pinned by internal/recovery TestRecoveryInfo_PointsAtDashboardNotWizard.",
|
||||
"err.backup.db_restore_failed_held_mixed": "BORN AS A KEY (R-893, 2026-10-08) -- a NEW sentence for a failed off-site replay whose database rollback worked but whose files/volumes/version had already moved; the app is held. Never a Go literal; pinned by internal/backup/r893_mixed_restore_test.go.",
|
||||
"err.backup.restore_failed_held_mixed": "BORN AS A KEY (R-893, 2026-10-08, security review) -- a restore that stopped half-way (placement, volume replay or DB start failed) after files/volumes/version had moved; the app is HELD. Pinned by internal/backup/r893_mixed_restore_test.go.",
|
||||
"note.reconstitute.held_mixed": "BORN AS A KEY (R-893, 2026-10-08) -- the hold sentence for that held app (HoldReasonRestoreMixed). Never a Go literal; pinned by internal/backup/r893_mixed_restore_test.go.",
|
||||
@@ -170,7 +173,6 @@
|
||||
"flash.backup.window_invalid_time": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
|
||||
"api.deploy.netstorage_unreachable": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
|
||||
"api.deploy.no_space": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
|
||||
"debug.api.reset_confirm_invalid": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
|
||||
"err.web.formazas_kesz_de_az_uj_fajlrendszer": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
|
||||
"err.selfupdate.mentes_fut_probalja_kesobb": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
|
||||
"err.stacks.field_required_password": "R-516 (2026-10-06) -- REWORDED on purpose: the formal („ön\") verb forms in this sentence became the product's te-form; the row is about exactly these bytes, so byte parity with the base literal cannot hold. Pinned by TestR516_WidenedFormalFormsAreGone.",
|
||||
@@ -676,8 +678,6 @@
|
||||
"debug.api.gitea_reachable": "Gitea elérhető (HTTP %d, %dms)",
|
||||
"debug.api.telemetry_collected": "Telemetria összegyűjtve: %d app, %d hiba, %d figyelmeztetés (%dms)",
|
||||
"debug.api.selfupdate_unconfigured": "Self-update nincs konfigurálva",
|
||||
"debug.api.marker_write_failed": "Marker fájl írási hiba: %v",
|
||||
"debug.api.restarting_to_setup": "Controller újraindítása setup módba...",
|
||||
"debug.api.agent_unconfigured": "Az ügynök nincs konfigurálva ezen a rendszeren.",
|
||||
"debug.api.agent_logs_unsupported": "Az ügynök naplónézete az ügynök következő frissítése után érhető el.",
|
||||
"debug.api.bundles_found": "%d csomag található",
|
||||
|
||||
@@ -63,7 +63,8 @@ BASE_FILE = os.path.join(HERE, "i18n_go_base.json")
|
||||
KEYS_FILE = os.path.join(HERE, "i18n_go_keys.json")
|
||||
|
||||
# Directories whose Go literals are IN SCOPE for the capture. cmd/ and internal/ minus the
|
||||
# first-boot wizard, which is out of scope and slated for deletion (R-554, 10-localisation.md s6).
|
||||
# first-boot wizard, which was out of scope and is DELETED in v0.308.0 (R-554). The skip stays: the frozen
|
||||
# base capture was taken with it, so a rescan must skip the same path to measure the same thing.
|
||||
SCOPE_ROOTS = ["internal", "cmd"]
|
||||
SCOPE_SKIP = [os.path.join("internal", "setup")]
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ to the signature characters below, so any hit is a defect. The allowlist is ZERO
|
||||
design — fix the text, don't allowlist it. (Windows grep false-negatives multibyte —
|
||||
this gate is Python by the multibyte rule, like emoji_gate.)
|
||||
|
||||
Scans every template (web + setup) and every Go source file:
|
||||
Scans every template (web) and every Go source file:
|
||||
- the file must decode as strict UTF-8 (undecodable bytes = broken already), and
|
||||
- the decoded text must contain none of the double-encoding signature characters:
|
||||
à (U+00C3)  (U+00C2) Ă (U+0102) ă (U+0103) ˘ (U+02D8) ˇ (U+02C7)
|
||||
@@ -19,7 +19,6 @@ import io, os, sys
|
||||
|
||||
ROOTS = [
|
||||
os.path.join("internal", "web", "templates"),
|
||||
os.path.join("internal", "setup", "templates"),
|
||||
"internal", # every .go string literal / comment
|
||||
"cmd",
|
||||
]
|
||||
|
||||
@@ -16,7 +16,6 @@ import i18n_bundle # noqa: E402
|
||||
|
||||
ROOTS = [
|
||||
os.path.join("internal", "web", "templates"),
|
||||
os.path.join("internal", "setup", "templates"),
|
||||
]
|
||||
|
||||
# A bare confirm(/prompt(/alert( CALL with an argument: not preceded by an identifier character,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""D1 §10 JS element-ID integrity gate.
|
||||
|
||||
For every template under internal/web/templates and internal/setup/templates, extract each
|
||||
For every template under internal/web/templates (the setup wizard templates are deleted, R-554), extract each
|
||||
getElementById('X') / querySelector('#X') literal used by the file's inline JS and assert an
|
||||
id="X" exists in the SAME file — or that the ID is created by that file's own JS (innerHTML /
|
||||
insertAdjacentHTML string containing id="X" / id='X'), or is explicitly allowlisted below with
|
||||
@@ -14,7 +14,6 @@ import io, os, re, sys
|
||||
|
||||
ROOTS = [
|
||||
os.path.join("internal", "web", "templates"),
|
||||
os.path.join("internal", "setup", "templates"),
|
||||
]
|
||||
|
||||
# Dynamic-ID exceptions: (template, id-prefix-or-name) -> justification.
|
||||
|
||||
Reference in New Issue
Block a user