a03e142101
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.
209 lines
12 KiB
Markdown
209 lines
12 KiB
Markdown
# 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).
|