From a4c82a2651a38158b9fd18b95d7d68a98da912bc Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 18 Jul 2026 12:03:24 +0200 Subject: [PATCH] =?UTF-8?q?docs(samba):=20CHANGELOG=20v0.144.0=20+=20REPOR?= =?UTF-8?q?T=20+=20CONTEXT=20+=20README=20=C2=A716=20+=20REUSE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the R-7 slice 1 ship, the four red-proof outcomes, live-validation evidence, the two bugs live validation caught, and the Part-4 Step-1 enumeration finding + the reported design fork (share data classified but not yet in a live backup run — needs a Viktor ruling, suggested R-7b). --- CHANGELOG.md | 61 +++++++++++++ CONTEXT.md | 30 ++++++- REPORT.md | 207 +++++++++++++++++++++++++++++++++---------- REUSE.md | 4 + controller/README.md | 76 ++++++++++++++++ 5 files changed, 329 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d35a56..a7e5f8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ ## Changelog +### v0.144.0 — „Megosztás": LAN SMB file sharing (R-7 slice 1) (2026-07-18) + +The customer turns on network sharing, sets ONE household SMB password, and exports folders. The box +appears in Windows Explorer's Network view as `\\FELHOM`; opening a share and writing to it works, and +every SMB write lands as uid:gid 1000 so apps and both backup tiers see consistent ownership. SMB is +an **embedded controller feature**, not a catalog app — it needs host networking (the R-6 spike +verdict), its config is a generated share list, and its roots ride the backup classification. + +- **New infra image `felhom-samba:1.0.0`** (`controller/infra-images/samba/`, built by + `controller/scripts/build-samba-image.sh`): pinned alpine 3.21 (`sha256:48b0309c…`) + smbd + **nmbd** + + wsdd + tini. Dumb by design — `smb.conf` is bind-mounted READ-ONLY, nothing is templated inside, + no name/password is baked, passdb lives on a named volume. nmbd is REQUIRED alongside wsdd: the R-6 + spike proved wsdd-only leaves the box *visible* but the Explorer double-click fails `0x80070035` + (no flat-name resolution). Anonymous pull verified from the guest. +- **Settings (`internal/settings/smb.go`, new):** `SMBSettings{Enabled, ServerName, UserSet}` + + `SMBShare{Name, Path, ReadOnly, Offsite, CreatedAt}` registry with NetBIOS-safe validation + (≤15 chars, no slash/dot) and case-insensitive name-collision refusal. **The household SMB password + is NEVER persisted** — only the `UserSet` boolean. +- **Renderers (`internal/infra/samba.go`, new):** pure `RenderSambaConfig` (hardened global block: + `server min protocol = SMB2`, `bind interfaces only = yes`, `interfaces = lo eth0`, + `disable netbios = no`, `map to guest = never`, per-share force-user block) and + `RenderSambaCompose` (`network_mode: host`, pinned image, config `:ro`, passdb volume, one bind per + share — `:ro` for read-only shares as defence in depth beside smb.conf). Exact smb.conf golden test. +- **Lifecycle (`internal/stacks/samba.go`, new):** `ensureSamba` joins `EnsureBaseStack` after + filebrowser, gated on `SMB.Enabled` (the cloudflared conditional-deploy precedent); `ReconcileSamba` + runs after every mutation. Idempotent — unchanged config + running container performs **zero** + compose calls. Config writes are atomic (tmp+fsync+rename). The password is applied via + `smbpasswd` on **STDIN** (never argv, never logged). Disable = `compose down`; the passdb volume and + every shared folder are KEPT. A share on a disconnected/decommissioned drive is rendered ABSENT from + smb.conf (never export a dead mountpoint) while its config is retained. +- **Protection:** `samba` is protected in CODE (`config.alwaysProtectedStacks`) because + `cfg.Stacks.Protected` comes from the golden-generated controller.yaml and predates it. This also + makes the app-backup loops correctly skip it (it is infrastructure, not a customer app). +- **UI (`internal/web/sharing_handlers.go` + `templates/sharing.html`, new):** a new top-nav category + **„Megosztás"** → **„Hálózati megosztás"**. Enable/server-name card, household password, shares table + (Név · Mappa · Írásvédett · Felhőmentés · Törlés — "a mappa és a fájlok megmaradnak"), and a create + flow (new folder under `/shares/` or an existing folder via the browse modal). +- **Picker security:** every customer-supplied path goes through `sharingResolvePath` — absolute → + `EvalSymlinks` → containment in a registered LIVE storage root → deny-listed system subtree → + is-a-directory. Refusals are **uniform** so the picker can never act as a filesystem oracle. The + deny-list is DERIVED from `stacks.ProtectedHDDPaths` (provably a subset, so it can only shrink, + never drift); the drive root is an exact-match denial so user-data folders under it stay shareable. + `sharingResolveStorageRoot` is a separate, strictly tighter check for the new-folder parent. +- **Backup classification [R4] (`internal/stacks/samba_classify.go`, new):** `ClassifiedBinds("samba")` + resolves from the shares registry instead of catalog metadata. Felhőmentés ON → `mandatory` + (offsite + tier-2); OFF → `optional` (tier-2 only); smb.conf/passdb never classified. Verified + through the real `ComputeCaptureSet` tier filter including the negative. **Zero backup-engine edits.** +- **KNOWN GAP (reported design fork, not improvised):** making that seam correct does NOT by itself put + share data into a live tier-2/offsite RUN. `backup.RunTier2` short-circuits on `os.Stat(unitDir)` + before it ever calls `GetStackClassifiedBinds`, and the offsite runner enumerates + `settings.GetOffboxApps()` — both are recovery-unit shaped, which a share-only infra stack has not. + Teaching them about one is more than an enumeration tweak, so per the task's STOP clause it is + reported rather than improvised. See `REPORT.md`. +- Live-validated end-to-end on demo guest 9201 through the REAL endpoints (curl against the exact + routes the UI posts to; the UI is password-gated so no browser leg): enable → password → create both + share kinds → guard refusals (appdata/backups//etc/drive-root all uniform 400) → smb.conf + container + + `:ro` bind verified on the box → Windows 11 workstation: `Test-NetConnection 445` True, nbtstat + `FELHOM <00>/<03>/<20> Registered`, `ping FELHOM` resolves, SMB write/read byte-compare PASS, and a + **write to the read-only share refused with no effect**. SMB-written files land as `1000:1000`. + Explorer render is Viktor's remaining human leg. + ### Build infra — build root relocated (2026-07-18) - `controller/build.sh`: `REPO_DIR` + `WEBSITE_ASSETS_DIR` repointed `/home/kisfenyo/…` → diff --git a/CONTEXT.md b/CONTEXT.md index d6845eb..a52cbb2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,35 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-17 (v0.143.0 — guest RAM resize UI, R-24; MinAgent 0.90.0) +Last updated: 2026-07-18 (v0.144.0 — „Megosztás": LAN SMB file sharing, R-7 slice 1) + +> **2026-07-18 — v0.144.0: „Megosztás" LAN SMB sharing (R-7 slice 1), LIVE on demo.** SMB ships as an +> **embedded controller feature** — the FOURTH protected infra stack (traefik/cloudflared/filebrowser/ +> **samba**), NOT a catalog app (it needs `network_mode: host` per the R-6 spike, its config is a +> generated share list, and its roots ride the backup classification). New own image +> **`felhom-samba:1.0.0`** (pinned alpine + smbd + **nmbd** + wsdd + tini; smb.conf bind-mounted +> read-only, nothing templated inside, passdb on a volume). nmbd is REQUIRED alongside wsdd — the R-6 +> spike proved wsdd-only leaves the box visible but the Explorer double-click fails `0x80070035`. +> New top-nav category „Megosztás" → „Hálózati megosztás": enable + one household SMB password +> (STDIN→smbpasswd, NEVER persisted — only `user_set`), shares table, and a create flow (new folder +> under `/shares/` or an existing folder via a guarded browse modal). Every customer path goes +> through `sharingResolvePath` (absolute → EvalSymlinks → containment in a registered LIVE storage root +> → deny-listed system subtree → is-a-dir) with **uniform** refusals so the picker is never a +> filesystem oracle; the deny-list is DERIVED from `ProtectedHDDPaths` (provably a subset). +> `ClassifiedBinds("samba")` resolves from the shares registry: Felhőmentés ON → mandatory +> (offsite+tier-2), OFF → optional (tier-2 only), with ZERO backup-engine edits. +> **OPEN / needs a Viktor ruling (suggested R-7b):** the classification seam is correct but share data +> is **not in any live backup run** — `RunTier2` short-circuits on the missing recovery unit before it +> reaches the seam, and the offsite runner enumerates `GetOffboxApps()`. Both engines are recovery-unit +> shaped; teaching them about a share-only stack is a structural change, so it was reported as a design +> fork rather than improvised (task STOP clause). Live-validated end-to-end through the real endpoints +> + a Windows 11 workstation (445, NetBIOS `FELHOM` resolves, write/read byte-compare PASS, write to a +> read-only share REFUSED with no effect, SMB writes land as uid 1000). **Explorer render = Viktor's +> remaining human leg.** Also: samba is protected in CODE (`config.alwaysProtectedStacks`) because +> controller.yaml is golden-generated — a side effect is that `monitor.EffectiveProtected` does NOT +> monitor samba liveness (deliberate: no false alarms while off; see REPORT §9). + + > **2026-07-17 — v0.143.0: guest RAM resize UI (R-24), LIVE on demo. MinAgent: 0.90.0.** The customer > sees the guest's current memory + allowed range on the **Rendszer** settings page and resizes it diff --git a/REPORT.md b/REPORT.md index 8fb8fd7..630cb9c 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,67 +1,178 @@ # REPORT — most recent implementation -## felhom-controller v0.143.0 — guest RAM resize UI (R-24) — 2026-07-17 +## felhom-controller v0.144.0 — „Megosztás": LAN SMB file sharing (R-7 slice 1) — 2026-07-18 -**Baseline:** v0.142.0 (`f900c83`) → **v0.143.0** (`3286c7f`, pushed). **MinAgent: 0.90.0** (couples to -felhom-agent v0.90.0's `/guest/memory`). Deployed + LIVE-validated on the nested demo guest. +**Class:** Implementation. **Shipped:** controller **v0.144.0** + new infra image +**`gitea.dooplex.hu/admin/felhom-samba:1.0.0`**. Deployed + live-validated on demo guest 9201. -### What shipped +### 1. Baselines -The customer sees the guest's current memory + the allowed range on the **Rendszer** settings page and -resizes it. The controller only proxies + maps the agent's machine `code` to Hungarian — the **agent** -(felhom-agent v0.90.0) enforces every bound and applies the change live (no reboot). Memory only. +| Repo | start `main` @ | Version | +|---|---|---| +| felhom-controller | `ac13966` (v0.143.0) | → **v0.144.0** | +| felhom.eu | `7e0370f` | docs only (hub v0.66.0 / scripts v1.20.0 untouched) | +| felhom-agent | `f222a7b` | NOT touched (v0.90.0) | +| app-catalog-felhom.eu | — | NOT touched (deliberately not a catalog app) | -- **agentapi (`internal/agentapi/client.go`):** `GuestMemory` (GET) + `ResizeMemory` (POST). A ruled 412 - surfaces `*MemoryRefusedError` (machine code + fresh bounds); a pre-0.90 agent 404s → typed `*StatusError`. -- **Capability (`internal/agentapi/features.go`):** `FeatureGuestMemoryResize` + `featureMinAgent` **0.90.0** - + a `featureProbes` row. The probe TYPE-ASSERTS the one method it needs (`GuestMemory`), so the shared - `SupportProber`/`netAgent` seams and their fakes are untouched (minimal-coupling choice — see Observations). -- **UI (`internal/web/system_memory_handlers.go` new; `templates/settings_system.html`):** a "Szerver - memória (RAM)" card; `POST /api/system/memory/resize` (mounted `/api/system/` in main.go) → capability - gate (SupportUnknown passes) → agent → response flash. JS confirm ONLY on a shrink. Code→Hungarian map; - agent-outdated hides the control (+"rendszerfrissítése szükséges" note); agent-unreachable falls back to - the guest's own `/proc/meminfo` (control disabled, honest note; the page never 500s). +*(The brief listed `a4a7de3d` for the controller; the live baseline was `ac13966` — the delta is the +2026-07-18 build-root relocation, paths/docs only.)* -### Tests +### 2. Files created / modified -- agentapi: GuestMemory decode + 404-typed; ResizeMemory success + refusal-code (`*MemoryRefusedError`); - capability table (0.89→No / 0.90→Yes via the version fast-path; probe 404→No / ok→Yes). -- web handler: success maps to the Hungarian old→new line; below_usage_floor maps + **the agent's English - never leaks**; agent_outdated gate refuses with ResizeMemory NEVER called. -- Gates: `template_id_gate` + `emoji_gate` OK. `go build/vet/test` all pass (23 packages). +**Created:** `controller/infra-images/samba/{Dockerfile,entrypoint.sh}`, +`controller/scripts/build-samba-image.sh`, `internal/settings/smb.go` (+test), +`internal/infra/samba.go` (+test), `internal/stacks/samba.go` (+test), +`internal/stacks/samba_classify.go` (+test), `internal/web/sharing_handlers.go` (+test), +`internal/web/templates/sharing.html`. -### Deploy + LIVE validation +**Modified:** `settings/settings.go` (2 fields), `infra/infra.go` (`SambaImage` pin), +`stacks/infra.go`, `stacks/manager.go` (3 test seams), `stacks/metadata.go` (samba branch), +`config/config.go` (`alwaysProtectedStacks`), `web/server.go`, `web/inframeta.go`, +`cmd/controller/main.go` (`/api/sharing/` mux), `templates/layout.html`, `templates/icons.html`, +`CHANGELOG.md`. -Built + pushed `felhom-controller:0.143.0`; deployed to BOTH demo guests (guest 9201 on felhom-pve AND the -nested guest 9201 inside `demo-vm-felhom-4846bc`) — both `Up … (healthy)`. +### 3. Commits -**LIVE end-to-end through the REAL controller endpoint** (login → CSRF → POST) on the nested demo guest — -the exact pipeline the settings page drives (no server logic skipped; method: authenticated curl to the -controller container, browser bridge unavailable): +| Hash | Phase | +|---|---| +| `f42f3e0` | Part 0 — felhom-samba image + build helper | +| `b0c5ef4` | Part 1 — settings registry + renderers | +| `0dcbea9` | Part 2 — lifecycle (ensure/reconcile/password/disable) + protection | +| `1d26a69` | Part 4 — backup classification from the shares registry | +| `4f08e5e` | Part 3 — „Megosztás" page + guarded picker | +| `2eef9b2` | fix: `/api/sharing/` mux registration (found by live validation) | +| `b409f5e` | fix: storage-ROOT vs share-TARGET validation split (found by live validation) | + +### 4. Part-4 Step-1 enumeration finding (verbatim, as required) + +`GetStackClassifiedBinds` is **NOT inert** — it is consumed by `backup/offbox_capture.go:36` +(offsite), `backup/tier2_capture.go:45` (tier-2), `appexport/fabplan.go:33,115` (.fab). The +enumeration nevertheless never reaches a share-only stack: + +- **Tier-2** — `RunAllTier2` (`tier2.go:399`) iterates `ListDeployedStacks()` and does **not** skip + protected stacks. But `RunTier2` (`tier2.go:269`) resolves `GetAppDrivePath(stack)` then + `os.Stat(RecoveryUnitPath(...))` and **returns nil before `tier2CaptureSet` is ever called** when no + recovery unit exists. samba has neither an app drive path nor a recovery unit. +- **Offsite** — `offbox.go:600` enumerates `settings.GetOffboxApps()` (apps with + `AppBackup[name].Offbox`); samba is not an app-backup app. +- **Volume dumps** — `backup.go:437–439` iterates `ListDeployedStacks()` and `continue`s on + `cfg.IsProtectedStack(name)`; with samba protected this correctly skips it. + +**Verdict — design fork STOPPED and reported, not improvised** (per the task's Part-4 clause): putting +share data into a live tier-2/offsite run requires teaching both engines about a stack with **no +recovery unit and no single app drive path** — a structural change inside the engines, far beyond an +enumeration tweak. Slice 1 therefore delivers the **correct classification seam** only. + +**Consequence, stated plainly:** [R4]'s intent — a customer dropping family files onto `\\FELHOM` gets +the product backup promise — is **classified but not executed**. Share data is in no live backup run +today. Needs a Viktor ruling (suggested new item **R-7b**). + +### 5. Tests + +Test functions **651 → 669** (+18). Green gate (`go build ./... && go vet ./... && go test ./...`) run +after every phase: **23/23 packages ok**, zero failures. Template gates green +(`template_id_gate.py`, `emoji_gate.py`). + +| Scenario | Tests | Result | +|---|---|---| +| A — enable + first share | `TestSambaReconcile_HappyPath`, `TestRenderSambaConfig_Golden`, settings CRUD | PASS | +| B — read-only share | `TestRenderSambaCompose` (`read only = yes` + `:ro` bind) | PASS | +| C — security gates | `TestSharingResolvePath_{GuardMatrix,SymlinkEscapeRefused,DecommissionedRootRefused,UniformRefusal}`, `TestSharingResolveStorageRoot`, `TestPathWithin_*`, name validation | PASS (symlink case SKIPs on Windows — no privilege) | +| D — classification | `TestSambaClassifiedBinds_{TierMembership,ExcludesConfigMounts,NoShares}` | PASS | +| E — delete/disable keep data | `TestSambaShareDeleteAndDisableKeepData` (byte-identical tree) | PASS | +| Idempotency | `TestSambaReconcile_IdempotentNoComposeCall` (seam asserts 0 compose calls) | PASS | +| Secrets | `TestSambaPasswordNeverPersisted`, `TestSambaPasswordRefusedWhenDisabled` | PASS | + +#### Red-proofs — all four run, confirmed, restored (`git diff` clean each time) + +1. **Classification** — inverted Offsite→mandatory → `TestSambaClassifiedBinds_TierMembership` FAILED: + `Felhőmentés ON must be mandatory, got "optional"` + `mandatory share must be in the OFFSITE set: []`. +2. **`:ro` bind** — dropped the read-only bind mapping → `TestRenderSambaCompose` FAILED: + `read-only share must get a :ro bind, missing "- …/filmek:…/filmek:ro"`. +3. **Picker guard** — removed the containment assert in `sharingOwningRoot` → + `TestSharingResolvePath_GuardMatrix` FAILED: + `outside every registered root: must be refused, got "…\Temp\…\002"`. +4. **Idempotency** — removed config change-detection → `TestSambaReconcile_IdempotentNoComposeCall` + FAILED: `unchanged registry must perform NO compose call: calls went 1 → 2`. + +#### Password-leak check (rule 4) + +`TestSambaPasswordNeverPersisted` asserts the secret reaches the smbpasswd seam and appears in **none** +of `settings.json`, `smb.conf`, `docker-compose.yml`. Live: the demo `settings.json` carries only +`"user_set": true`. The password is STDIN-only (never argv) and never logged. + +### 6. Deployment (live) ``` -GET /api/system/memory → {"allocated_mb":2890,"usage_mb":181,"host_total_mb":3914,"min_mb":2048,"max_mb":1866,"floor_mb":2048,"running":true} -POST /api/system/memory/resize {memory_mb:5000} → {"code":"above_max"} "A megengedett maximum 1866 MB (a gazdagép tartaléka miatt)." -POST /api/system/memory/resize {memory_mb:1024} → {"code":"below_min"} "A minimális memória 2048 MB." +felhom-controller: gitea.dooplex.hu/admin/felhom-controller:0.144.0 Up (healthy) +felhom-samba: gitea.dooplex.hu/admin/felhom-samba:1.0.0 Up ``` +Anonymous pull of the samba image verified from guest 9201 without registry creds (Part-0 gate). -The full chain is proven live: controller auth → capability gate resolving **SupportYes via the -X-Felhom-Agent-Version=0.90.0 header** → agentapi → agent enforcement → machine code → Hungarian mapping -(the agent's English is not shown). The lxcfs ripple (guest `/proc/meminfo` follows a real resize) is -Phase-0-proven at the agent layer. +Binds (`docker inspect felhom-samba`) — Scenario B proof: +``` +/opt/docker/stacks/samba/smb.conf -> /etc/samba/smb.conf rw=false +/mnt/felhom-drives/hdd_1/media/filmek -> …/media/filmek rw=false (read-only share) +/mnt/felhom-drives/hdd_1/shares/dokumentumok -> …/shares/dokumentumok rw=true +samba-passdb volume -> /var/lib/samba rw=true +``` +Listeners LAN-only (docker 172.x bridges absent — `bind interfaces only` working): +`445` on `127.0.0.1` + `192.168.0.127`, `137/138` NetBIOS, `3702` WSD, `5357`. -### NOT yet live-validated +### 7. Live validation -- A **successful** resize through the bounded endpoint could not be shown on the tiny 4 GB nested host: its - bounds are degenerate (`max_mb 1866 < current 2890`) so every target is correctly refused. The successful - live cgroup apply (2890→3146→2890, no reboot) is Phase-0-proven at the agent layer (agent REPORT §2). On a - normal-sized customer box a grow/shrink will succeed — folds into the rehearsal (S6 gains the resize touch). -- Full browser click-through of the card (the bridge attaches only to sessions started after it connected). +**Method:** the UI is password-gated and no browser leg was used, so validation drove the **exact +endpoints the UI posts to** (curl with a real session cookie + `_csrf` token scraped from the rendered +page) from inside guest 9201. No server logic skipped — only client rendering. -### Observations +| Leg | Evidence | +|---|---| +| login + `GET /sharing` | 302 login; page renders (title + nav present) | +| `POST /sharing/enable` | 303; `smb.enabled=true, server_name=FELHOM` | +| `POST /sharing/password` | 303; `user_set=true`; log `household SMB password applied for user=felhom` | +| `POST /sharing/shares` (new folder) | 303; `…/hdd_1/shares/dokumentumok` created **owned 1000:1000** | +| `POST /sharing/shares` (existing, read-only) | 303; stored `read_only: true` | +| Guard refusals (`/api/sharing/browse`) | `appdata`, `backups`, `/etc`, drive-root → **all 400, identical** `{"error":"Ez a mappa nem osztható meg."}` | +| Guard accept | `…/hdd_1/media` → 200 with entries | +| Generated `smb.conf` | hardened global verbatim; `[filmek] read only = yes`, `[dokumentumok] read only = no`, force-user block on both | +| Windows 11 (192.168.0.110) | `Test-NetConnection 445` → **True** | +| NetBIOS flat name (nmbd) | `nbtstat`: `FELHOM <00>/<03>/<20> Registered`; `ping FELHOM` → `Reply from 192.168.0.127` | +| SMB round-trip (writable) | write → readback **BYTE-COMPARE: PASS** | +| **Write-refused (read-only)** | write to `\\192.168.0.127\filmek` **rejected**; folder still empty on the box (non-effect proven) | +| `force user` promise | SMB-written `r7.txt` owned **1000:1000** on the box | +| WS-Discovery | ProbeMatch from `192.168.0.127` **PASS** | -- The `featureProbes` type-assertion (vs. widening the shared `SupportProber`) keeps the memory feature from - coupling `netAgent` and every unrelated prober/fake to the memory surface. A prober that can't be asked → - a non-404 error → SupportUnknown (fail-open), never a false "supported". -- The `memAgent` seam carries `NetVerifyStatus` + `AgentVersion` purely to satisfy the shared SupportProber / - version fast-path (production `*agentapi.Client` has them anyway) — the one small wart of reusing the cache. +#### Two real bugs caught by live validation (fixed + regression-tested) + +1. **`/api/sharing/browse` unreachable** — the `/api/` subtree is routed on the main mux in `main.go`, + so the case added to the web `ServeHTTP` switch was shadowed by the apiRouter catch-all and + returned `401 authentication required`. Fixed by registering + `mux.Handle("/api/sharing/", RequireAuth(CsrfProtect(ServeSharingAPI)))`, matching `/api/storage/`. +2. **Share creation silently failed** — the "new folder" flow passed the storage ROOT through + `sharingResolvePath`, which correctly refuses the drive root as a share TARGET. Split out + `sharingResolveStorageRoot` (accepts exactly a registered live root — strictly tighter); + `TestSharingResolveStorageRoot` asserts both halves. + +### 8. NOT yet live-validated + +- **Explorer render — Viktor's human leg.** Network view shows `FELHOM`, double-click opens both + shares. Everything objective around it passed (ProbeMatch, 445, NetBIOS name resolution, by-name + mount), so this is UX confirmation, not a mechanism gate. Stack + both shares are live on the demo box. +- **Share data in a real backup run** — blocked by the §4 design fork (classified, not executed). +- **Symlink-escape guard on Linux** — unit test SKIPs on Windows (no privilege). +- Drive-disconnect behaviour live (unit-tested only); multi-root share sets; SMB throughput/signing. + +### 9. Observations (not acted on) + +- `monitor.EffectiveProtected` derives only from `cfg.Stacks.Protected` (golden-generated), so samba is + **not** liveness-monitored — a dead samba container raises no "protected container missing" issue. + Fixing it needs the golden controller.yaml to list samba, or the SMB-enabled flag threaded through 4 + `RunHealthCheck` call sites. Deferred deliberately; the shipped direction is the safe one (no false + alarms while the feature is off). +- A classified bind's `RelPath` is relative to its owning storage root; a share set spanning + **multiple** roots has no single resolution root in the current `(Root, RelPath) + hddPath` + vocabulary — part of the §4 fork. +- `sambaWriteAtomic` is a fourth atomic-write helper (with `backup.atomicWrite`, + `bootstrap.writeFileAtomic`, `setup.atomicWriteFile`) — a shared util is worth a cleanup pass. +- A throwaway `r7.txt` from the round-trip test remains in the demo `dokumentumok` share (harmless; + left so the Explorer leg has visible content). diff --git a/REUSE.md b/REUSE.md index 2083f1b..b283276 100644 --- a/REUSE.md +++ b/REUSE.md @@ -56,6 +56,7 @@ | `Settings.save` (unexported) | controller/internal/settings/settings.go | via mutator methods only | ALL settings.json persistence | tmp+rename, then `.bak` last-known-good AFTER rename succeeds. Never write settings.json by hand | | `settings.Load` | controller/internal/settings/settings.go | `(path, logger) (*Settings, error)` | Startup load | Corruption recovery: `.bak` restore → else preserve `.corrupt-` + safe defaults; never crash-loops | | `Manager.writeJournal` / `loadJournal` | controller/internal/stacks/migrate.go | `(j *MigrationJob)` | Migration crash journal | Enables `RecoverMigration` at startup | +| `sambaWriteAtomic` | controller/internal/stacks/samba.go | `(path, data, mode) error` | samba smb.conf/compose writes | tmp+**fsync**+rename (the only one of these that fsyncs). Fourth atomic-write helper in the tree — see §6 | | `Loop.writeMarker` / `Recover` | controller/internal/quiesce/quiesce.go | `(m Marker)` / `()` | Quiesce crash-safety | Marker written BEFORE stopping stacks; Recover restarts stranded stacks at boot | ### Compose ops / stack lifecycle @@ -107,6 +108,8 @@ | `planDriveGates` / `Server.ReconcileDriveGates` | controller/internal/web/intermediary.go | pure plan + executor | Drive appear/disappear reactions | `planDriveGates` is PURE (unit-testable); loop at `driveGateLoop` | | `Server.runStorageInit` / `runStorageAttach` | controller/internal/web/storage_handlers.go | wizard pipelines | New-drive enroll / re-attach | Format goes through the agent's two-step confirm (below) | +| `Server.sharingResolvePath` / `sharingResolveStorageRoot` | controller/internal/web/sharing_handlers.go | `(raw) (string, error)` | THE guard for every customer-supplied SMB share path | resolvePath validates a share TARGET (refuses the drive root); resolveStorageRoot validates the new-folder PARENT (accepts exactly a registered live root). Refusals are UNIFORM (no filesystem oracle). Never add a second deny-list — `stacks.SharingDeniedRoots` derives from `ProtectedHDDPaths` | + ### Agent local-API client (cross-repo edge) | Symbol | File | Short signature | Use for | Gotchas | @@ -190,6 +193,7 @@ | `Server.agentLogsFn` (func seam) | controller/internal/web/server.go | nil → `agentClient().DebugLogs` (agent GET /debug/logs) | injected in controller/internal/web/observability_test.go (incl. the pre-0.83 typed-404 notice path) | | `escrowAgent` + `Server.escrowAgentFn/escrowStageFn/escrowStaleFn` | controller/internal/web/escrow_handlers.go (+ server.go fields) | `*agentapi.Client` / `PushOffboxPasswordForEscrow` / `report.EscrowAutoConfirmer.StaleBlob` (SetEscrowStale) | `fakeEscrowAgent` + fn injections in escrow_wizard_test.go — call-ORDER assertions (stage BEFORE trigger) + agent-never-called gates. The claim leg is the ONLY surface R crosses: no-store, never logged, never templated | | `offboxCeremonyWaitState` + `escrowCeremonyGraceWindow` | controller/internal/web/handlers.go | pure pick: (awaiting, timedOut) from `OffboxTarget.{EscrowState,CeremonyCompletedAt}` — the v0.138.0 "megerősítésre vár" card. Stamp SET on claim (escrow_handlers.go), CLEARED on the flip (main.go Flip + offbox_handlers.go manual confirm) | escrow_wait_state_test.go truth table (escrowed/unstamped/unparseable → plain CTA; boundary via `>=`) | +| `Manager.sambaUpFn` / `sambaPasswdFn` / `sambaRunFn` (func seams) | controller/internal/stacks/manager.go (fields) + samba.go | nil → `composeUp` / `docker exec smbpasswd` (STDIN) / `containerRunning("felhom-samba")` | injected in controller/internal/stacks/samba_test.go — the idempotency test asserts the up-seam is called **zero** times when config is unchanged; the passwd seam means no unit test ever handles a real secret or touches docker | | `report.SetPendingControllerLog` / `SetControllerLogSource` | controller/internal/report/selftail.go | ACK-armed consume-once self-log pull (the logtail.go shape) | selftail_test.go; source = `logBuffer.Lines`, wired once in main.go | | `util.ParseVersion` / `util.Version.Compare` | controller/internal/util/version.go | THE one semver comparator (house rule: never a second) — selfupdate aliases it; agentapi's MinAgent comparison uses it | rejects pre-release/dev/latest (callers fall back, never trust); numeric compare (0.100 > 0.81) | | `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line | diff --git a/controller/README.md b/controller/README.md index af1755c..e5f0e1c 100644 --- a/controller/README.md +++ b/controller/README.md @@ -2305,6 +2305,82 @@ Toggle switches on the provider's deploy/settings page ("Integrációk" section, --- +### 16. Network File Sharing — SMB („Megosztás") (v0.144.0, R-7 slice 1) + +LAN file sharing so the box behaves like a NAS: the customer enables sharing, sets ONE household SMB +password, and exports folders that appear in Windows Explorer / Mac Finder as `\\FELHOM\`. + +**Architecturally this is an EMBEDDED CONTROLLER FEATURE, not a catalog app** — three reasons: +it requires `network_mode: host` (the R-6 spike proved the default docker bridge is deaf to the LAN +multicast that WSD/mDNS discovery needs), its configuration is a dynamic share list rendered into +`smb.conf` (not env vars), and its share roots must ride the backup classification. It is therefore +the **fourth protected infra stack**: traefik / cloudflared / filebrowser / **samba**. + +#### The image (`controller/infra-images/samba/`, `felhom-samba:1.0.0`) + +Our own pinned image (alpine 3.21 by digest) running **three** daemons — `smbd` (445), **`nmbd`** +(NetBIOS flat-name resolution), `wsdd` (WS-Discovery), under `tini`. nmbd is not optional: the R-6 +spike proved wsdd alone makes the box *visible* in Explorer while the double-click still fails +`0x80070035`, because WSD supplies an icon, not a name→IP mapping. The image is deliberately dumb — +`/etc/samba/smb.conf` is bind-mounted READ-ONLY by the controller, nothing is templated inside, no +name or password is baked, and the passdb lives on a named volume so the household password survives +container recreation. Built by `controller/scripts/build-samba-image.sh` (never `:latest`). + +#### Data model (`internal/settings/smb.go`) + +- `SMBSettings{Enabled, ServerName, UserSet}` — `ServerName` is the NetBIOS name (≤15, NetBIOS-safe). +- `SMBShare{Name, Path, ReadOnly, Offsite, CreatedAt}` — the share registry. +- **The SMB password is NEVER persisted.** Only `UserSet` (a boolean) is stored; the secret lives in + the container's passdb, applied via `smbpasswd` on STDIN. + +#### Rendering + lifecycle (`internal/infra/samba.go`, `internal/stacks/samba.go`) + +Pure renderers produce a hardened `smb.conf` (`server min protocol = SMB2`, `bind interfaces only` +on `lo eth0`, `disable netbios = no`, `map to guest = never`, per-share `force user/group = felhom` +so every SMB write lands as uid:gid 1000) and a compose file (`network_mode: host`, pinned image, +config `:ro`, passdb volume, one bind per share — `:ro` for read-only shares as defence in depth). + +`ensureSamba` joins `EnsureBaseStack` after filebrowser, gated on `SMB.Enabled` (the cloudflared +conditional-deploy precedent); `ReconcileSamba` runs after every mutation. Both are idempotent — +unchanged config plus a running container performs **zero** compose calls. Config writes are atomic +(tmp+fsync+rename). A share whose drive is disconnected/decommissioned is rendered ABSENT from +smb.conf (never export a dead mountpoint) while its configuration is retained. + +**Nothing in this feature deletes or moves customer files.** Disabling sharing is `compose down` +(passdb volume kept); deleting a share is a config-only edit. The only `os.Mkdir*` is the guarded +new-share-folder create. + +#### UI + the picker guard (`internal/web/sharing_handlers.go`, `templates/sharing.html`) + +Top-nav category **„Megosztás"** → **„Hálózati megosztás"**: enable/server-name card, household +password, shares table (Név · Mappa · Írásvédett · Felhőmentés · Törlés), and a create flow — either a +NEW folder under `/shares/` or an EXISTING folder chosen in a browse modal. + +`sharingResolvePath` is the security gate for every customer-supplied path: absolute → +`EvalSymlinks` (before containment, so a planted symlink cannot escape) → must live inside a +registered, live storage root → must not be in a deny-listed system subtree → must be a directory. +**Refusals are uniform** (`Ez a mappa nem osztható meg.`) so the picker can never act as a filesystem +oracle. The deny-list is DERIVED from `stacks.SharingDeniedRoots`, itself provably a subset of +`ProtectedHDDPaths` — it can only shrink relative to the delete guard, never drift into a stale second +list. The drive root is an exact-match denial (a whole drive is never shareable) while user-data +folders under it stay shareable. `sharingResolveStorageRoot` is a separate, strictly tighter check +used only as the new-folder parent. The picker endpoint is `/api/sharing/browse`, registered on the +main mux behind `RequireAuth`+`CsrfProtect` (the `/api/` subtree is routed there, not in the web +`ServeHTTP` switch). + +#### Backup classification (`internal/stacks/samba_classify.go`) + +`ClassifiedBinds("samba")` resolves from the shares registry rather than catalog metadata (samba has +no `.felhom.yml` and its binds are absolute share paths). Per-share `Felhőmentés` ON → `mandatory` +(offsite + tier-2); OFF → `optional` (tier-2 only). smb.conf/passdb are never classified. + +> **KNOWN GAP (design fork, deliberately not improvised).** The seam is correct, but share data is not +> yet in a live tier-2/offsite RUN: `backup.RunTier2` short-circuits on a missing recovery unit before +> it reaches `GetStackClassifiedBinds`, and the offsite runner enumerates `settings.GetOffboxApps()`. +> Both engines are recovery-unit shaped, which a share-only infra stack has not. See root `REPORT.md`. + +--- + ## Repository Layout ```