Files
felhom.eu/documentation/controller/sharing.md
T
admin a03e142101 docs(R-7b): correction — all four legs PROVEN-LIVE, with snapshot evidence
Drops the false 'no offsite target on the demo box' clause from the ROADMAP row,
the capability-map SMB row and sharing.md. Cites offsite snapshots e0b9d723 /
4e2b15ec and the restore round-trip results. Root cause (guessed settings key) is
recorded in felhom-controller REPORT section 7b.
2026-07-18 14:32:39 +02:00

209 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<storage>/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.
## Backup EXECUTION — the sibling shares source (R-7b, controller v0.145.0)
The classification above is now *executed*. It did not get there through `ClassifiedBinds` — the
engines stayed recovery-unit shaped and Model B deliberately left every per-app path byte-identical.
Instead `internal/backup` runs a **sibling shares source** off the same registry:
| Tier | Entry point | What it writes |
|---|---|---|
| 2 (cross-drive) | `RunSharesTier2`, after the per-stack loop in the same run | `backups/secondary/_shares/<sourceDriveKey>/<share>` per source drive, `_payload/`, `.felhom-tier2-layout` written **LAST** |
| 3 (offsite) | `runOffboxSharesLeg`, after the per-app loop and **before** retention | ONE `restic backup --tag felhom-offbox --tag _shares` = payload dir + every MANDATORY share |
| restore | „Megosztások" on `/backups/restore` | scratch → missing-only merge, each destination prefix-asserted against LIVE storage roots |
**The payload is the point.** `_shares-manifest.json` carries the share *definitions* (sorted →
byte-deterministic, so an unchanged registry gives the mirror nothing to rewrite) and `passdb.tar`
carries the household credential, best-effort. Without it a restore hands the customer their files
back and an empty „Megosztás" page. The credential copy is **secret-bearing**: 0600, encrypted inside
restic, never logged at INFO and never in a report or a committed file.
**Degradation contract.** A quota-blocked offsite push falls back to the **manifest only, never to
nothing** — definitions protection must not regress because the files stopped fitting.
**Restore semantics.** Files merge missing-only (a live file is never overwritten). Definitions merge
**existing-wins**: a restore must never silently flip a live share's read-only or „Felhőmentés"
setting, and anything skipped is named in the flash. Then `ReconcileSamba` re-renders `smb.conf`, then
the credential goes back into the named volume.
**`_shares` is a reserved key** (restic tag, dest root, status record). `ValidateSMBShareName` refuses
a leading underscore — note this was a REAL gap, not a formality: `nbNameRe` begins with
`[A-Za-z0-9_]`, so „_shares" was previously an accepted share name. The key never reaches a customer
surface; `backup.DisplayStackName` maps it to „Megosztások" at the notification and prose boundaries,
while the persisted set, the tag and the paths keep the raw key.
> **Live-validation status (2026-07-18): FULLY PROVEN-LIVE.** Tier-2 leg via the real
> `/api/backup/tier2` endpoint (tree + marker + payload, mirrored file md5-identical, payload 0600
> preserved). Offsite leg: snapshots `e0b9d723` (Viktor's manual run) and `4e2b15ec`, each tagged
> `felhom-offbox,_shares` and carrying the payload dir (`_shares-manifest.json` + `passdb.tar`, both
> 0600) alongside both share folders with uid 1000 preserved. Restore round-trip: a deleted probe file
> came back byte-identical and a deleted share DEFINITION came back with its original flags, while the
> two pre-existing files were NOT overwritten and `filmek` was reported *kept* — the missing-only and
> existing-wins contracts proven on live data. Samba liveness: hub-accepted `health_critical`.
> Remaining human leg: SMB positive auth with the real household password, which is never persisted.
## Operator notes
- samba is protected in **code** (`config.alwaysProtectedStacks`), because `cfg.Stacks.Protected` comes
from the golden-generated `controller.yaml` and predates the feature. **Since v0.145.0 (R-7b) it is
also monitored for liveness:** `monitor.EffectiveProtected` takes the SMB settings and adds the
container `infra.SambaContainerName` exactly while sharing is ON, so a dead sharing service raises
the standard protected-container issue → `health_critical` → alert → Hungarian degradation e-mail,
while a box that never enabled sharing stays quiet (both directions unit-tested and red-proofed).
Note the CONTAINER name (`felhom-samba`) is deliberately not the stack name (`samba`) — the health
check docker-inspects container names. PROVEN-LIVE 2026-07-18: stopping the container produced
`Protected container not running: felhom-samba`, status `fail`, and a hub-accepted `health_critical`
event; the next tick's `EnsureBaseStack` self-healed it.
- 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).