From d3a564cbff2310da76c26e2f89c2568c03d6d27d Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 18 Jul 2026 12:05:15 +0200 Subject: [PATCH] =?UTF-8?q?docs(R-7):=20SMB=20sharing=20slice=201=20shippe?= =?UTF-8?q?d=20=E2=80=94=20feature=20doc,=20map=20row,=20ROADMAP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit controller/sharing.md (code-verified vs controller v0.144.0 + felhom-samba 1.0.0); capability map 'Files from Windows Explorer / Mac Finder (SMB server)' MISSING -> IMPLEMENTED (PROVEN-LIVE pending Viktor's Explorer leg); ROADMAP R-7 -> shipped-slice-1 with the slice-2 remainder, and the backup design fork split out as R-7b (shares are classified but not in any live backup run yet). --- .../architecture/00-capability-map.md | 2 +- documentation/backlog/ROADMAP.md | 3 +- documentation/controller/sharing.md | 169 ++++++++++++++++++ 3 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 documentation/controller/sharing.md diff --git a/documentation/architecture/00-capability-map.md b/documentation/architecture/00-capability-map.md index 388a04f..def8378 100644 --- a/documentation/architecture/00-capability-map.md +++ b/documentation/architecture/00-capability-map.md @@ -85,7 +85,7 @@ | LAN access when internet is down (lan_resolver) | agent | **IMPLEMENTED** | — | Never drilled as a customer experience ("net down — can I reach my photos?") → R-19 | | Phone photo backup | immich (classified) | **PROVEN-LIVE** | 6D end-to-end restore proof | | | Documents/OCR | paperless-ngx (classified) | **PROVEN-LIVE** | `CAMPAIGN-6C` 4P-1 (deploy paperless-ngx, ingest 3 docs via consume flow, OCR + PDF/A ~90s) + 4P-2/3/5 | Consume-folder ingestion awkward without SMB → R-7 | -| Files from Windows Explorer / Mac Finder (SMB **server**) | — | **MISSING** | — | → R-6 (spike), R-7 (feature) | +| Files from Windows Explorer / Mac Finder (SMB **server**) | controller v0.144.0 + `felhom-samba:1.0.0` | **IMPLEMENTED** | `felhom-controller` REPORT.md (v0.144.0) + `controller/sharing.md`; transport verdict `audits/SPIKE-lan-discovery-2026-07-18.md` | „Megosztás" page: enable + one household password + shares (new folder or picked existing, per-share read-only). Fourth protected infra stack (host-net, smbd+**nmbd**+wsdd). Live on demo: 445 reachable, NetBIOS `FELHOM` resolves, write/read byte-compare PASS, write to a read-only share REFUSED, SMB writes land as uid 1000. **PROVEN-LIVE pending only Viktor's Explorer-render leg.** KNOWN GAP: share data is backup-**classified** (mandatory/optional per share) but not yet in a live tier-2/offsite RUN — reported design fork, see REPORT §4 | | Media to TV via DLNA | — | **MISSING** | — | Jellyfin app exists; DLNA/SSDP unvalidated → R-6, R-8 | | File access via browser | FileBrowser (infra app, auto-mount sync) | **IMPLEMENTED** | FileBrowser runs healthy + userdata-bound (`storage-lifecycle-acceptance-2026-06-15`, `CAMPAIGN-3`) | Actual browse/download **through FileBrowser** is exercised in no doc. (Cited `CAMPAIGN-2` T-PAGE-ALL renders only the controller dashboard pages, not FileBrowser.) Demoted | | Forgot dashboard password → instant reset code | controller v0.123, hub | **PROVEN-LIVE** | `DRILL-day0-take2-2026-07-12` F-15 (live re-run of the exact failure path: hash applied 1s after request, code accepted first try) | | diff --git a/documentation/backlog/ROADMAP.md b/documentation/backlog/ROADMAP.md index 3b87cff..a29c0d6 100644 --- a/documentation/backlog/ROADMAP.md +++ b/documentation/backlog/ROADMAP.md @@ -31,7 +31,8 @@ |----|------|------|--------|-------| | R-5 | Hub: offsite storage visibility — RESTIC box aggregate (v0.64.0) + PBS DR datastore (v0.65.0), each with fill + operator alert; Offsite tabs + dual dashboard gauges | M | **SHIPPED (hub v0.64.0 + v0.65.0 + tenantsync v1.2.0, 2026-07-17)** | Restic: `monitor.OffsiteBoxChecker` (throttled Hetzner GET; fill 80/90% + oversub 2.0×; scope `"pool-box"`). PBS DR (v0.65.0, Option A): read-only `usage` op on the ep0 `felhom-tenantsync` forced command (`df`, no mutation) → `monitor.PBSDRBoxChecker` (fill 80/90%, states ok/unavailable/degraded, scope `"pbsdr-box"`); `/offsite` Restic/PBS-DR tabs + two gauges; hub deploy ⟂ ep0 update (graceful "n/a"). **Both data pipelines PROVEN-LIVE** (restic `0.2% full`; PBS `19.1% full, 7.1 GB of 37.2 GB` after ep0 → v1.2.0). Thresholds pending Viktor (named keys; separate restic/PBS pairs). OPEN: UI render unit-only (password-gated), alert emails unit + red-proof only (both nominal). See hub v0.64.0 + v0.65.0 REPORTs | | R-6 | **Spike: LAN service discovery from the guest** — SSDP multicast (UDP 1900, DLNA), WSD (Windows discovery), mDNS; host-network vs macvlan; is the customer LXC LAN-bridged in appliance deployments? | M | **spiked (2026-07-18)** | **VERDICT: appliance guest IS LAN-bridged (own DHCP lease on the household /24); multicast discovery works ONLY in the guest netns — guest-direct or Docker `--network host` (SSDP/mDNS/WSD all PASS both ways); the default docker bridge is categorically DEAF to LAN multicast (WSD/mDNS RX FAIL, unicast-publish PASS). Real samba+wsdd on host-net → Windows 11 ProbeMatch + FELHOM-SPIKE renders in Explorer + 445 + authenticated SMB round-trip all PASS; real SSDP `MediaServer:1` advert reaches both LAN clients. → R-7 SMB stack MUST be host-network LAN-bound; R-8 Jellyfin-DLNA plausible if host-network. Caveat: `vmbr0 multicast_snooping=1` worked only because the household router is a live querier — customer LANs w/ snooping+no-querier, and Peti's BYO bridge, are UNTESTED gaps.** **S4b (human leg, the sharpest finding): wsdd makes the box VISIBLE but the Explorer double-click FAILS `0x80070035` — WSD gives no name resolution; the flat `\\FELHOM-SPIKE` resolved by no path. Adding `nmbd` (NetBIOS) fixed it live (flat name resolves + mounts). → R-7 needs smbd+wsdd+nmbd (+avahi/.local for modern clients), not wsdd alone.** Doc: `audits/SPIKE-lan-discovery-2026-07-18.md`. | -| R-7 | SMB **server** share (**gate input now exists — R-6 spiked 2026-07-18: host-network, LAN-bound, smbd+wsdd+nmbd**): samba + wsdd + nmbd, LAN-only binding (never tunnel), user model (single household user first), which roots are shared (dedicated `shares/` vs app userdata — every SMB-writable path needs a backup class), uid-1000 convention, paperless consume flow | L | idea (unblocked) | Flips map row E/MISSING. Alpha-relevant: "my box is a NAS" is a core household expectation. **R-6 settled the transport (`network_mode: host`) AND the service set: wsdd for WSD visibility is NOT enough — add `nmbd` so the Explorer double-click on `\\FELHOM-SPIKE` resolves (S4b proved wsdd-only fails `0x80070035`); ideally avahi/.local too. Pin daemons to eth0 (`bind interfaces only`). Carry the port-collision + snooping/querier caveats into the spec** | +| R-7 | SMB **server** share — LAN file sharing as an embedded controller feature („Megosztás") | L | **SHIPPED slice 1 (controller v0.144.0 + `felhom-samba:1.0.0`, 2026-07-18)** | Flips map row *Files from Windows Explorer / Mac Finder (SMB server)* MISSING → **IMPLEMENTED**. Delivered as the FOURTH protected infra stack (host-net, smbd+**nmbd**+wsdd, `bind interfaces only = lo eth0`), NOT a catalog app. „Megosztás" → „Hálózati megosztás": enable + ONE household password (STDIN→smbpasswd, never persisted — only `user_set`), shares table, create flow (new folder under `/shares/` or an existing folder via a guarded picker). Picker guard = absolute → EvalSymlinks → containment in a registered LIVE root → deny-listed system subtree (derived from `ProtectedHDDPaths`, provably a subset) → is-a-dir, with UNIFORM refusals (no filesystem oracle). Per-share read-only enforced in smb.conf AND as a `:ro` bind. `force user = felhom` → SMB writes land as uid 1000. Live-validated on demo through the real endpoints + a Win11 workstation (445 True, NetBIOS `FELHOM` resolves, write/read byte-compare PASS, **write to a read-only share REFUSED**, WSD ProbeMatch). **Explorer render = Viktor's remaining human leg.** Docs: `controller/sharing.md`; ship report `felhom-controller/REPORT.md`. **Slice 2 remainder:** avahi/`.local` for modern+Apple clients, curated app-folder presets (one-click paperless `consume/`), per-share users + guest access, recycle bin. **Backup gap split out → R-7b.** | +| R-7b | **Share data is backup-CLASSIFIED but not backed up** (design fork reported by R-7 slice 1, not improvised). `ClassifiedBinds("samba")` correctly returns mandatory/optional per share, but no live run consumes it: `backup.RunTier2` short-circuits on `os.Stat(unitDir)` BEFORE calling `GetStackClassifiedBinds`, and the offsite runner enumerates `settings.GetOffboxApps()`. Both engines are **recovery-unit shaped**, which a share-only infra stack has not. Decide the model: give samba a synthetic recovery unit, teach the tier engines a unit-less class-driven source, or keep shares tier-2-only. | M | **idea (needs Viktor ruling)** | Origin: R-7 slice 1 (`felhom-controller` REPORT §4). **Customer-facing consequence: until this lands, files dropped on a share are NOT in any backup — do not promise otherwise.** [R4] intended new shares to default to the full product backup promise (mandatory = offsite + tier-2); the classification honours that, the execution does not yet. Also blocks a clean "every SMB-writable path has a backup class" claim in the R-7 docs | | R-8 | DLNA (**gate input now exists — R-6 spiked 2026-07-18: SSDP reaches LAN clients from host-net**): validate Jellyfin's built-in DLNA server first; only add minidlna to the catalog if Jellyfin-DLNA fails | S | idea (unblocked) | Don't add catalog weight before proving the cheap path. **R-6 confirmed the cheap path is physically viable — Jellyfin DLNA must run host-network (same multicast constraint as R-7)** | | R-9 | Uninstaller trio (from 07-15 Peti session): cluster-aware `felhom_guests` guard (node-local `pct list` deletes cluster-wide pveum objects); saferemove detection + time estimate + opt-in `--quick-remove` (never mutate `storage.cfg`); smarter `restore_storage` default for BYO clusters (shared storage, not local-lvm) | M | idea | Second item's rejected alternative (temp-disable-and-restore) stays rejected — crash window silently downgrades cluster wipe policy | | R-10 | T-6E-1: DB-dump dir-fsync asymmetry (LOW, confirmed in 6E) | XS | idea | One-line hardening; batch with the next controller task | diff --git a/documentation/controller/sharing.md b/documentation/controller/sharing.md new file mode 100644 index 0000000..ca579c0 --- /dev/null +++ b/documentation/controller/sharing.md @@ -0,0 +1,169 @@ +# Controller — LAN file sharing over SMB („Megosztás") + +> **Code-verified feature doc.** Matches `felhom-controller` **v0.144.0** (R-7 slice 1) and the infra +> image **`gitea.dooplex.hu/admin/felhom-samba:1.0.0`**. Transport/discovery verdicts come from +> `documentation/audits/SPIKE-lan-discovery-2026-07-18.md` (R-6). Where this doc and the code +> disagree, the code wins — update this file. + +## What the customer gets + +The box behaves like a NAS on the home network. On **„Megosztás" → „Hálózati megosztás"** the customer +turns sharing on, sets ONE household SMB password, and exports folders. The box then appears in +Windows Explorer's *Network* view (and in Mac Finder) as `\\FELHOM`; opening a share and writing to it +works from any household device. Sharing is **LAN-only** — it is never exposed through the tunnel. + +## Why it is NOT a catalog app + +SMB ships as an **embedded controller feature** — the **fourth protected infra stack** +(traefik / cloudflared / filebrowser / **samba**). Three reasons, all structural: + +1. **Host networking is mandatory.** The R-6 spike proved the default Docker bridge is categorically + deaf to the LAN multicast that WSD and mDNS discovery need; only a container in the guest's own + network namespace can send *and* receive it. +2. **Its config is a generated share list**, rendered into `smb.conf` — not a handful of env vars a + catalog template could carry. +3. **Its share roots must ride the backup classification**, which catalog metadata cannot express for + absolute, customer-chosen paths. + +## The image (`felhom-samba:1.0.0`) + +Built from `controller/infra-images/samba/` by `controller/scripts/build-samba-image.sh`. Pinned +alpine 3.21 (by digest) running **three** daemons under `tini`: + +| Daemon | Port(s) | Why | +|---|---|---| +| `smbd` | 445 | the SMB/CIFS server itself | +| **`nmbd`** | 137/138 | **NetBIOS flat-name resolution** — without it the box is *visible* in Explorer but the double-click fails `0x80070035` (S4b of the spike: WSD hands Explorer an icon, not a name→IP mapping) | +| `wsdd` | 3702 / 5357 | WS-Discovery, so the box appears in Explorer's Network view at all | + +The image is deliberately **dumb**: `/etc/samba/smb.conf` is bind-mounted READ-ONLY by the controller, +nothing is templated inside, and **no share name or password is ever baked in**. The passdb lives on +a named volume (`samba-passdb`) so the household password survives container recreation. Never +tagged `:latest`; the controller pins the exact tag in the compose it generates. + +## Configuration model + +Persisted in `settings.json` (`internal/settings/smb.go`): + +- `smb: {enabled, server_name, user_set}` — `server_name` is the NetBIOS name (≤15 chars, + letters/digits/hyphen/underscore; default `FELHOM`). +- `smb_shares: [{name, path, read_only, offsite, created_at}]` — the share registry. + +> **The SMB password is never persisted.** Only the boolean `user_set` is stored. The secret is passed +> to `smbpasswd` on **STDIN** (never argv, which is world-readable via `/proc`), is never logged, and +> never appears in `smb.conf`, the compose file, or `settings.json`. + +## Generated `smb.conf` + +Global block (hardened, all controller-managed): + +``` + server min protocol = SMB2 # no SMB1 + security = user # named auth only + map to guest = never # no anonymous access, ever + disable netbios = no # nmbd on → \\NAME resolves + bind interfaces only = yes + interfaces = lo eth0 # LAN + loopback ONLY, never the docker bridges +``` + +Each share adds a section with `force user`/`force group = felhom` (uid:gid **1000**), so files written +over SMB carry the same ownership the apps and both backup tiers expect: + +``` +[dokumentumok] + path = /mnt/felhom-drives/hdd_1/shares/dokumentumok + read only = no + valid users = felhom + force user = felhom + force group = felhom +``` + +A read-only share gets `read only = yes` **and** a `:ro` bind in the compose file — defence in depth, +so a samba misconfiguration alone cannot make it writable. + +## Lifecycle + +`ensureSamba` runs inside `EnsureBaseStack` (boot + every health tick) after filebrowser, gated on +`smb.enabled` — the same conditional-deploy shape cloudflared uses. `ReconcileSamba` runs after every +share/settings mutation: re-render → atomic write (tmp + fsync + rename) → `compose up -d`. + +- **Idempotent:** unchanged config plus a running container performs **zero** compose calls. +- **No password, no deploy:** with sharing on but no household password set, the stack stays + undeployed and the UI blocks with „először adj meg jelszót". (Setting the password brings the stack + up as part of that action — safe, because `security = user` + `map to guest = never` means nothing + is reachable until the password lands.) +- **Dead mounts are never exported:** a share whose drive is disconnected or decommissioned is + rendered ABSENT from `smb.conf` (publishing a missing mountpoint would show an empty share and let a + write land on the underlying root instead of the drive). Its configuration is retained, and the UI + row shows „A meghajtó nem elérhető." + +### This feature never deletes customer data + +There is no file-removal path in it at all. Disabling sharing is `compose down` (the passdb volume and +every shared folder are kept). Deleting a share is a **config-only** edit — the UI says so explicitly: +„A megosztás törölve — a mappa és a fájlok megmaradtak." The only directory creation is the guarded +new-share-folder create. + +## Choosing what to share — the picker guard + +Shares can be a **new folder** under `/shares/`, or **any existing folder**, with one +exception class: system data. Every customer-supplied path goes through `sharingResolvePath`: + +1. must be **absolute**; +2. `EvalSymlinks` **before** the containment check — a symlink planted inside a storage root cannot + point outside it; +3. must resolve inside a **registered, live** storage root (decommissioned roots are out); +4. must not be inside a deny-listed **system subtree** — `appdata/` (sharing a live app database + writable over SMB is a corruption foot-gun), `backups/`, the legacy `felhom-data/` nest; +5. the **drive root itself** is refused (a whole drive is never shareable) — an exact-match rule, so + ordinary user-data folders *under* it stay shareable; +6. must be a directory. + +**Every refusal returns the same message** („Ez a mappa nem osztható meg.") so the picker can never be +used as an oracle for what exists outside the customer's storage. + +The deny-list is **derived** from `stacks.ProtectedHDDPaths` (each entry is emitted only if that guard +already contains it), so it can only ever *shrink* relative to the delete guard — it can never drift +into a stale second source of truth. `media/` and `Dokumentumok/` are protected *there* as delete +targets but are customer data and remain shareable. + +The new-folder flow uses a separate, strictly tighter check (`sharingResolveStorageRoot`) that accepts +**exactly** a registered live storage root — because there the root is the *parent*, not the share. + +## Backup classification + +`ClassifiedBinds("samba")` resolves from the shares registry instead of catalog metadata (samba has no +`.felhom.yml`, and its binds are absolute share paths): + +| Per-share „Felhőmentés" | Class | Tiers | +|---|---|---| +| ON (default for new shares) | `mandatory` | offsite **and** tier-2 | +| OFF | `optional` | tier-2 only (never offsite) | + +`smb.conf` and the passdb are config, not customer data, and are never classified. + +> **KNOWN GAP — share data is classified but not yet backed up.** Making this seam correct does not by +> itself put share data into a live run: `backup.RunTier2` short-circuits on a missing *recovery unit* +> before it ever calls `GetStackClassifiedBinds`, and the offsite runner enumerates +> `settings.GetOffboxApps()`. Both engines are recovery-unit shaped, which a share-only infra stack +> has not. Teaching them about one is a structural change, so it was reported as a design fork rather +> than improvised inside the engines. **Until it is resolved, do not tell a customer that files +> dropped on a share are backed up.** See `felhom-controller/REPORT.md` §4. + +## Operator notes + +- samba is protected in **code** (`config.alwaysProtectedStacks`), because `cfg.Stacks.Protected` comes + from the golden-generated `controller.yaml` and predates the feature. Consequence: + `monitor.EffectiveProtected` does **not** monitor samba liveness — a dead samba container raises no + "protected container missing" issue. Deliberate for slice 1 (the safe direction: no false alarms + while the feature is off). +- Discovery depends on the household LAN. The R-6 spike's §7d caveat applies: a customer network with + IGMP snooping enabled and **no querier** could prune the discovery multicast, and a BYO topology may + put the guest on a bridge with no L2 path to the household LAN. The page carries an honest hint that + the device list can lag while `\\NÉV` always works. +- The customer authenticates as user **`felhom`** with the household password. + +## Not in slice 1 (→ slice 2) + +avahi / `.local` name publishing for modern + Apple clients; curated app-folder presets (one-click +paperless `consume/`); per-share users and guest access; a recycle bin. DLNA is a separate item (R-8).