diff --git a/CHANGELOG.md b/CHANGELOG.md index 75dd0fe..a9eaeaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ ## Changelog +### v0.43.0 — rebuilt storage management (guided init/attach/eject on the agent disk model) (2026-06-11) + +After the 8C de-privileging, the storage UI's buttons pointed at deleted routes (`/settings/storage/init`, +`/attach`, `/migrate-drive`, per-stack `/migrate`) — all 404. Everything underneath already worked (the +agent owns disk execution + the data-bearing signature gate; the controller has the `agentapi` client + +`/api/disks/*` proxies + the `StoragePath` registry). This is a controller-only UI/orchestration layer +over those. + +- **Storage overview** (`settings.html`, driven by `GET /api/disks`): the agent's live disk view — name, + type, state, device, mount, class, and the **`data_bearing` badge** + registered cross-reference. +- **Guided init** (`/settings/storage/init` + `POST /api/storage/init`): pick a disk → format → resolve + the new fs UUID from the re-listed disks → assign (mount) → register the `StoragePath`. **A data-bearing + device is REFUSED** by the agent; the UI surfaces the exact `felhom-opsign -op storage_wipe -host … -durable-id …` + command and stops — **there is no force-format path** (the gate is the agent's; the controller has no + destructive authority). +- **Guided attach** (`/settings/storage/attach` + `POST /api/storage/attach`): non-destructive — resolve + the existing fs UUID → assign → register. +- **Eject** (`POST /api/storage/eject`): benign unmount (data preserved) + deregister, surfacing the + agent's dependent-guest warning. +- **`agentapi`**: `DiskInfo` gains `DurableID` (+ `FSUUID()` to strip the `uuid:` prefix — the assign + key); `FormatResult` gains `PendingOp` (+ `OpsignCommand()`), now parsed from the agent's 403 body + (the old path discarded it). Pairs with `felhom-agent` v0.22.0, which exposes `durable_id` in `/disks`. +- **Honest buttons**: init/attach are wired; migrate (drive + per-stack) is disabled "Hamarosan" — no 404s. +- **De-priv template debt (Phase 3)**: removed the dead `CrossDrive*` blocks in `deploy.html` (the "2. + mentés" form + 3 JS fns) and `backups.html` (the run buttons + 2 JS fns) — they referenced fields the + de-privileged handlers no longer provide (a `gt/eq` over a missing field 500s the page). +- Migration (controller-side rsync) is intentionally deferred to its own slice (the migrate buttons are + disabled, not dead). +- Tests: the init refusal surfaces the `pending_op`/opsign and performs **no** assign/register; success + assigns with the resolved UUID + registers the expected `StoragePath`; a template-parse test guards all + pages. + ### v0.42.1 — real Let's Encrypt cert: wildcard proactive issuance via the controller route (2026-06-11) The base-infra traefik obtained **no** real cert (acme.json empty) — both routers relied on the diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 6ef69e6..789ec1a 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -677,6 +677,8 @@ func main() { // disk execution; the controller forwards list/assign/eject/format. mux.Handle("/api/disks", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI)))) mux.Handle("/api/disks/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI)))) + // Guided storage provisioning (init/attach/eject orchestration over the agent disk API + registry). + mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI)))) // Host metrics API — thin proxy to the host agent (slice 9). Read-only host-wide health + // per-storage capacity for the monitoring view; the de-privileged controller can't read the // host itself. GET only, so no CSRF wrapper needed. diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 42b468f..99378de 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -186,6 +186,19 @@ type DiskInfo struct { Class string `json:"class"` DataBearing bool `json:"data_bearing"` DataReason string `json:"data_reason"` + // DurableID is the target's stable identity (e.g. "uuid:" for usb/local-dir). The + // fs UUID (strip the "uuid:" prefix) is the key the controller passes to AssignDisk — it's the + // only way the de-privileged controller learns a mount key it cannot read off the device itself. + DurableID string `json:"durable_id"` +} + +// FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity +// is not a filesystem UUID (network/lvm targets — not assignable as a host mount). +func (d DiskInfo) FSUUID() string { + if rest, ok := strings.CutPrefix(d.DurableID, "uuid:"); ok { + return rest + } + return "" } // DisksResponse mirrors GET /disks. @@ -196,11 +209,26 @@ type DisksResponse struct { // FormatResult mirrors POST /disks/format (the success/refusal payload). type FormatResult struct { - VMID int `json:"vmid"` - Device string `json:"device"` - Formatted bool `json:"formatted"` - DataBearing bool `json:"data_bearing"` - Reason string `json:"reason"` + VMID int `json:"vmid"` + Device string `json:"device"` + Formatted bool `json:"formatted"` + DataBearing bool `json:"data_bearing"` + Reason string `json:"reason"` + PendingOp *PendingOp `json:"pending_op,omitempty"` +} + +// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller +// surfaces the exact `felhom-opsign` command from it — it CANNOT complete a destructive format itself. +type PendingOp struct { + Op string `json:"op"` // e.g. "storage_wipe" + HostScope string `json:"host_scope"` // the agent's host id (anti-retarget) + DurableID string `json:"durable_id"` // byid:…|byuuid:… — the device's stable identity + FSType string `json:"fstype"` // the filesystem to mkfs after the wipe +} + +// OpsignCommand returns the literal command the operator must run offline to authorize the wipe. +func (p PendingOp) OpsignCommand() string { + return fmt.Sprintf("felhom-opsign -op %s -host %s -durable-id %s", p.Op, p.HostScope, p.DurableID) } // ErrFormatRefused is returned by FormatDisk when the agent refuses a data-bearing format @@ -253,20 +281,51 @@ func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, erro // is irrelevant. Only a device the agent reads as blank is formatted. func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatResult, error) { var out FormatResult - body, err := c.post(ctx, "/disks/format", map[string]string{"device": device, "fstype": fstype}) + // Status-aware POST: the agent returns the FULL FormatResponse (incl. pending_op) even on the + // 403 refusal, so we must read the body on non-2xx rather than discarding it. + data, status, err := c.postWithStatus(ctx, "/disks/format", map[string]string{"device": device, "fstype": fstype}) if err != nil { - // A data-bearing refusal comes back as HTTP 403 (the post helper turns it into an error). - if strings.Contains(err.Error(), "HTTP 403") { - return FormatResult{Device: device, Formatted: false, DataBearing: true}, ErrFormatRefused - } return out, err } - if err := json.Unmarshal(body, &out); err != nil { - return out, fmt.Errorf("agentapi: decode /disks/format: %w", err) + // data is the envelope's {data:…} payload (present on both success and the 403 refusal). + if len(data) > 0 { + _ = json.Unmarshal(data, &out) // best-effort; fields default on a missing/partial body + } + if status == http.StatusForbidden || (out.DataBearing && !out.Formatted) { + out.DataBearing = true + out.Formatted = false + return out, ErrFormatRefused // carries PendingOp for the caller to surface the opsign command } return out, nil } +// postWithStatus issues an authenticated JSON POST and returns the envelope's data payload + the HTTP +// status, even on a non-2xx (so callers like FormatDisk can read a 403 refusal body). A transport or +// envelope-parse failure is still an error; an `ok:false` business refusal is NOT (the data carries it). +func (c *Client) postWithStatus(ctx context.Context, path string, body any) (json.RawMessage, int, error) { + buf, err := json.Marshal(body) + if err != nil { + return nil, 0, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(buf)) + if err != nil { + return nil, 0, err + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + resp, err := c.hc.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("agentapi: POST %s: %w", path, err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + var env apiResponse + if err := json.Unmarshal(raw, &env); err != nil { + return nil, resp.StatusCode, fmt.Errorf("agentapi: POST %s: HTTP %d, bad envelope: %w", path, resp.StatusCode, err) + } + return env.Data, resp.StatusCode, nil +} + // ---- slice 9: host metrics (the customer host-health view) ------------------------------- // HostMetrics mirrors the agent's GET /host/metrics `host` block (shared HostMetrics wire shape). diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 65160dc..10de0d9 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -227,6 +227,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.settingsStorageSchedulableHandler(w, r) case path == "/settings/storage/label" && r.Method == http.MethodPost: s.settingsStorageLabelHandler(w, r) + case path == "/settings/storage/init" && r.Method == http.MethodGet: + s.storageWizardPageHandler(w, r, "storage_init") + case path == "/settings/storage/attach" && r.Method == http.MethodGet: + s.storageWizardPageHandler(w, r, "storage_attach") case path == "/backup/restore" && r.Method == http.MethodPost: s.backupRestoreHandler(w, r) case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"): diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go new file mode 100644 index 0000000..1001a08 --- /dev/null +++ b/controller/internal/web/storage_handlers.go @@ -0,0 +1,277 @@ +package web + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "path" + "regexp" + "strings" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// Guided storage provisioning (rebuilt on the agent-delegated disk model). The controller is a thin +// orchestrator over the agent's authoritative disk endpoints (format/assign/eject, all data-bearing- +// gated on the agent) + the local StoragePath registry. It holds NO destructive authority: a data- +// bearing format is REFUSED by the agent, and the only thing the controller does with that refusal is +// surface the exact `felhom-opsign` command — there is no force-format path here. + +// diskAgent is the subset of *agentapi.Client the orchestration needs (an interface so it's testable +// without a live agent). *agentapi.Client satisfies it. +type diskAgent interface { + Disks(ctx context.Context) (agentapi.DisksResponse, error) + FormatDisk(ctx context.Context, device, fstype string) (agentapi.FormatResult, error) + AssignDisk(ctx context.Context, uuid, where, fstype, options string) error + EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error) +} + +// mountNameRe is the safe `/mnt/` component (DNS-ish: letters, digits, _ , -). +var mountNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,40}$`) + +// validFSTypes are the filesystems the init flow offers (the agent re-validates). +var validFSTypes = map[string]bool{"ext4": true, "xfs": true} + +// mountWhere builds + validates the mount target from a user-supplied name → "/mnt/". +func mountWhere(name string) (string, error) { + name = strings.TrimSpace(name) + if !mountNameRe.MatchString(name) { + return "", fmt.Errorf("érvénytelen csatlakoztatási név (csak betűk, számok, _ és - engedélyezett)") + } + return "/mnt/" + name, nil +} + +// fsUUIDForDevice re-lists the agent's disks and returns the fs UUID of the storage backed by `device` +// (the only way the de-privileged controller learns the UUID it must pass to assign). "" if not found. +func fsUUIDForDevice(disks agentapi.DisksResponse, device string) string { + for _, d := range disks.Disks { + if d.BackingDevice == device { + return d.FSUUID() + } + } + return "" +} + +// storageInitResult is the outcome of an init attempt (JSON-rendered to the wizard). +type storageInitResult struct { + Registered bool `json:"registered"` + Where string `json:"where,omitempty"` + // Refusal (data-bearing): the operator must sign offline. No bypass. + Refused bool `json:"refused,omitempty"` + Reason string `json:"reason,omitempty"` + Opsign string `json:"opsign,omitempty"` +} + +// runStorageInit is the testable core of the init flow: format → (refuse?) → resolve new UUID → +// assign → register. On a data-bearing refusal it returns a result with Refused+Opsign and performs +// NO further (destructive or mount) action. +func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) { + if !validFSTypes[fstype] { + return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", fstype) + } + // 1. Format — the AGENT inspects the device and decides. A data-bearing device is refused. + fr, err := agent.FormatDisk(ctx, device, fstype) + if errors.Is(err, agentapi.ErrFormatRefused) { + res := storageInitResult{Refused: true, Reason: fr.Reason} + if fr.PendingOp != nil { + res.Opsign = fr.PendingOp.OpsignCommand() + } + return res, nil // STOP — no bypass; the UI surfaces Opsign. + } + if err != nil { + return storageInitResult{}, fmt.Errorf("formázás sikertelen: %w", err) + } + if !fr.Formatted { + return storageInitResult{}, fmt.Errorf("az eszköz nem lett megformázva (%s)", fr.Reason) + } + // 2. Resolve the NEW fs UUID (re-list disks; the device's storage now carries a fresh UUID). + disks, err := agent.Disks(ctx) + if err != nil { + return storageInitResult{}, fmt.Errorf("formázás kész, de a meghajtólista nem olvasható: %w", err) + } + uuid := fsUUIDForDevice(disks, device) + if uuid == "" { + return storageInitResult{}, fmt.Errorf("formázás kész, de az új fájlrendszer-azonosító nem feloldható — frissítsen és használja a Csatolás funkciót") + } + // 3. Mount (benign assign) + 4. register. + if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil { + return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err) + } + if err := s.registerStoragePath(where, label, setDefault); err != nil { + return storageInitResult{}, err + } + return storageInitResult{Registered: true, Where: where}, nil +} + +// runStorageAttach mounts an existing-filesystem device (non-destructive — never touches the gate) +// and registers it. The UUID is resolved server-side from the device. +func (s *Server) runStorageAttach(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) { + disks, err := agent.Disks(ctx) + if err != nil { + return storageInitResult{}, fmt.Errorf("a meghajtólista nem olvasható: %w", err) + } + uuid := fsUUIDForDevice(disks, device) + if uuid == "" { + return storageInitResult{}, fmt.Errorf("a kiválasztott meghajtóhoz nem található fájlrendszer-azonosító (csak fájlrendszerrel rendelkező meghajtó csatolható)") + } + if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil { + return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err) + } + if err := s.registerStoragePath(where, label, setDefault); err != nil { + return storageInitResult{}, err + } + return storageInitResult{Registered: true, Where: where}, nil +} + +// registerStoragePath records a freshly-mounted path in the StoragePath registry (schedulable by +// default) and refreshes the FileBrowser mounts so it's usable immediately. +func (s *Server) registerStoragePath(where, label string, setDefault bool) error { + if strings.TrimSpace(label) == "" { + label = settings.InferStorageLabel(where) + } + sp := settings.StoragePath{ + Path: where, + Label: label, + IsDefault: setDefault, + Schedulable: true, + AddedAt: time.Now().UTC().Format(time.RFC3339), + } + if err := s.settings.AddStoragePath(sp); err != nil { + return fmt.Errorf("regisztráció sikertelen: %w", err) + } + go s.SyncFileBrowserMounts() + return nil +} + +// ---- HTTP handlers (behind RequireAuth + CsrfProtect) ----------------------------------------- + +// storageWizardPageHandler renders the init/attach wizard page (the disk list + actions are driven +// client-side from GET /api/disks; the form posts to /api/storage/{init,attach}). +func (s *Server) storageWizardPageHandler(w http.ResponseWriter, r *http.Request, tmpl string) { + title := "Új meghajtó inicializálása" + if tmpl == "storage_attach" { + title = "Meglévő meghajtó csatolása" + } + data := s.baseData(tmpl, title) + s.render(w, tmpl, data) +} + +// ServeStorageAPI dispatches /api/storage/* (guided init/attach/eject orchestration). +func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/storage/init" && r.Method == http.MethodPost: + s.handleStorageInit(w, r) + case r.URL.Path == "/api/storage/attach" && r.Method == http.MethodPost: + s.handleStorageAttach(w, r) + case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost: + s.handleStorageEject(w, r) + default: + http.NotFound(w, r) + } +} + +type storageProvReq struct { + Device string `json:"device"` + FSType string `json:"fstype"` + MountName string `json:"mount_name"` + Label string `json:"label"` + SetDefault bool `json:"set_default"` +} + +func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) { + var req storageProvReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil) + return + } + where, err := mountWhere(req.MountName) + if err != nil { + writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil) + return + } + if req.Device == "" { + writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil) + return + } + agent, err := s.agentClient() + if err != nil { + writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + return + } + res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault) + if err != nil { + writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + return + } + if res.Refused { + writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res) + return + } + writeDiskJSON(w, http.StatusOK, true, "", res) +} + +func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) { + var req storageProvReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil) + return + } + where, err := mountWhere(req.MountName) + if err != nil { + writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil) + return + } + if req.Device == "" { + writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil) + return + } + agent, err := s.agentClient() + if err != nil { + writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + return + } + res, err := s.runStorageAttach(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault) + if err != nil { + writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + return + } + writeDiskJSON(w, http.StatusOK, true, "", res) +} + +// handleStorageEject unmounts a host mount (benign, data preserved) and DEREGISTERS its StoragePath. +// It surfaces the agent's dependent-guest warning. (Distinct from the signature-gated decommission.) +func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) { + var req struct { + Where string `json:"where"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil) + return + } + req.Where = path.Clean(strings.TrimSpace(req.Where)) + if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") { + writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil) + return + } + agent, err := s.agentClient() + if err != nil { + writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) + return + } + res, err := agent.EjectDisk(r.Context(), req.Where) + if err != nil { + writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + return + } + // Deregister the path (best-effort — the unmount already succeeded). + if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil { + s.logger.Printf("[WARN] [web] eject: unmounted %s but deregister failed: %v", req.Where, rerr) + } else { + go s.SyncFileBrowserMounts() + } + writeDiskJSON(w, http.StatusOK, true, "", res) +} diff --git a/controller/internal/web/storage_handlers_test.go b/controller/internal/web/storage_handlers_test.go new file mode 100644 index 0000000..5178364 --- /dev/null +++ b/controller/internal/web/storage_handlers_test.go @@ -0,0 +1,160 @@ +package web + +import ( + "context" + "io" + "log" + "path/filepath" + "testing" + "text/template" + + "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// TestTemplatesParse forces every HTML template (incl. the new storage wizards and the de-priv +// cleanups) to parse — they are otherwise only parsed at server startup (template.Must). +func TestTemplatesParse(t *testing.T) { + s := &Server{} + if _, err := template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"); err != nil { + t.Fatalf("templates parse: %v", err) + } +} + +// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action. +type mockAgent struct { + disks agentapi.DisksResponse + formatRes agentapi.FormatResult + formatErr error + assignErr error + assignCalls []assignCall + disksCalls int +} + +type assignCall struct{ uuid, where, fstype string } + +func (m *mockAgent) Disks(context.Context) (agentapi.DisksResponse, error) { + m.disksCalls++ + return m.disks, nil +} +func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string) (agentapi.FormatResult, error) { + return m.formatRes, m.formatErr +} +func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) error { + m.assignCalls = append(m.assignCalls, assignCall{uuid, where, fstype}) + return m.assignErr +} +func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectResult, error) { + return agentapi.EjectResult{Ejected: where}, nil +} + +func testServer(t *testing.T) *Server { + t.Helper() + lg := log.New(io.Discard, "", 0) + sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg) + if err != nil { + t.Fatalf("settings: %v", err) + } + return &Server{settings: sett, logger: lg, cfg: &config.Config{}} +} + +// SECURITY: a data-bearing refusal must surface the opsign command and perform NO assign/register. +func TestRunStorageInit_DataBearingRefusal(t *testing.T) { + s := testServer(t) + agent := &mockAgent{ + formatErr: agentapi.ErrFormatRefused, + formatRes: agentapi.FormatResult{ + Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature", + PendingOp: &agentapi.PendingOp{Op: "storage_wipe", HostScope: "host-1", DurableID: "byuuid:1234", FSType: "ext4"}, + }, + } + res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Refused { + t.Fatal("expected Refused=true on a data-bearing device") + } + if res.Opsign != "felhom-opsign -op storage_wipe -host host-1 -durable-id byuuid:1234" { + t.Errorf("opsign command not surfaced: %q", res.Opsign) + } + if len(agent.assignCalls) != 0 { + t.Fatalf("REFUSAL MUST NOT mount: got %d assign call(s)", len(agent.assignCalls)) + } + if len(s.settings.GetStoragePaths()) != 0 { + t.Fatal("REFUSAL MUST NOT register a StoragePath") + } +} + +// Happy path: format → resolve new fs UUID from the disk list → assign with that UUID → register. +func TestRunStorageInit_Success(t *testing.T) { + s := testServer(t) + agent := &mockAgent{ + formatRes: agentapi.FormatResult{Device: "/dev/sdb1", Formatted: true, DataBearing: false}, + disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{ + {Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-9999"}, + }}, + } + res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Registered || res.Where != "/mnt/hdd1" { + t.Fatalf("expected registered at /mnt/hdd1, got %+v", res) + } + if len(agent.assignCalls) != 1 || agent.assignCalls[0].uuid != "NEW-9999" || agent.assignCalls[0].where != "/mnt/hdd1" { + t.Fatalf("assign must use the resolved fs UUID + mount path: %+v", agent.assignCalls) + } + paths := s.settings.GetStoragePaths() + if len(paths) != 1 || paths[0].Path != "/mnt/hdd1" || paths[0].Label != "Külső HDD" || !paths[0].IsDefault || !paths[0].Schedulable { + t.Fatalf("StoragePath not registered as expected: %+v", paths) + } +} + +// Attach is non-destructive: resolve UUID → assign → register (no format). +func TestRunStorageAttach_Success(t *testing.T) { + s := testServer(t) + agent := &mockAgent{ + disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{ + {Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:EXISTING-42"}, + }}, + } + res, err := s.runStorageAttach(context.Background(), agent, "/dev/sdb1", "", "/mnt/media", "Média", false) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Registered { + t.Fatal("expected registered") + } + if len(agent.assignCalls) != 1 || agent.assignCalls[0].uuid != "EXISTING-42" { + t.Fatalf("attach must assign by the existing fs UUID: %+v", agent.assignCalls) + } +} + +func TestFSUUIDForDevice(t *testing.T) { + disks := agentapi.DisksResponse{Disks: []agentapi.DiskInfo{ + {BackingDevice: "/dev/sda1", DurableID: "uuid:AAAA"}, + {BackingDevice: "/dev/sdb1", DurableID: "store:lvm"}, // non-fs identity → no UUID + }} + if got := fsUUIDForDevice(disks, "/dev/sda1"); got != "AAAA" { + t.Errorf("fsUUIDForDevice(sda1) = %q, want AAAA", got) + } + if got := fsUUIDForDevice(disks, "/dev/sdb1"); got != "" { + t.Errorf("fsUUIDForDevice(non-fs) = %q, want empty", got) + } + if got := fsUUIDForDevice(disks, "/dev/sdc1"); got != "" { + t.Errorf("fsUUIDForDevice(absent) = %q, want empty", got) + } +} + +func TestMountWhere(t *testing.T) { + if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" { + t.Errorf("mountWhere(hdd_1) = %q, %v", w, err) + } + for _, bad := range []string{"", "../etc", "a/b", "x y", "/abs"} { + if _, err := mountWhere(bad); err == nil { + t.Errorf("mountWhere(%q) should be rejected", bad) + } + } +} diff --git a/controller/internal/web/templates/backups.html b/controller/internal/web/templates/backups.html index 67fa9e7..d9fe375 100644 --- a/controller/internal/web/templates/backups.html +++ b/controller/internal/web/templates/backups.html @@ -335,9 +335,6 @@ 📁
Beállítás -
{{else}} ✓ 1. mentés auto @@ -364,11 +361,6 @@ {{end}} - {{if .Backup.CrossDriveSummary}} -
- -
- {{end}} {{end}} @@ -604,50 +596,6 @@ function toggleTier(header) { } } -function triggerCrossDriveBackup(stackName, btn) { - btn.disabled = true; - btn.textContent = 'Fut...'; - fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST', headers: csrfHeaders()}) - .then(function(r) { return r.json(); }) - .then(function(d) { - if (!d.ok) { - alert('Hiba: ' + (d.error || 'Ismeretlen hiba')); - btn.disabled = false; - btn.textContent = 'Futtatás most'; - return; - } - btn.textContent = 'Fut...'; - setTimeout(function() { location.reload(); }, 5000); - }) - .catch(function(e) { - alert('Hálózati hiba: ' + e.message); - btn.disabled = false; - btn.textContent = 'Futtatás most'; - }); -} - -function triggerAllCrossDrive(btn) { - btn.disabled = true; - btn.textContent = 'Indítás...'; - fetch('/api/backup/cross-drive/run-all', {method: 'POST', headers: csrfHeaders()}) - .then(function(r) { return r.json(); }) - .then(function(d) { - if (!d.ok) { - alert('Hiba: ' + (d.error || 'Ismeretlen hiba')); - btn.disabled = false; - btn.textContent = 'Összes futtatása most'; - return; - } - btn.textContent = 'Mentések futnak...'; - setTimeout(function() { location.reload(); }, 5000); - }) - .catch(function(e) { - alert('Hálózati hiba: ' + e.message); - btn.disabled = false; - btn.textContent = 'Összes futtatása most'; - }); -} - function triggerBackupFromPage() { const btn = document.getElementById('backup-page-btn'); btn.disabled = true; diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index 540a46c..889d581 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -68,9 +68,9 @@ {{end}} {{if .OtherStoragePaths}} - + 📦 Mozgatás másik tárolóra - + {{end}} {{end}} @@ -116,94 +116,6 @@ Mentési állapot → - -
- -

2. mentés — másolat másik meghajtóra:

- - {{if .BackupDestWarning}} -
{{.BackupDestWarning}}
- {{end}} - - {{if not .BackupDestPaths}} -
- Másik adattároló szükséges a másolat készítéséhez. - Csatlakoztass egy külső meghajtót a Beállítások oldalon. -
- {{else}} -
- {{.CSRFField}} -
-
- Engedélyezve - -
-
- Cél tárhely - -
-
- Ütemezés -
- -
- ℹ Heti mentés esetén visszaállításkor az adatbázis is a mentés napjára áll vissza - a konzisztencia érdekében. A mentés napja és a visszaállítás között keletkezett - adatbázis-változások elvesznek (max. 7 nap). -
-
-
-
- - {{if .CrossDriveConfig}} - {{if .CrossDriveConfig.LastRun}} -
- Utolsó futás: {{.CrossDriveConfig.LastRun}} - {{if eq .CrossDriveConfig.LastStatus "ok"}}Sikeres{{else if eq .CrossDriveConfig.LastStatus "error"}}Hiba: {{.CrossDriveConfig.LastError}}{{else if eq .CrossDriveConfig.LastStatus "running"}}Fut...{{end}} - {{if .CrossDriveConfig.LastDuration}} ({{.CrossDriveConfig.LastDuration}}){{end}} - {{if .CrossDriveConfig.LastSizeHuman}} — {{.CrossDriveConfig.LastSizeHuman}}{{end}} -
- {{end}} - {{end}} - -
- - {{if and .CrossDriveConfig .CrossDriveConfig.Enabled}} - - {{end}} -
-
- -
- A cél meghajtó legyen más fizikai eszköz a meghibásodás elleni védelem érdekében. -
- {{end}} {{end}} @@ -775,62 +687,6 @@ function buildPostDeployCard(stackName) { return html; } -function toggleCrossDriveFields() { - var enabled = document.getElementById('cross-drive-enabled').checked; - var fields = document.querySelectorAll('.cross-drive-field'); - for (var i = 0; i < fields.length; i++) { - fields[i].disabled = !enabled; - } -} - -function onScheduleChange() { - var sel = document.getElementById('cd-schedule'); - var note = document.getElementById('weekly-note'); - if (sel && note) { - note.style.display = sel.value === 'weekly' ? 'block' : 'none'; - } -} - -function triggerCrossDriveBackup(stackName, btn) { - btn.disabled = true; - btn.textContent = 'Mentés folyamatban...'; - fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST', headers: csrfHeaders()}) - .then(function(r) { return r.json(); }) - .then(function(d) { - if (!d.ok) { - showAlert('Hiba: ' + (d.error || 'Ismeretlen hiba')); - btn.disabled = false; - btn.textContent = 'Mentés most'; - return; - } - btn.textContent = 'Mentés folyamatban...'; - // Poll status - var poll = setInterval(function() { - fetch('/api/stacks/' + stackName + '/cross-backup/status') - .then(function(r) { return r.json(); }) - .then(function(s) { - if (!s.ok || !s.data) return; - if (!s.data.running) { - clearInterval(poll); - var status = s.data.last_status; - if (status === 'ok') { - btn.textContent = 'Mentés kész'; - } else { - btn.textContent = 'Hiba'; - showAlert('Hiba: ' + (s.data.last_error || 'Ismeretlen hiba')); - } - setTimeout(function() { location.reload(); }, 2000); - } - }).catch(function(){}); - }, 3000); - }) - .catch(function(e) { - showAlert('Hálózati hiba: ' + e.message); - btn.disabled = false; - btn.textContent = 'Mentés most'; - }); -} - function checkStorageSpace(sel) { var opt = sel.options[sel.selectedIndex]; var warn = document.getElementById('storage-space-warn'); diff --git a/controller/internal/web/templates/settings.html b/controller/internal/web/templates/settings.html index 1da3412..10ac502 100644 --- a/controller/internal/web/templates/settings.html +++ b/controller/internal/web/templates/settings.html @@ -289,7 +289,7 @@ function pollUntilBack() {
{{.Name}} {{if .SizeHuman}}{{.SizeHuman}}{{end}} - 📦 Mozgatás + 📦 Mozgatás
{{end}} @@ -334,7 +334,7 @@ function pollUntilBack() { {{end}} {{if and (gt .AppCount 0) .HasOtherPaths}} - 📦 Összes adat átköltöztetése + 📦 Összes adat átköltöztetése {{end}} {{end}} @@ -352,6 +352,53 @@ function pollUntilBack() { 🔗 Meglévő meghajtó csatolása +
+

Meghajtók (ügynök nézet)

+

A host-ügynök által észlelt meghajtók élő nézete (a tárolás végrehajtása az ügynöké).

+
Betöltés…
+
+ +
Már csatlakoztatott tárhely hozzáadása kézzel
diff --git a/controller/internal/web/templates/storage_attach.html b/controller/internal/web/templates/storage_attach.html new file mode 100644 index 0000000..08748be --- /dev/null +++ b/controller/internal/web/templates/storage_attach.html @@ -0,0 +1,97 @@ +{{define "storage_attach"}} +{{template "layout_start" .}} + + + +
+

1. Meghajtó kiválasztása

+

Válassza ki a már fájlrendszerrel rendelkező meghajtót. + A meghajtón lévő adatok nem törlődnek — a csatolás csak elérhetővé teszi azokat.

+ +
Betöltés…
+
+ +
+ + + +{{template "layout_end" .}} +{{end}} diff --git a/controller/internal/web/templates/storage_init.html b/controller/internal/web/templates/storage_init.html new file mode 100644 index 0000000..bd7170d --- /dev/null +++ b/controller/internal/web/templates/storage_init.html @@ -0,0 +1,125 @@ +{{define "storage_init"}} +{{template "layout_start" .}} + + + +
+

1. Meghajtó kiválasztása

+

Válassza ki a formázandó meghajtót. A formázás biztonságát a host-ügynök + garantálja: adatot tartalmazó meghajtó nem formázható operátori aláírás nélkül.

+ +
Betöltés…
+
+ + + + + +{{template "layout_end" .}} +{{end}}