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:
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user