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

12 KiB
Raw Blame History

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 subtreeappdata/ (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).