v0.44.0: role-aware drive management — protected lockout + customer type-to-confirm wipe + drive-list restyle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:44:50 +02:00
parent 2c32c821fe
commit 12064dcd88
13 changed files with 696 additions and 182 deletions
+36
View File
@@ -1,5 +1,41 @@
## Changelog ## Changelog
### v0.44.0 — role-aware drive management: protected lockout + customer type-to-confirm wipe + drive-list restyle (2026-06-11)
The controller half of the storage-authorization redesign. The drive UI is now driven by the agent's
authoritative **role** (`system` | `backup` | `user-data`, from `GET /api/disks`): the appliance's own
system storage and the backup safety-net are visibly protected with NO destructive controls; the
customer manages their own data drives with informed consent instead of a support ticket.
- **`agentapi` client** — `DiskInfo` gains `role` + capacity (`total_bytes`/`used_bytes`/
`used_fraction`); `FormatResult` gains `role`/`needs_confirmation`/`durable_id`. `FormatDisk` now
takes `confirmed` + `durableID` and returns a new `ErrNeedsConfirmation` (user-data, awaiting the
customer's confirmation) distinct from `ErrFormatRefused` (system/backup, operator signature).
- **Role-aware overview** (`settings.html`, "Meghajtók (ügynök nézet)") — restyled from a raw
`<table>` to **cards** in the house style: prominent storage name, mono device/mount detail, badges
for class (gyors/lassú), data ("Adatot tartalmaz"), **role** (🔒 Rendszer / 🔒 Biztonsági mentés —
védett / Felhasználói adat) and registered state, plus a **capacity bar** (the monitoring page's
green→amber→red `system-bar`). Destructive controls (Leválasztás / Törlés) render **only** for
user-data drives mounted under `/mnt`. System/backup get the lock badge and no controls.
- **Type-to-confirm + name-the-apps** — a modal that (1) lists, **by name**, the deployed apps whose
data lives on the drive (`GET /api/storage/impact``appsUsingPath`), and (2) requires the customer
to **type the mount name** before the destructive button enables. No reflex-clickable destructive
action. Applies to both eject and wipe.
- **Customer wipe** (`POST /api/storage/wipe`) — eject (unmount + deregister) then a server-side
two-step customer-confirmed format (learn the agent's durable id, then re-submit `confirmed:true`
bound to it). The mount name is re-checked server-side. A system/backup device is refused by the
agent regardless of what the controller sends.
- **Init wizard** (`storage_init.html`) — the data-bearing path now uses the **customer-confirmation**
flow (type-to-confirm → re-submit confirmed) instead of the `felhom-opsign` instruction; the disk
selector is restyled to cards and lists only user-data targets. `storage_attach.html` likewise
restyled (cards, user-data only). No raw `<table>` remains in the storage UI.
- **Tests** — `agentapi`: blank → ok, system/backup → ErrFormatRefused (+pending op), user-data →
ErrNeedsConfirmation (+durable id), confirmed → formatted. `web`: init surfaces NeedsConfirmation and
does NO mount/register; confirmed init forwards the confirmation+durable id and proceeds; the
dependency-impact (`appsUsingPathIn`) names the right deployed apps.
Pairs with **felhom-agent v0.23.0** (the authoritative role classifier + the tiered wipe gate).
### v0.43.0 — rebuilt storage management (guided init/attach/eject on the agent disk model) (2026-06-11) ### 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`, After the 8C de-privileging, the storage UI's buttons pointed at deleted routes (`/settings/storage/init`,
+53 -53
View File
@@ -1,61 +1,61 @@
# REPORT — v0.43.0: rebuilt storage management (guided init/attach/eject on the agent disk model) # REPORT — felhom-controller v0.44.0: role-aware drive management + customer type-to-confirm wipe
**Repo:** `felhom-controller` · **Version:** 0.43.0 · **Date:** 2026-06-11 **Repo:** `felhom-controller` · **Version:** 0.44.0 · **Date:** 2026-06-11 · pairs with **felhom-agent v0.23.0**
**Pushed commit:** `29a9dcd` · paired with `felhom-agent` v0.22.0 (`4734d4a`, exposes `durable_id`) + golden rebake.
## What shipped ## What this implements
After the 8C de-privileging the storage UI's buttons pointed at deleted routes (all 404); only manual The controller half of the storage-authorization redesign (CC SPEC, Part B). The drive UI is driven by
"add already-mounted path" survived. The agent already owns disk execution + the data-bearing signature the agent's authoritative **role** (`system` | `backup` | `user-data`): system/backup are visibly
gate, and the controller already had the `agentapi` client + `/api/disks/*` proxies + the `StoragePath` protected with no destructive controls; the customer manages their own data drives with informed
registry. This is a **controller-only UI/orchestration layer** over those — the controller holds **no consent (type-to-confirm + named app impact).
destructive authority**.
- **Storage overview** (`settings.html`, `GET /api/disks`): the agent's live disk view — name/type/state/ ### B0 — `agentapi` client
device/mount/class + the **`data_bearing` badge** + a "registered?" cross-reference. - `DiskInfo` += `role`, `total_bytes`, `used_bytes`, `used_fraction`.
- **Guided init** (`/settings/storage/init` + `POST /api/storage/init`): format → resolve the new fs UUID - `FormatResult` += `role`, `needs_confirmation`, `durable_id`.
from the re-listed disks → assign (mount) → register the `StoragePath`. **A data-bearing device is - `FormatDisk(ctx, device, fstype, confirmed, durableID)` — new `ErrNeedsConfirmation` (user-data,
REFUSED** by the agent; the UI surfaces the exact `felhom-opsign …` command and **stops** — no force-format. awaiting the customer's confirmation) vs `ErrFormatRefused` (system/backup, operator signature).
- **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 + deregister, surfacing the agent's dependent-guest warning.
- **`agentapi`**: `DiskInfo.DurableID` + `FSUUID()` (the assign key — strip `uuid:`); `FormatResult.PendingOp`
+ `OpsignCommand()`, now parsed from the agent's 403 body (the old client discarded it).
- **Honest buttons**: init/attach wired; migrate (drive + per-stack, both places) disabled "Hamarosan" — **no 404s**.
- **Phase 3 (de-priv template debt)**: removed the dead `CrossDrive*` blocks in `deploy.html` (the "2.
mentés" form + 3 JS fns) and `backups.html` (run buttons + 2 JS fns) — they referenced fields the
de-privileged handlers no longer provide.
## Security invariant — held, proven live ### B1 — Role-aware overview (no destructive controls on protected drives)
The UI **never** bypasses the agent's data-bearing gate; there is **no force-format**. A refusal surfaces - `settings.html` "Meghajtók (ügynök nézet)" restyled from a raw `<table>` to **cards** (house style):
the `felhom-opsign` command only. Unit-tested (`runStorageInit` on a data-bearing refusal performs **zero** name prominent, mono device/mount sub-detail, badges for class / data / **role** (🔒 lock for
assign/register) **and** proven live on 9201's real `sdb`: system & backup) / registered, and a **capacity bar** reusing the monitoring `system-bar`
`POST /api/storage/init {device:/dev/sdb1}`**HTTP 409**, `refused:true`, `registered:false`, (green→amber→red). Eject/Wipe controls render **only** for user-data drives mounted under `/mnt`.
`opsign: felhom-opsign -op storage_wipe -host demo-felhom-01 -durable-id byid:wwn-0x5000039ddb108568-part1`.
No format, no mount, no registration.
## Live validation (guest 9201, real 1TB USB `sdb` = `felhom-usb`) ### B2 — Customer wipe/eject flow
- `/api/disks` now carries `durable_id`; `felhom-usb``/dev/sdb1`, `data_bearing:true` ("device is - **Name the apps**: `GET /api/storage/impact?where=``appsUsingPath` → the deployed apps (by display
mounted"), `durable_id:uuid:277a2179-…`. Overview badge maps correctly. name) whose `HDD_PATH` is that mount. Shown in the modal before any destructive action.
- **Init on sdb (data-bearing) → 409 + opsign, gate held** (the spec's passing gate test — sdb holds data). - **Type-to-confirm**: a modal with a text field; the destructive button stays disabled until the typed
- Pages render (no 404/500): `/settings`, `/settings/storage/init`, `/settings/storage/attach`, value equals the mount name exactly (enforced client-side AND server-side in `/api/storage/wipe`).
`/stacks/<app>/deploy` (deploy.html — CrossDrive removed), `/stacks`, `/monitoring`. No dead storage links. - **Wipe** (`POST /api/storage/wipe`): eject (unmount + deregister) → server-side two-step
- Tests: refusal-surfaces-opsign-and-does-NOT-mount/register; success assigns with the resolved UUID + customer-confirmed format (learn the agent's durable id via the NeedsConfirmation response, then
registers the expected `StoragePath`; UUID resolution; a **template-parse test** guards every page. re-submit `confirmed:true` bound to it). Deregisters the StoragePath.
## Deferred / flagged (NOT in this slice) ### B3 — Init/attach role-gated + restyled
- **Phase 2 — migration (controller-side rsync):** intentionally its own slice (the migrate buttons are - `storage_init.html`: data-bearing path now uses the **customer-confirmation** flow (type-to-confirm →
disabled "Hamarosan", not dead). The controller still has `/mnt:/mnt:rw`, so it can rsync app-data re-submit confirmed) instead of the `felhom-opsign` instruction; selector restyled to cards, lists
between mounts + update `app.yaml`'s `HDD_PATH` (stop→rsync→verify→start) — no agent endpoint needed. **only user-data** targets (system/backup are not offered). The opsign surface remains as a fallback
- **`/backups` still 500s on PRE-EXISTING restic debt (NOT this change, NOT CrossDrive).** The page if a protected device somehow reaches it.
references ~30 dead restic-tier fields (`.Backup.RepoStats`, `.SnapshotHistory`, `.ResticSchedule`, - `storage_attach.html`: restyled to cards, user-data only.
`.Retention`, `.LastBackup`, `.NextBackup`, `.LastCheckOK`, …) that 8C removed from the backend — the
whole restic snapshot tier + repo stats + snapshot history + restic-password UI is dead. That's a
**backups-page de-priv rebuild** (a design slice: what the page shows in the app-data-only model), well
beyond the CrossDrive cleanup this spec scoped (the spec listed "backups.html (5)" = the CrossDrive refs,
which I removed). `/backups` was already 500'ing before this task. **Recommend it as the next slice.**
## Notes ### B4 — UI polish
- No agent disk-subsystem or gate changes; the only agent change is the read-only `durable_id` exposure - New CSS appended to `style.css` (reusing existing tokens): `.drive-card` / `.drive-badges` /
(v0.22.0) the user approved (without it the de-privileged controller can't learn the fs UUID `assign` `.drive-cap`, the missing `.badge-ok` / `.badge-lock` / `.badge-muted` / standalone `.mono`, and the
needs). Golden rebaked with controller 0.43.0 so fresh provisions get the rebuilt UI. type-to-confirm `.confirm-overlay` / `.confirm-box`. No new design system; no raw table left.
## Tests
- `internal/agentapi/disks_test.go` — blank → ok; system/backup → `ErrFormatRefused` (+pending op);
user-data → `ErrNeedsConfirmation` (+durable id + role); confirmed → formatted.
- `internal/web/storage_handlers_test.go` — init on a user-data data-bearing device returns
NeedsConfirmation and does NO mount/register; confirmed init forwards the confirmation+durable id and
proceeds to register; system/backup init still surfaces the opsign; `appsUsingPathIn` names the right
deployed apps (dependency-impact). Plus `TestTemplatesParse` (all templates parse).
`go build ./... && go vet ./... && go test ./...` all green (Go 1.26, local).
## Version
`v0.43.0 → v0.44.0` (build-time ldflags `Version`).
## Live validation / deploy
Pending in this session: build+push the image, deploy to guest 9201, rebake the golden, and run the
live checks (overview tiers, the hand-issued `confirmed:true` refusal on a system/backup device, a
user-data confirmed wipe binding to the durable id, and the audit-log entry).
+26 -13
View File
@@ -514,23 +514,36 @@ not just those with HDD data. Non-HDD apps can configure destination, method, an
### 4. Storage Management ### 4. Storage Management
> **⚠️ Rebuilt on the agent-delegated disk model (v0.43.0).** After the 8C de-privileging, the controller > **⚠️ Rebuilt on the agent-delegated disk model (v0.43.0), made ROLE-AWARE in v0.44.0.** After the 8C
> holds **no Proxmox/disk credentials and no destructive authority** — disk execution + the data-bearing > de-privileging, the controller holds **no Proxmox/disk credentials and no destructive authority** — disk
> signature gate live entirely in the **host agent**. The controller is now a thin presenter/orchestrator: > execution + the gate live entirely in the **host agent**. The drive UI is driven by the agent's
> - **Overview** (`settings.html` ← `GET /api/disks`): the agent's live disk view (name/type/state/device/ > authoritative **role** (`system` | `backup` | `user-data`, from `GET /api/disks`): the appliance's own
> mount/class) + the **`data_bearing`** badge + "registered?" cross-reference. > system storage and the backup safety-net are visibly **protected** (lock badge, NO destructive controls);
> the customer manages their own **user-data** drives with informed consent. The agent re-enforces role at
> wipe time — the UI lockout is defense-in-depth, not the gate.
> - **Overview** (`settings.html` ← `GET /api/disks`): styled **cards** (not a table) — name, mono
> device/mount, badges for class (gyors/lassú), data (`Adatot tartalmaz`), **role** (🔒 Rendszer / 🔒
> Biztonsági mentés — védett / Felhasználói adat) and registered state, plus a **capacity bar** (the
> monitoring `system-bar`, from the agent's `total_bytes`/`used_bytes`). Eject/Wipe render **only** for
> user-data drives mounted under `/mnt`.
> - **Customer wipe/eject** — a **type-to-confirm** modal that names the deployed apps that break
> (`GET /api/storage/impact` → `appsUsingPath`) and disables the destructive button until the **mount
> name is typed exactly**. Wipe (`POST /api/storage/wipe`): eject (unmount + deregister) → server-side
> two-step customer-confirmed format (learn the agent's durable id, then re-submit `confirmed:true` bound
> to it). The agent refuses a protected device regardless of what the controller sends.
> - **Guided init** (`/settings/storage/init`, `POST /api/storage/init`, `web/storage_handlers.go`): format > - **Guided init** (`/settings/storage/init`, `POST /api/storage/init`, `web/storage_handlers.go`): format
> → resolve the new fs UUID from the re-listed disks (`durable_id`, `uuid:`-stripped) → `assign` (mount) > → resolve the new fs UUID → `assign` → register. The selector lists **only user-data** targets. A
> → register a `StoragePath`. **A data-bearing device is REFUSED by the agent** (`pending_op`); the UI > data-bearing user-data device now uses the **customer-confirmation** flow (type-to-confirm → re-submit
> surfaces the exact `felhom-opsign -op storage_wipe -host … -durable-id …` command and stops — **there > `confirmed:true` + durable id), NOT the `felhom-opsign` command. The opsign surface remains a fallback
> is no force-format**. The agent's `data_bearing` verdict (it inspects the device) is ground truth. > only if a protected device somehow reaches init.
> - **Guided attach** (`/settings/storage/attach`, `POST /api/storage/attach`): non-destructive — resolve > - **Guided attach** (`/settings/storage/attach`, `POST /api/storage/attach`): non-destructive — resolve
> the existing fs UUID → `assign` → register. > the existing fs UUID → `assign` → register. Selector restyled to cards (user-data only).
> - **Eject** (`POST /api/storage/eject`): benign unmount + deregister, with the agent's dependent-guest warning. > - **Eject** (`POST /api/storage/eject`): benign unmount + deregister, with the agent's dependent-guest warning.
> - **`agentapi`** (`internal/agentapi`) is the pinned client to the agent local API: `Disks`/`AssignDisk`/ > - **`agentapi`** (`internal/agentapi`) is the pinned client to the agent local API: `Disks`/`AssignDisk`/
> `EjectDisk`/`FormatDisk`; `DiskInfo.FSUUID()` + `FormatResult.PendingOp.OpsignCommand()`. > `EjectDisk`/`FormatDisk(…, confirmed, durableID)`; `DiskInfo.role`+capacity;
> - The **`StoragePath` registry** (`settings.go`: `AddStoragePath`/default/schedulable/label) is unchanged — > `FormatResult.{role,needs_confirmation,durable_id}`; `ErrNeedsConfirmation` (user-data) vs
> init/attach register into it; the existing per-path management handlers stay. > `ErrFormatRefused` (system/backup). `FormatResult.PendingOp.OpsignCommand()` for the operator path.
> - The **`StoragePath` registry** (`settings.go`: `AddStoragePath`/default/schedulable/label) is unchanged.
> - **Migration** (drive + per-stack) is **deferred** to its own slice (buttons disabled "Hamarosan"). > - **Migration** (drive + per-stack) is **deferred** to its own slice (buttons disabled "Hamarosan").
> >
> The privileged controller-side disk subsections **below are historical** (the `internal/storage/*` scan/ > The privileged controller-side disk subsections **below are historical** (the `internal/storage/*` scan/
+53 -20
View File
@@ -184,8 +184,15 @@ type DiskInfo struct {
BackingDevice string `json:"backing_device"` BackingDevice string `json:"backing_device"`
MountPath string `json:"mount_path"` MountPath string `json:"mount_path"`
Class string `json:"class"` Class string `json:"class"`
DataBearing bool `json:"data_bearing"` // Role is the agent's AUTHORITATIVE protection tier: "system" | "backup" | "user-data". The UI
DataReason string `json:"data_reason"` // is driven from it — system/backup get a lock badge and NO destructive controls; user-data is
// customer-manageable (eject/wipe with type-to-confirm).
Role string `json:"role"`
DataBearing bool `json:"data_bearing"`
DataReason string `json:"data_reason"`
TotalBytes int64 `json:"total_bytes"`
UsedBytes int64 `json:"used_bytes"`
UsedFraction float64 `json:"used_fraction"`
// DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The // DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The
// fs UUID (strip the "uuid:" prefix) is the key the controller passes to AssignDisk — it's 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. // only way the de-privileged controller learns a mount key it cannot read off the device itself.
@@ -209,12 +216,19 @@ type DisksResponse struct {
// FormatResult mirrors POST /disks/format (the success/refusal payload). // FormatResult mirrors POST /disks/format (the success/refusal payload).
type FormatResult struct { type FormatResult struct {
VMID int `json:"vmid"` VMID int `json:"vmid"`
Device string `json:"device"` Device string `json:"device"`
Formatted bool `json:"formatted"` Formatted bool `json:"formatted"`
DataBearing bool `json:"data_bearing"` DataBearing bool `json:"data_bearing"`
Reason string `json:"reason"` Reason string `json:"reason"`
PendingOp *PendingOp `json:"pending_op,omitempty"` // Role is the agent's tier for this device (system | backup | user-data).
Role string `json:"role,omitempty"`
// NeedsConfirmation is set on a USER-DATA data-bearing refusal: re-submit with confirmed=true +
// DurableID after the type-to-confirm UI (NOT an operator signature).
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
DurableID string `json:"durable_id,omitempty"`
// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the operator-signature op.
PendingOp *PendingOp `json:"pending_op,omitempty"`
} }
// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller // PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller
@@ -231,9 +245,14 @@ func (p PendingOp) OpsignCommand() string {
return fmt.Sprintf("felhom-opsign -op %s -host %s -durable-id %s", p.Op, p.HostScope, p.DurableID) 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 // ErrFormatRefused is returned by FormatDisk when the agent refuses a data-bearing format on a
// (pending operator authorization — the 8C invariant). The UI surfaces this distinctly. // SYSTEM/BACKUP device (operator signature required). The UI surfaces the pending opsign command.
var ErrFormatRefused = fmt.Errorf("agentapi: format refused — device is data-bearing (operator authorization required)") var ErrFormatRefused = fmt.Errorf("agentapi: format refused — system/backup device (operator authorization required)")
// ErrNeedsConfirmation is returned by FormatDisk when the agent refuses a data-bearing format on a
// USER-DATA device pending the CUSTOMER's informed-confirmation (bound to FormatResult.DurableID).
// The UI surfaces the type-to-confirm flow, then re-submits with confirmed=true + that durable id.
var ErrNeedsConfirmation = fmt.Errorf("agentapi: format needs customer confirmation — user-data device")
// Disks lists the host drives the agent manages, with a data-bearing flag per drive. // Disks lists the host drives the agent manages, with a data-bearing flag per drive.
func (c *Client) Disks(ctx context.Context) (DisksResponse, error) { func (c *Client) Disks(ctx context.Context) (DisksResponse, error) {
@@ -276,14 +295,22 @@ func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, erro
return out, nil return out, nil
} }
// FormatDisk asks the agent to format a device. The AGENT inspects the device and decides // FormatDisk asks the agent to format a device. The AGENT inspects the device and tiers it by ROLE
// data-bearing-ness — a data-bearing device is refused (ErrFormatRefused), the controller's claim // (its own classification, never the controller's claim):
// is irrelevant. Only a device the agent reads as blank is formatted. // - blank device → formatted.
func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatResult, error) { // - user-data, data-bearing, NOT confirmed → ErrNeedsConfirmation (out.DurableID = the id to confirm).
// - user-data, data-bearing, confirmed + matching durable id → formatted.
// - system/backup, data-bearing → ErrFormatRefused (out.PendingOp = the operator opsign command).
//
// confirmed + durableID authorize a user-data wipe (the durable id the agent gave on the prior
// ErrNeedsConfirmation); they are inert for system/backup.
func (c *Client) FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (FormatResult, error) {
var out FormatResult var out FormatResult
// Status-aware POST: the agent returns the FULL FormatResponse (incl. pending_op) even on the // Status-aware POST: the agent returns the FULL FormatResponse (incl. pending_op / durable_id)
// 403 refusal, so we must read the body on non-2xx rather than discarding it. // 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}) data, status, err := c.postWithStatus(ctx, "/disks/format", map[string]any{
"device": device, "fstype": fstype, "confirmed": confirmed, "durable_id": durableID,
})
if err != nil { if err != nil {
return out, err return out, err
} }
@@ -291,10 +318,16 @@ func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatR
if len(data) > 0 { if len(data) > 0 {
_ = json.Unmarshal(data, &out) // best-effort; fields default on a missing/partial body _ = json.Unmarshal(data, &out) // best-effort; fields default on a missing/partial body
} }
if out.Formatted {
return out, nil
}
if out.NeedsConfirmation {
out.DataBearing = true
return out, ErrNeedsConfirmation // user-data: surface the type-to-confirm flow
}
if status == http.StatusForbidden || (out.DataBearing && !out.Formatted) { if status == http.StatusForbidden || (out.DataBearing && !out.Formatted) {
out.DataBearing = true out.DataBearing = true
out.Formatted = false return out, ErrFormatRefused // system/backup: surface the opsign command
return out, ErrFormatRefused // carries PendingOp for the caller to surface the opsign command
} }
return out, nil return out, nil
} }
+46 -10
View File
@@ -22,14 +22,21 @@ func diskStub(t *testing.T) (*httptest.Server, string) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"ejected":"/mnt/bulk","dependent_guests":[8200]}}`)) _, _ = w.Write([]byte(`{"ok":true,"data":{"ejected":"/mnt/bulk","dependent_guests":[8200]}}`))
}) })
mux.HandleFunc("POST /disks/format", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("POST /disks/format", func(w http.ResponseWriter, r *http.Request) {
var body struct{ Device, FSType string } var body struct {
_ = decodeJSON(r, &body) Device, FSType, DurableID string
if strings.Contains(body.Device, "data") { Confirmed bool
w.WriteHeader(http.StatusForbidden) }
_, _ = w.Write([]byte(`{"ok":false,"error":"device is data-bearing — operator authorization (pending_signature)"}`)) _ = decodeJSON(r, &body)
return switch {
case strings.Contains(body.Device, "protected"): // system/backup → operator signature
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"ok":false,"data":{"device":"` + body.Device + `","data_bearing":true,"role":"system","pending_op":{"op":"storage_wipe","host_scope":"h","durable_id":"byid:x","fstype":"ext4"}}}`))
case strings.Contains(body.Device, "data") && !body.Confirmed: // user-data, not yet confirmed
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"ok":false,"data":{"device":"` + body.Device + `","data_bearing":true,"role":"user-data","needs_confirmation":true,"durable_id":"byid:wwn-1"}}`))
default: // blank, or user-data confirmed
_, _ = w.Write([]byte(`{"ok":true,"data":{"device":"` + body.Device + `","formatted":true,"role":"user-data"}}`))
} }
_, _ = w.Write([]byte(`{"ok":true,"data":{"device":"` + body.Device + `","formatted":true}}`))
}) })
s := httptest.NewTLSServer(mux) s := httptest.NewTLSServer(mux)
return s, strings.TrimPrefix(s.URL, "https://") return s, strings.TrimPrefix(s.URL, "https://")
@@ -65,20 +72,49 @@ func TestFormat_BlankOK(t *testing.T) {
s, ep := diskStub(t) s, ep := diskStub(t)
defer s.Close() defer s.Close()
c := clientFor(t, s, ep) c := clientFor(t, s, ep)
res, err := c.FormatDisk(context.Background(), "/dev/sdb", "ext4") res, err := c.FormatDisk(context.Background(), "/dev/sdb", "ext4", false, "")
if err != nil || !res.Formatted { if err != nil || !res.Formatted {
t.Fatalf("blank format: %v %+v", err, res) t.Fatalf("blank format: %v %+v", err, res)
} }
} }
func TestFormat_DataBearingRefused(t *testing.T) { // SYSTEM/BACKUP data-bearing → ErrFormatRefused with the operator pending op.
func TestFormat_ProtectedRefused(t *testing.T) {
s, ep := diskStub(t) s, ep := diskStub(t)
defer s.Close() defer s.Close()
c := clientFor(t, s, ep) c := clientFor(t, s, ep)
_, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4") res, err := c.FormatDisk(context.Background(), "/dev/protected-disk", "ext4", false, "")
if !errors.Is(err, ErrFormatRefused) { if !errors.Is(err, ErrFormatRefused) {
t.Fatalf("expected ErrFormatRefused, got %v", err) t.Fatalf("expected ErrFormatRefused, got %v", err)
} }
if res.PendingOp == nil {
t.Fatal("protected refusal should carry the operator pending op")
}
}
// USER-DATA data-bearing, not confirmed → ErrNeedsConfirmation with the durable id to confirm against.
func TestFormat_UserDataNeedsConfirmation(t *testing.T) {
s, ep := diskStub(t)
defer s.Close()
c := clientFor(t, s, ep)
res, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4", false, "")
if !errors.Is(err, ErrNeedsConfirmation) {
t.Fatalf("expected ErrNeedsConfirmation, got %v", err)
}
if !res.NeedsConfirmation || res.DurableID != "byid:wwn-1" || res.Role != "user-data" {
t.Fatalf("needs-confirmation payload not surfaced: %+v", res)
}
}
// USER-DATA data-bearing, confirmed + durable id → formatted.
func TestFormat_UserDataConfirmed(t *testing.T) {
s, ep := diskStub(t)
defer s.Close()
c := clientFor(t, s, ep)
res, err := c.FormatDisk(context.Background(), "/dev/data-disk", "ext4", true, "byid:wwn-1")
if err != nil || !res.Formatted {
t.Fatalf("confirmed user-data format: %v %+v", err, res)
}
} }
func TestEject_Dependents(t *testing.T) { func TestEject_Dependents(t *testing.T) {
+11 -4
View File
@@ -139,8 +139,10 @@ func (s *Server) agentDiskEjectHandler(w http.ResponseWriter, r *http.Request) {
// can show "operator authorization required". // can show "operator authorization required".
func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Device string `json:"device"` Device string `json:"device"`
FSType string `json:"fstype"` FSType string `json:"fstype"`
Confirmed bool `json:"confirmed"`
DurableID string `json:"durable_id"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil) writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil)
@@ -155,9 +157,14 @@ func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request)
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return return
} }
resp, err := client.FormatDisk(r.Context(), req.Device, req.FSType) resp, err := client.FormatDisk(r.Context(), req.Device, req.FSType, req.Confirmed, req.DurableID)
if errors.Is(err, agentapi.ErrNeedsConfirmation) {
s.logger.Printf("[INFO] [web] disk format needs customer confirmation (user-data): %s", req.Device)
writeDiskJSON(w, http.StatusConflict, false, "customer confirmation required", resp)
return
}
if errors.Is(err, agentapi.ErrFormatRefused) { if errors.Is(err, agentapi.ErrFormatRefused) {
s.logger.Printf("[WARN] [web] disk format refused by agent (data-bearing): %s", req.Device) s.logger.Printf("[WARN] [web] disk format refused by agent (system/backup-protected): %s", req.Device)
writeDiskJSON(w, http.StatusConflict, false, "operator authorization required", resp) writeDiskJSON(w, http.StatusConflict, false, "operator authorization required", resp)
return return
} }
+9 -2
View File
@@ -1022,12 +1022,19 @@ func (s *Server) countAppsUsingPath(storagePath string) int {
} }
func (s *Server) appsUsingPath(storagePath string) []string { func (s *Server) appsUsingPath(storagePath string) []string {
return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath)
}
// appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the
// deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is
// the "name the apps that break" list for the type-to-confirm wipe/eject UI.
func appsUsingPathIn(allStacks []stacks.Stack, loadCfg func(string) *stacks.AppConfig, storagePath string) []string {
var names []string var names []string
for _, stack := range s.stackMgr.GetStacks() { for _, stack := range allStacks {
if !stack.Deployed { if !stack.Deployed {
continue continue
} }
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil { if appCfg := loadCfg(stack.Name); appCfg != nil {
if appCfg.Env["HDD_PATH"] == storagePath { if appCfg.Env["HDD_PATH"] == storagePath {
names = append(names, stack.Meta.DisplayName) names = append(names, stack.Meta.DisplayName)
} }
+120 -9
View File
@@ -25,7 +25,7 @@ import (
// without a live agent). *agentapi.Client satisfies it. // without a live agent). *agentapi.Client satisfies it.
type diskAgent interface { type diskAgent interface {
Disks(ctx context.Context) (agentapi.DisksResponse, error) Disks(ctx context.Context) (agentapi.DisksResponse, error)
FormatDisk(ctx context.Context, device, fstype string) (agentapi.FormatResult, error) FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error)
AssignDisk(ctx context.Context, uuid, where, fstype, options string) error AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error) EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
} }
@@ -60,21 +60,33 @@ func fsUUIDForDevice(disks agentapi.DisksResponse, device string) string {
type storageInitResult struct { type storageInitResult struct {
Registered bool `json:"registered"` Registered bool `json:"registered"`
Where string `json:"where,omitempty"` Where string `json:"where,omitempty"`
// Refusal (data-bearing): the operator must sign offline. No bypass. // NeedsConfirmation (USER-DATA data-bearing): the customer must confirm the wipe (type-to-confirm),
// then the wizard re-submits with confirmed=true. NOT an operator signature.
NeedsConfirmation bool `json:"needs_confirmation,omitempty"`
Role string `json:"role,omitempty"`
DurableID string `json:"durable_id,omitempty"`
// Refusal (system/backup data-bearing): the operator must sign offline. No bypass.
Refused bool `json:"refused,omitempty"` Refused bool `json:"refused,omitempty"`
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
Opsign string `json:"opsign,omitempty"` Opsign string `json:"opsign,omitempty"`
} }
// runStorageInit is the testable core of the init flow: format → (refuse?) → resolve new UUID → // runStorageInit is the testable core of the init flow: format → (confirm/refuse?) → resolve new
// assign → register. On a data-bearing refusal it returns a result with Refused+Opsign and performs // UUID → assign → register. A USER-DATA data-bearing device requires the customer's confirmation
// NO further (destructive or mount) action. // (NeedsConfirmation); a SYSTEM/BACKUP device requires an operator signature (Refused+Opsign). In
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) { // either refusal it performs NO further (destructive or mount) action.
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault, confirmed bool, durableID string) (storageInitResult, error) {
if !validFSTypes[fstype] { if !validFSTypes[fstype] {
return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", 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. // 1. Format — the AGENT inspects the device and tiers it by role. A data-bearing user-data device
fr, err := agent.FormatDisk(ctx, device, fstype) // is allowed only with the customer's confirmation bound to its durable id; system/backup needs
// an operator signature.
fr, err := agent.FormatDisk(ctx, device, fstype, confirmed, durableID)
if errors.Is(err, agentapi.ErrNeedsConfirmation) {
// USER-DATA: surface the type-to-confirm requirement + the durable id to confirm against.
return storageInitResult{NeedsConfirmation: true, Role: fr.Role, DurableID: fr.DurableID, Reason: fr.Reason}, nil
}
if errors.Is(err, agentapi.ErrFormatRefused) { if errors.Is(err, agentapi.ErrFormatRefused) {
res := storageInitResult{Refused: true, Reason: fr.Reason} res := storageInitResult{Refused: true, Reason: fr.Reason}
if fr.PendingOp != nil { if fr.PendingOp != nil {
@@ -169,6 +181,10 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
s.handleStorageAttach(w, r) s.handleStorageAttach(w, r)
case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost: case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost:
s.handleStorageEject(w, r) s.handleStorageEject(w, r)
case r.URL.Path == "/api/storage/wipe" && r.Method == http.MethodPost:
s.handleStorageWipe(w, r)
case r.URL.Path == "/api/storage/impact" && r.Method == http.MethodGet:
s.handleStorageImpact(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@@ -180,6 +196,10 @@ type storageProvReq struct {
MountName string `json:"mount_name"` MountName string `json:"mount_name"`
Label string `json:"label"` Label string `json:"label"`
SetDefault bool `json:"set_default"` SetDefault bool `json:"set_default"`
// Confirmed + DurableID: the customer's type-to-confirm authorization for a USER-DATA data-bearing
// wipe (the durable id the agent returned on the prior NeedsConfirmation response).
Confirmed bool `json:"confirmed"`
DurableID string `json:"durable_id"`
} }
func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) { func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
@@ -202,11 +222,15 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return return
} }
res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault) res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault, req.Confirmed, req.DurableID)
if err != nil { if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return return
} }
if res.NeedsConfirmation {
writeDiskJSON(w, http.StatusConflict, false, "ügyfél-megerősítés szükséges", res)
return
}
if res.Refused { if res.Refused {
writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res) writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res)
return return
@@ -214,6 +238,93 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
writeDiskJSON(w, http.StatusOK, true, "", res) writeDiskJSON(w, http.StatusOK, true, "", res)
} }
// storageImpactReq / handleStorageImpact return the deployed apps whose data lives on a given mount —
// the "name the apps that break" requirement for the type-to-confirm wipe/eject UI.
func (s *Server) handleStorageImpact(w http.ResponseWriter, r *http.Request) {
where := path.Clean(strings.TrimSpace(r.URL.Query().Get("where")))
if where == "" || where == "." || !strings.HasPrefix(where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
return
}
apps := s.appsUsingPath(where)
if apps == nil {
apps = []string{}
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"where": where, "apps": apps})
}
// handleStorageWipe is the customer-confirmed wipe of a USER-DATA drive: it unmounts (eject —
// deregisters + frees the device) then formats with the customer's confirmation bound to the device's
// durable id. The agent re-classifies the role and re-resolves the durable id itself — a system/backup
// device is refused by the agent regardless of what the controller sends. The mount name must be typed
// to match (type-to-confirm) — enforced both client-side (disabled button) and here (server-side).
func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
var req struct {
Device string `json:"device"`
Where string `json:"where"`
MountName string `json:"mount_name"` // the typed confirmation (must equal the basename of Where)
FSType string `json:"fstype"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", 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
}
// Server-side type-to-confirm: the typed name must match the mount's basename exactly.
if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
return
}
fstype := req.FSType
if !validFSTypes[fstype] {
fstype = "ext4"
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
// 1. Unmount (benign — frees the device so mkfs can run) + deregister the StoragePath.
if _, eerr := agent.EjectDisk(r.Context(), req.Where); eerr != nil {
s.logger.Printf("[WARN] [web] wipe: eject %s failed (continuing to format): %v", req.Where, eerr)
} else if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil {
s.logger.Printf("[WARN] [web] wipe: deregister %s failed: %v", req.Where, rerr)
} else {
go s.SyncFileBrowserMounts()
}
// 2. Two-step customer-confirmed format: learn the agent's durable id (NeedsConfirmation), then
// re-submit confirmed:true bound to it. The agent re-resolves + matches the durable id and
// re-classifies the role — a protected device is refused here even though we send confirmed:true.
probe, perr := agent.FormatDisk(r.Context(), req.Device, fstype, false, "")
if errors.Is(perr, agentapi.ErrFormatRefused) {
writeDiskJSON(w, http.StatusConflict, false, "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással", probe)
return
}
if !errors.Is(perr, agentapi.ErrNeedsConfirmation) {
if perr != nil {
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+perr.Error(), nil)
return
}
// Already blank (no confirmation needed) — the format the agent just ran is the wipe.
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": true})
return
}
fr, ferr := agent.FormatDisk(r.Context(), req.Device, fstype, true, probe.DurableID)
if ferr != nil {
writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+ferr.Error(), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID})
}
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) { func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
var req storageProvReq var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+102 -13
View File
@@ -11,6 +11,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/config" "gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings" "gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
) )
// TestTemplatesParse forces every HTML template (incl. the new storage wizards and the de-priv // TestTemplatesParse forces every HTML template (incl. the new storage wizards and the de-priv
@@ -24,21 +25,27 @@ func TestTemplatesParse(t *testing.T) {
// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action. // mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
type mockAgent struct { type mockAgent struct {
disks agentapi.DisksResponse disks agentapi.DisksResponse
formatRes agentapi.FormatResult formatRes agentapi.FormatResult
formatErr error formatErr error
assignErr error assignErr error
assignCalls []assignCall assignCalls []assignCall
disksCalls int disksCalls int
formatCalls []formatCall
} }
type assignCall struct{ uuid, where, fstype string } type assignCall struct{ uuid, where, fstype string }
type formatCall struct {
device, fstype, durableID string
confirmed bool
}
func (m *mockAgent) Disks(context.Context) (agentapi.DisksResponse, error) { func (m *mockAgent) Disks(context.Context) (agentapi.DisksResponse, error) {
m.disksCalls++ m.disksCalls++
return m.disks, nil return m.disks, nil
} }
func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string) (agentapi.FormatResult, error) { func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error) {
m.formatCalls = append(m.formatCalls, formatCall{device, fstype, durableID, confirmed})
return m.formatRes, m.formatErr return m.formatRes, m.formatErr
} }
func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) error { func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) error {
@@ -59,22 +66,23 @@ func testServer(t *testing.T) *Server {
return &Server{settings: sett, logger: lg, cfg: &config.Config{}} return &Server{settings: sett, logger: lg, cfg: &config.Config{}}
} }
// SECURITY: a data-bearing refusal must surface the opsign command and perform NO assign/register. // SECURITY: a SYSTEM/BACKUP data-bearing refusal must surface the opsign command and perform NO
func TestRunStorageInit_DataBearingRefusal(t *testing.T) { // assign/register (operator signature required — confirmation cannot help).
func TestRunStorageInit_SystemBackupRefusal(t *testing.T) {
s := testServer(t) s := testServer(t)
agent := &mockAgent{ agent := &mockAgent{
formatErr: agentapi.ErrFormatRefused, formatErr: agentapi.ErrFormatRefused,
formatRes: agentapi.FormatResult{ formatRes: agentapi.FormatResult{
Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature", Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature", Role: "system",
PendingOp: &agentapi.PendingOp{Op: "storage_wipe", HostScope: "host-1", DurableID: "byuuid:1234", FSType: "ext4"}, 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) res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if !res.Refused { if !res.Refused {
t.Fatal("expected Refused=true on a data-bearing device") t.Fatal("expected Refused=true on a protected data-bearing device")
} }
if res.Opsign != "felhom-opsign -op storage_wipe -host host-1 -durable-id byuuid:1234" { if res.Opsign != "felhom-opsign -op storage_wipe -host host-1 -durable-id byuuid:1234" {
t.Errorf("opsign command not surfaced: %q", res.Opsign) t.Errorf("opsign command not surfaced: %q", res.Opsign)
@@ -87,6 +95,54 @@ func TestRunStorageInit_DataBearingRefusal(t *testing.T) {
} }
} }
// A USER-DATA data-bearing device returns NeedsConfirmation (+ the durable id to confirm against) and
// performs NO assign/register — the customer must confirm the wipe first (NOT an operator signature).
func TestRunStorageInit_UserDataNeedsConfirmation(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatErr: agentapi.ErrNeedsConfirmation,
formatRes: agentapi.FormatResult{
Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature",
Role: "user-data", DurableID: "byid:wwn-abc",
},
}
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.NeedsConfirmation || res.DurableID != "byid:wwn-abc" || res.Role != "user-data" {
t.Fatalf("expected NeedsConfirmation with the durable id + role: %+v", res)
}
if res.Refused || res.Opsign != "" {
t.Fatal("a user-data device must NOT surface an operator-signature path")
}
if len(agent.assignCalls) != 0 || len(s.settings.GetStoragePaths()) != 0 {
t.Fatal("NeedsConfirmation MUST NOT mount or register")
}
}
// After the customer confirms, the wizard re-submits with confirmed=true + the durable id; the format
// then succeeds and the flow proceeds to assign + register. Assert the confirmation is forwarded.
func TestRunStorageInit_UserDataConfirmedProceeds(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatRes: agentapi.FormatResult{Device: "/dev/sdb1", Formatted: true, DataBearing: true, Role: "user-data"},
disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-1", Role: "user-data"},
}},
}
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, true, "byid:wwn-abc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Registered {
t.Fatalf("confirmed wipe should proceed to register: %+v", res)
}
if len(agent.formatCalls) != 1 || !agent.formatCalls[0].confirmed || agent.formatCalls[0].durableID != "byid:wwn-abc" {
t.Fatalf("the customer confirmation + durable id were not forwarded to the agent: %+v", agent.formatCalls)
}
}
// Happy path: format → resolve new fs UUID from the disk list → assign with that UUID → register. // Happy path: format → resolve new fs UUID from the disk list → assign with that UUID → register.
func TestRunStorageInit_Success(t *testing.T) { func TestRunStorageInit_Success(t *testing.T) {
s := testServer(t) s := testServer(t)
@@ -96,7 +152,7 @@ func TestRunStorageInit_Success(t *testing.T) {
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-9999"}, {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) res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true, false, "")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@@ -148,6 +204,39 @@ func TestFSUUIDForDevice(t *testing.T) {
} }
} }
// Dependency-impact: name the deployed apps whose data lives on a given mount (the type-to-confirm
// "which apps break" list). Pure helper so no live stacks.Manager is needed.
func TestAppsUsingPathIn(t *testing.T) {
all := []stacks.Stack{
{Name: "immich", Deployed: true, Meta: stacks.Metadata{DisplayName: "Immich"}},
{Name: "nextcloud", Deployed: true, Meta: stacks.Metadata{DisplayName: "Nextcloud"}},
{Name: "paperless", Deployed: true, Meta: stacks.Metadata{DisplayName: "Paperless"}},
{Name: "notdeployed", Deployed: false, Meta: stacks.Metadata{DisplayName: "Nem telepített"}},
}
env := map[string]map[string]string{
"immich": {"HDD_PATH": "/mnt/hdd_1"},
"nextcloud": {"HDD_PATH": "/mnt/hdd_1"},
"paperless": {"HDD_PATH": "/mnt/hdd_2"}, // different drive
"notdeployed": {"HDD_PATH": "/mnt/hdd_1"}, // on the drive but not deployed → excluded
}
load := func(name string) *stacks.AppConfig {
if e, ok := env[name]; ok {
return &stacks.AppConfig{Env: e}
}
return nil
}
got := appsUsingPathIn(all, load, "/mnt/hdd_1")
if len(got) != 2 || got[0] != "Immich" || got[1] != "Nextcloud" {
t.Fatalf("apps on /mnt/hdd_1: got %v, want [Immich Nextcloud]", got)
}
if other := appsUsingPathIn(all, load, "/mnt/hdd_2"); len(other) != 1 || other[0] != "Paperless" {
t.Fatalf("apps on /mnt/hdd_2: got %v, want [Paperless]", other)
}
if none := appsUsingPathIn(all, load, "/mnt/empty"); len(none) != 0 {
t.Fatalf("apps on an unused mount: got %v, want []", none)
}
}
func TestMountWhere(t *testing.T) { func TestMountWhere(t *testing.T) {
if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" { if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" {
t.Errorf("mountWhere(hdd_1) = %q, %v", w, err) t.Errorf("mountWhere(hdd_1) = %q, %v", w, err)
+98 -22
View File
@@ -354,46 +354,122 @@ function pollUntilBack() {
<div style="margin-top:1.5rem"> <div style="margin-top:1.5rem">
<h4 style="margin-bottom:.25rem">Meghajtók (ügynök nézet)</h4> <h4 style="margin-bottom:.25rem">Meghajtók (ügynök nézet)</h4>
<p class="form-hint" style="margin-bottom:.75rem">A host-ügynök által észlelt meghajtók élő nézete (a tárolás végrehajtása az ügynöké).</p> <p class="form-hint" style="margin-bottom:.75rem">A host-ügynök által észlelt meghajtók élő nézete. A meghajtó <strong>szerepkörét</strong> az ügynök saját vizsgálattal állapítja meg: a rendszer- és biztonsági-mentés meghajtók védettek (csak operátori aláírással módosíthatók), a felhasználói adatmeghajtókat Ön kezeli.</p>
<div id="agent-disks">Betöltés…</div> <div id="agent-disks">Betöltés…</div>
</div> </div>
<div id="confirm-root"></div>
<script> <script>
window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}{{end}}]; window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}{{end}}];
(function(){ (function(){
function badge(d){ function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
if(d.backing_device===""){ return ''; } function hum(b){ if(!b||b<=0) return ''; var u=['B','KB','MB','GB','TB'],i=0,v=b; while(v>=1024&&i<u.length-1){v/=1024;i++;} return (v>=10||i===0?Math.round(v):v.toFixed(1))+' '+u[i]; }
return d.data_bearing function usageColorClass(p){ if(p>=85) return 'system-bar-red'; if(p>=70) return 'system-bar-yellow'; return 'system-bar-green'; }
? '<span class="badge badge-error" title="'+(d.data_reason||'')+'">Adatot tartalmaz</span>' function classBadge(d){
: '<span class="badge badge-ok">Üres</span>'; if(d.class==='fast') return '<span class="badge badge-ok">gyors</span>';
if(d.class==='slow') return '<span class="badge badge-muted">lassú</span>';
return '';
}
function roleBadge(role){
if(role==='system') return '<span class="badge badge-lock"><span class="lock-ico">🔒</span>Rendszer</span>';
if(role==='backup') return '<span class="badge badge-lock"><span class="lock-ico">🔒</span>Biztonsági mentés — védett</span>';
if(role==='user-data') return '<span class="badge badge-ok">Felhasználói adat</span>';
return '<span class="badge badge-lock"><span class="lock-ico">🔒</span>Védett</span>';
}
function dataBadge(d){ return d.data_bearing ? '<span class="badge badge-error" title="'+esc(d.data_reason)+'">Adatot tartalmaz</span>' : ''; }
function regBadge(d, registered){
if(!d.mount_path) return '';
return registered[d.mount_path] ? '<span class="badge badge-ok">Regisztrálva</span>' : '<span class="badge badge-muted">Nem regisztrált</span>';
}
function capBar(d){
if(!d.total_bytes || d.total_bytes<=0) return '';
var pct = d.used_fraction ? d.used_fraction*100 : (d.used_bytes/d.total_bytes*100);
pct = Math.max(0, Math.min(100, pct));
return '<div class="drive-cap"><div class="system-bar"><div class="system-bar-fill '+usageColorClass(pct)+'" style="width:'+pct.toFixed(1)+'%"></div></div>'
+'<div class="drive-cap-label">'+hum(d.used_bytes)+' / '+hum(d.total_bytes)+' ('+pct.toFixed(0)+'%)</div></div>';
}
function actions(d){
// Destructive controls ONLY for user-data drives that are mounted under /mnt. System/backup get none.
if(d.role!=='user-data' || !d.mount_path || d.mount_path.indexOf('/mnt/')!==0) return '';
var dev = esc(d.backing_device||''), mp = esc(d.mount_path);
var btns = '<button class="btn btn-xs btn-danger-outline" onclick="confirmEject(\''+mp+'\')">Leválasztás</button>';
if(d.backing_device){ btns += ' <button class="btn btn-xs btn-danger-outline" onclick="confirmWipe(\''+dev+'\',\''+mp+'\')">Törlés…</button>'; }
return '<div class="drive-actions">'+btns+'</div>';
} }
function reg(d, registered){ return registered[d.mount_path] ? '<span class="badge badge-ok">Regisztrálva</span>' : (d.mount_path?'<span class="badge">Nem regisztrált</span>':''); }
async function load(){ async function load(){
var box=document.getElementById('agent-disks'); if(!box) return; var box=document.getElementById('agent-disks'); if(!box) return;
try{ try{
var r=await fetch('/api/disks'); var j=await r.json(); var r=await fetch('/api/disks'); var j=await r.json();
if(!j.ok){ box.innerHTML='<p class="form-hint">'+(j.error||'Nem elérhető')+'</p>'; return; } if(!j.ok){ box.innerHTML='<p class="form-hint">'+esc(j.error||'Nem elérhető')+'</p>'; return; }
var disks=(j.data&&j.data.disks)||[]; var disks=(j.data&&j.data.disks)||[];
if(disks.length===0){ box.innerHTML='<p class="form-hint">Nincs észlelt meghajtó.</p>'; return; } if(disks.length===0){ box.innerHTML='<p class="form-hint">Nincs észlelt meghajtó.</p>'; return; }
var registered={}; (window.__registeredPaths||[]).forEach(function(p){registered[p]=true;}); var registered={}; (window.__registeredPaths||[]).forEach(function(p){registered[p]=true;});
var html='<table class="data-table"><thead><tr><th>Tároló</th><th>Típus</th><th>Eszköz</th><th>Csatolás</th><th>Osztály</th><th>Adat</th><th>Reg.</th><th></th></tr></thead><tbody>'; var html='<div class="drive-list">';
disks.forEach(function(d){ disks.forEach(function(d){
var ej = (d.mount_path && d.mount_path.indexOf('/mnt/')===0) ? '<button class="btn btn-xs btn-danger-outline" onclick="ejectDisk(\''+d.mount_path+'\')">Leválasztás</button>' : ''; var sub = esc(d.type)+' · '+esc(d.backing_device||'—')+(d.mount_path?' · '+esc(d.mount_path):'');
html+='<tr><td>'+d.name+'</td><td>'+d.type+'</td><td class="mono">'+(d.backing_device||'—')+'</td><td class="mono">'+(d.mount_path||'—')+'</td><td>'+(d.class||'—')+'</td><td>'+badge(d)+'</td><td>'+reg(d,registered)+'</td><td>'+ej+'</td></tr>'; var badges = roleBadge(d.role)+classBadge(d)+dataBadge(d)+regBadge(d,registered);
html+='<div class="drive-card role-'+esc(d.role||'system')+'">'
+'<div class="drive-card-top"><div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span><span class="drive-sub">'+sub+'</span></div>'
+'<div class="drive-badges">'+badges+'</div></div>'
+capBar(d)
+actions(d)
+'</div>';
}); });
html+='</tbody></table>'; html+='</div>';
box.innerHTML=html; box.innerHTML=html;
}catch(e){ box.innerHTML='<p class="form-hint">Hiba: '+e.message+'</p>'; } }catch(e){ box.innerHTML='<p class="form-hint">Hiba: '+esc(e.message)+'</p>'; }
} }
window.ejectDisk=async function(where){
if(!confirm('Leválasztja a(z) '+where+' meghajtót? Az adatok megmaradnak, de az ott lévő alkalmazások elveszítik a tárhelyet.')) return; // ---- type-to-confirm modal (destructive user-data actions) ----
function closeModal(){ document.getElementById('confirm-root').innerHTML=''; }
window.__closeConfirm=closeModal;
async function openConfirm(opts){
// opts: {title, mount, mountName, danger, onConfirm}
var apps=[];
try{ try{
var r=await fetch('/api/storage/eject',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({where:where})}); var r=await fetch('/api/storage/impact?where='+encodeURIComponent(opts.mount));
var j=await r.json(); var j=await r.json(); if(j.ok && j.data && j.data.apps) apps=j.data.apps;
if(!j.ok){ alert('Hiba: '+(j.error||'')); return; } }catch(e){}
var dep=(j.data&&j.data.dependent_guests)||[]; var appsHtml = apps.length
if(dep.length>0){ alert('Leválasztva. Figyelem: '+dep.length+' vendég (VMID: '+dep.join(', ')+') függött ettől a tárhelytől.'); } ? '<p>A művelet után a következő alkalmazások <strong>nem fognak működni</strong>:</p><ul class="confirm-apps">'+apps.map(function(a){return '<li>'+esc(a)+'</li>';}).join('')+'</ul>'
location.reload(); : '<p class="form-hint">Ehhez a meghajtóhoz jelenleg nincs telepített alkalmazás rendelve.</p>';
}catch(e){ alert('Hiba: '+e.message); } var root=document.getElementById('confirm-root');
root.innerHTML='<div class="confirm-overlay" onclick="if(event.target===this)__closeConfirm()"><div class="confirm-box">'
+'<h3>'+esc(opts.title)+'</h3>'
+'<div class="alert alert-warning">'+esc(opts.danger)+'</div>'
+appsHtml
+'<div class="confirm-input"><label>Megerősítéshez írja be a csatlakoztatási nevet: <strong class="mono">'+esc(opts.mountName)+'</strong></label>'
+'<input type="text" id="confirm-type" class="form-control" autocomplete="off" placeholder="'+esc(opts.mountName)+'" oninput="document.getElementById(\'confirm-go\').disabled=(this.value!==\''+esc(opts.mountName)+'\')"></div>'
+'<div class="form-actions"><button id="confirm-go" class="btn btn-danger-outline" disabled>Megerősítés</button>'
+'<button class="btn btn-outline" onclick="__closeConfirm()">Mégsem</button></div>'
+'<div id="confirm-result" style="margin-top:.6rem"></div></div></div>';
document.getElementById('confirm-go').onclick=opts.onConfirm;
}
window.confirmEject=function(where){
var name=where.replace(/^\/mnt\//,'');
openConfirm({title:'Meghajtó leválasztása', mount:where, mountName:name,
danger:'A meghajtó leválasztásra kerül. Az adatok megmaradnak, de az ott tárolt alkalmazások elvesztik a tárhelyüket, amíg újra nem csatolja.',
onConfirm:async function(){
var out=document.getElementById('confirm-result'); out.innerHTML='<p class="form-hint">Leválasztás folyamatban…</p>';
try{
var r=await fetch('/api/storage/eject',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({where:where})});
var j=await r.json(); if(!j.ok){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(j.error||'')+'</div>'; return; }
location.reload();
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; }
}});
};
window.confirmWipe=function(device, where){
var name=where.replace(/^\/mnt\//,'');
openConfirm({title:'Meghajtó törlése (formázás)', mount:where, mountName:name,
danger:'FIGYELEM: a meghajtón lévő ÖSSZES ADAT véglegesen törlődik (formázás). Ez nem vonható vissza.',
onConfirm:async function(){
var out=document.getElementById('confirm-result'); out.innerHTML='<p class="form-hint">Törlés folyamatban…</p>';
try{
var r=await fetch('/api/storage/wipe',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({device:device, where:where, mount_name:name})});
var j=await r.json(); if(!j.ok){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(j.error||'')+'</div>'; return; }
location.reload();
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; }
}});
}; };
load(); load();
})(); })();
@@ -50,21 +50,30 @@
<script> <script>
var selDevice = "", selFSType = ""; var selDevice = "", selFSType = "";
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
function classBadge(d){
if(d.class==='fast') return '<span class="badge badge-ok">gyors</span>';
if(d.class==='slow') return '<span class="badge badge-muted">lassú</span>';
return '';
}
async function loadDisks(){ async function loadDisks(){
try{ try{
var r = await fetch('/api/disks'); var j = await r.json(); var r = await fetch('/api/disks'); var j = await r.json();
if(!j.ok){ throw new Error(j.error||'Hiba'); } if(!j.ok){ throw new Error(j.error||'Hiba'); }
var disks = (j.data&&j.data.disks)||[]; var disks = (j.data&&j.data.disks)||[];
// Attachable: has a backing device, an fs-UUID identity (durable_id "uuid:…"), and isn't mounted yet. // Attachable: a user-data drive with a backing device, an fs-UUID identity, not mounted yet.
var attachable = disks.filter(function(d){ return d.backing_device!=="" && (d.durable_id||"").indexOf("uuid:")===0 && !d.mount_path; }); var attachable = disks.filter(function(d){ return d.backing_device!=="" && (d.durable_id||"").indexOf("uuid:")===0 && !d.mount_path && d.role==='user-data'; });
if(attachable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs csatolható (fájlrendszerrel rendelkező, még nem csatolt) meghajtó.</p>'; return; } if(attachable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs csatolható (fájlrendszerrel rendelkező, még nem csatolt) felhasználói adatmeghajtó.</p>'; return; }
var html='<table class="data-table"><thead><tr><th></th><th>Tároló</th><th>Típus</th><th>Eszköz</th><th>Osztály</th></tr></thead><tbody>'; var html='<div class="drive-list">';
attachable.forEach(function(d){ attachable.forEach(function(d,i){
html+='<tr><td><input type="radio" name="disk" value="'+d.backing_device+'" onchange="pickDisk(this)"></td>' var sub = esc(d.type)+' · '+esc(d.backing_device);
+'<td>'+d.name+'</td><td>'+d.type+'</td><td class="mono">'+d.backing_device+'</td><td>'+(d.class||'—')+'</td></tr>'; html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(d.backing_device)+'" data-i="'+i+'" onchange="pickDisk(this)">'
+'<div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span><span class="drive-sub">'+sub+'</span></div></div>'
+'<div class="drive-badges"><span class="badge badge-ok">Felhasználói adat</span>'+classBadge(d)+'</div></div></label>';
}); });
html+='</tbody></table>'; html+='</div>';
document.getElementById('disk-list').innerHTML=html; document.getElementById('disk-list').innerHTML=html;
}catch(e){ var el=document.getElementById('disk-error'); el.style.display='block'; el.textContent='Meghajtók betöltése sikertelen: '+e.message; } }catch(e){ var el=document.getElementById('disk-error'); el.style.display='block'; el.textContent='Meghajtók betöltése sikertelen: '+e.message; }
} }
@@ -72,6 +81,8 @@ async function loadDisks(){
function pickDisk(radio){ function pickDisk(radio){
selDevice=radio.value; selDevice=radio.value;
document.getElementById('sel-device').textContent=selDevice; document.getElementById('sel-device').textContent=selDevice;
document.querySelectorAll('.drive-card').forEach(function(c){c.classList.remove('is-picked');});
var card=document.getElementById('dc-'+radio.getAttribute('data-i')); if(card) card.classList.add('is-picked');
document.getElementById('cfg-card').style.display='block'; document.getElementById('cfg-card').style.display='block';
document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'}); document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'});
} }
@@ -10,8 +10,9 @@
<div class="settings-card"> <div class="settings-card">
<h3>1. Meghajtó kiválasztása</h3> <h3>1. Meghajtó kiválasztása</h3>
<p class="settings-card-desc">Válassza ki a formázandó meghajtót. A formázás biztonságát a host-ügynök <p class="settings-card-desc">Válassza ki a formázandó <strong>felhasználói adatmeghajtót</strong>. Rendszer- és
garantálja: <strong>adatot tartalmazó meghajtó nem formázható operátori aláírás nélkül</strong>.</p> biztonsági-mentés meghajtók itt nem jelennek meg — azok védettek. Ha a meghajtó adatot tartalmaz, a törlést
Önnek meg kell erősítenie.</p>
<div id="disk-error" class="alert alert-error" style="display:none;margin-bottom:1rem"></div> <div id="disk-error" class="alert alert-error" style="display:none;margin-bottom:1rem"></div>
<div id="disk-list">Betöltés…</div> <div id="disk-list">Betöltés…</div>
</div> </div>
@@ -48,8 +49,8 @@
<span class="toggle-label">Beállítás alapértelmezett adattárolóként új telepítéseknél</span> <span class="toggle-label">Beállítás alapértelmezett adattárolóként új telepítéseknél</span>
</label> </label>
<div class="alert alert-warning" id="warn-databearing" style="display:none;margin-bottom:1rem"> <div class="alert alert-warning" id="warn-databearing" style="display:none;margin-bottom:1rem">
⚠️ A kiválasztott meghajtó <strong>adatot tartalmaz</strong>. A formázás védelmi okból csak ⚠️ A kiválasztott meghajtó <strong>adatot tartalmaz</strong>. Az inicializálás (formázás) törli a rajta lévő
operátori aláírással hajtható végre — a rendszer megmutatja a szükséges parancsot. összes adatot — a folytatáshoz meg kell erősítenie.
</div> </div>
<div class="form-actions" style="gap:.75rem"> <div class="form-actions" style="gap:.75rem">
<button type="submit" class="btn btn-danger-outline" id="init-btn">Inicializálás indítása</button> <button type="submit" class="btn btn-danger-outline" id="init-btn">Inicializálás indítása</button>
@@ -61,28 +62,36 @@
<script> <script>
var selDevice = "", selDataBearing = false; var selDevice = "", selDataBearing = false;
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];}); }
function badge(d){ function dataBadge(d){
if(d.backing_device===""){ return '<span class="badge"></span>'; }
return d.data_bearing return d.data_bearing
? '<span class="badge badge-error" title="'+(d.data_reason||'')+'">Adatot tartalmaz</span>' ? '<span class="badge badge-error" title="'+esc(d.data_reason)+'">Adatot tartalmaz</span>'
: '<span class="badge badge-ok">Üres — formázható</span>'; : '<span class="badge badge-ok">Üres — formázható</span>';
} }
function classBadge(d){
if(d.class==='fast') return '<span class="badge badge-ok">gyors</span>';
if(d.class==='slow') return '<span class="badge badge-muted">lassú</span>';
return '';
}
async function loadDisks(){ async function loadDisks(){
try{ try{
var r = await fetch('/api/disks'); var j = await r.json(); var r = await fetch('/api/disks'); var j = await r.json();
if(!j.ok){ throw new Error(j.error||'Hiba'); } if(!j.ok){ throw new Error(j.error||'Hiba'); }
var disks = (j.data&&j.data.disks)||[]; var disks = (j.data&&j.data.disks)||[];
var formattable = disks.filter(function(d){return d.backing_device!=="";}); // Only USER-DATA drives with a block device are valid init (format) targets.
if(formattable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs formázható (blokkeszközzel rendelkező) meghajtó.</p>'; return; } var formattable = disks.filter(function(d){ return d.backing_device!=="" && d.role==='user-data'; });
var html='<table class="data-table"><thead><tr><th></th><th>Tároló</th><th>Típus</th><th>Eszköz</th><th>Állapot</th><th>Osztály</th><th>Adat</th></tr></thead><tbody>'; if(formattable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs formázható felhasználói adatmeghajtó. (A rendszer- és biztonsági-mentés meghajtók védettek.)</p>'; return; }
var html='<div class="drive-list">';
formattable.forEach(function(d,i){ formattable.forEach(function(d,i){
html+='<tr><td><input type="radio" name="disk" value="'+d.backing_device+'" data-db="'+(d.data_bearing?'1':'0')+'" onchange="pickDisk(this)"></td>' var sub = esc(d.type)+' · '+esc(d.backing_device)+(d.mount_path?' · '+esc(d.mount_path):'');
+'<td>'+d.name+'</td><td>'+d.type+'</td><td class="mono">'+d.backing_device+'</td>' html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
+'<td>'+(d.state==='attached'?'csatlakoztatva':d.state)+'</td><td>'+(d.class||'—')+'</td><td>'+badge(d)+'</td></tr>'; +'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(d.backing_device)+'" data-db="'+(d.data_bearing?'1':'0')+'" data-i="'+i+'" onchange="pickDisk(this)">'
+'<div class="drive-id"><span class="drive-name">'+esc(d.name)+'</span><span class="drive-sub">'+sub+'</span></div></div>'
+'<div class="drive-badges"><span class="badge badge-ok">Felhasználói adat</span>'+classBadge(d)+dataBadge(d)+'</div></div></label>';
}); });
html+='</tbody></table>'; html+='</div>';
document.getElementById('disk-list').innerHTML=html; document.getElementById('disk-list').innerHTML=html;
}catch(e){ var el=document.getElementById('disk-error'); el.style.display='block'; el.textContent='Meghajtók betöltése sikertelen: '+e.message; } }catch(e){ var el=document.getElementById('disk-error'); el.style.display='block'; el.textContent='Meghajtók betöltése sikertelen: '+e.message; }
} }
@@ -91,33 +100,64 @@ function pickDisk(radio){
selDevice=radio.value; selDataBearing=radio.getAttribute('data-db')==='1'; selDevice=radio.value; selDataBearing=radio.getAttribute('data-db')==='1';
document.getElementById('sel-device').textContent=selDevice; document.getElementById('sel-device').textContent=selDevice;
document.getElementById('warn-databearing').style.display=selDataBearing?'block':'none'; document.getElementById('warn-databearing').style.display=selDataBearing?'block':'none';
document.querySelectorAll('.drive-card').forEach(function(c){c.classList.remove('is-picked');});
var card=document.getElementById('dc-'+radio.getAttribute('data-i')); if(card) card.classList.add('is-picked');
document.getElementById('cfg-card').style.display='block'; document.getElementById('cfg-card').style.display='block';
document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'}); document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'});
} }
// postInit runs the init POST. confirmed/durableId carry the customer's wipe confirmation (user-data).
async function postInit(confirmed, durableId){
var body={device:selDevice, fstype:document.getElementById('fstype').value,
mount_name:document.getElementById('mount-name').value, label:document.getElementById('storage-label').value,
set_default:document.getElementById('set-default').checked, confirmed:!!confirmed, durable_id:durableId||""};
var r=await fetch('/api/storage/init',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
return {status:r.status, j:await r.json()};
}
async function submitInit(ev){ async function submitInit(ev){
ev.preventDefault(); ev.preventDefault();
var btn=document.getElementById('init-btn'); var out=document.getElementById('init-result'); var btn=document.getElementById('init-btn'); var out=document.getElementById('init-result');
btn.disabled=true; out.innerHTML='<p class="form-hint">Formázás és csatlakoztatás folyamatban…</p>'; btn.disabled=true; out.innerHTML='<p class="form-hint">Formázás és csatlakoztatás folyamatban…</p>';
try{ try{
var body={device:selDevice, fstype:document.getElementById('fstype').value, var res=await postInit(false, "");
mount_name:document.getElementById('mount-name').value, label:document.getElementById('storage-label').value, // USER-DATA data-bearing → the customer must confirm the wipe (type-to-confirm), then re-submit.
set_default:document.getElementById('set-default').checked}; if(res.status===409 && res.j.data && res.j.data.needs_confirmation){
var r=await fetch('/api/storage/init',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)}); renderConfirm(res.j.data.durable_id, out, btn);
var j=await r.json(); return false;
if(r.status===409 && j.data && j.data.refused){ }
out.innerHTML='<div class="alert alert-warning"><strong>Operátori aláírás szükséges.</strong><br>' // SYSTEM/BACKUP (shouldn't reach here — filtered out — but surface the opsign if it does).
+'A meghajtó adatot tartalmaz, ezért a formázás védelmi okból nem hajtható végre automatikusan' if(res.status===409 && res.j.data && res.j.data.refused){
+(j.data.reason?(' ('+j.data.reason+')'):'')+'.<br><br>Az engedélyezéshez futtassa offline az operátor gépén:' out.innerHTML='<div class="alert alert-warning"><strong>Operátori aláírás szükséges.</strong> Ez a meghajtó védett (rendszer/biztonsági mentés).'
+'<pre class="mono" style="white-space:pre-wrap;background:var(--bg-primary);padding:.75rem;border-radius:6px;margin-top:.5rem">'+(j.data.opsign||'(nem elérhető)')+'</pre>' +(res.j.data.opsign?('<pre class="mono" style="white-space:pre-wrap;background:var(--bg-primary);padding:.75rem;border-radius:6px;margin-top:.5rem">'+esc(res.j.data.opsign)+'</pre>'):'')+'</div>';
+'Az aláírás után a Hub végrehajtja a műveletet; ezután térjen vissza ide.</div>';
btn.disabled=false; return false; btn.disabled=false; return false;
} }
if(!j.ok){ throw new Error(j.error||'Hiba'); } finishInit(res.j, out);
out.innerHTML='<div class="alert alert-success">✅ A meghajtó sikeresen inicializálva és regisztrálva: <strong class="mono">'+(j.data.where||'')+'</strong>. <a href="/settings">Vissza a Beállításokhoz →</a></div>'; }catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; btn.disabled=false; }
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+e.message+'</div>'; btn.disabled=false; }
return false; return false;
} }
function renderConfirm(durableId, out, btn){
var name=document.getElementById('mount-name').value;
out.innerHTML='<div class="alert alert-warning">⚠️ A meghajtó adatot tartalmaz. A formázás <strong>véglegesen törli</strong> a rajta lévő összes adatot.</div>'
+'<div class="confirm-input"><label>Megerősítéshez írja be a csatlakoztatási nevet: <strong class="mono">'+esc(name)+'</strong></label>'
+'<input type="text" id="init-confirm-type" class="form-control" autocomplete="off" oninput="document.getElementById(\'init-confirm-go\').disabled=(this.value!==\''+esc(name)+'\')"></div>'
+'<div class="form-actions" style="gap:.6rem;margin-top:.75rem"><button id="init-confirm-go" class="btn btn-danger-outline" disabled>Törlés és inicializálás megerősítése</button></div>'
+'<div id="init-confirm-result" style="margin-top:.6rem"></div>';
document.getElementById('init-confirm-go').onclick=async function(){
var cr=document.getElementById('init-confirm-result'); cr.innerHTML='<p class="form-hint">Törlés és inicializálás folyamatban…</p>';
try{
var res2=await postInit(true, durableId);
if(!res2.j.ok){ cr.innerHTML='<div class="alert alert-error">Hiba: '+esc(res2.j.error||'')+'</div>'; return; }
finishInit(res2.j, out);
}catch(e){ cr.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; }
};
}
function finishInit(j, out){
if(!j.ok){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(j.error||'')+'</div>'; document.getElementById('init-btn').disabled=false; return; }
out.innerHTML='<div class="alert alert-success">✅ A meghajtó sikeresen inicializálva és regisztrálva: <strong class="mono">'+esc(j.data&&j.data.where)+'</strong>. <a href="/settings">Vissza a Beállításokhoz →</a></div>';
}
loadDisks(); loadDisks();
</script> </script>
@@ -3086,3 +3086,58 @@ a.stat-card:hover {
border-radius: 6px; padding: 0.2rem 0.5rem; cursor: pointer; font-size: 0.8rem; border-radius: 6px; padding: 0.2rem 0.5rem; cursor: pointer; font-size: 0.8rem;
} }
.btn-danger-outline:hover { background: var(--red-bg); } .btn-danger-outline:hover { background: var(--red-bg); }
/* ============================================================================
Agent drive lists (overview + init/attach selector) storage authz redesign.
Reuses the existing card/badge/bar tokens; no new design system.
============================================================================ */
.drive-list { display: flex; flex-direction: column; gap: .6rem; margin-top: .25rem; }
.drive-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-left: 4px solid var(--gray, #6e7681);
border-radius: 12px;
padding: .85rem 1rem;
display: flex; flex-direction: column; gap: .55rem;
}
.drive-card.role-user-data { border-left-color: var(--accent-blue); }
.drive-card.role-system,
.drive-card.role-backup { border-left-color: var(--yellow); }
.drive-card.is-selectable { cursor: pointer; transition: border-color .15s, background .15s; }
.drive-card.is-selectable:hover { border-color: var(--accent-light); }
.drive-card.is-picked { border-color: var(--accent-light); background: rgba(0, 136, 204, 0.06); }
.drive-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
.drive-id { display: flex; flex-direction: column; gap: .15rem; min-width: 0; }
.drive-name { font-size: .95rem; font-weight: 600; color: var(--text-primary); }
.drive-sub {
font-size: .78rem; color: var(--text-muted);
font-family: 'JetBrains Mono', monospace; word-break: break-all;
}
.drive-badges { display: flex; flex-wrap: wrap; gap: .35rem; align-items: center; justify-content: flex-end; }
.drive-cap { display: flex; flex-direction: column; gap: .3rem; }
.drive-cap .system-bar { height: 8px; }
.drive-cap-label { font-size: .75rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
.drive-actions { display: flex; flex-wrap: wrap; gap: .4rem; }
.drive-select { display: flex; align-items: center; gap: .5rem; }
/* badges missing from the global sheet */
.badge-ok { background: rgba(35, 134, 54, 0.18); color: #3fb950; }
.badge-lock { background: rgba(210, 153, 34, 0.18); color: var(--yellow); }
.badge-muted { background: rgba(110, 118, 129, 0.18); color: var(--text-muted); }
.badge .lock-ico { margin-right: .25rem; }
span.mono, .mono { font-family: 'JetBrains Mono', monospace; }
/* Type-to-confirm modal (destructive user-data eject/wipe) */
.confirm-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.6);
display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 1rem;
}
.confirm-box {
background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 14px;
max-width: 480px; width: 100%; padding: 1.4rem; box-shadow: 0 12px 40px rgba(0,0,0,.5);
}
.confirm-box h3 { margin: 0 0 .75rem; font-size: 1.05rem; }
.confirm-box .confirm-apps { margin: .5rem 0; padding-left: 1.1rem; }
.confirm-box .confirm-apps li { margin: .15rem 0; }
.confirm-box .confirm-input { margin: .9rem 0 .4rem; }
.confirm-box .form-actions { display: flex; gap: .6rem; margin-top: 1rem; }