Research 038, ADR 0263 and to-be 52: let the mesh own shares and mounts, so no node depends on a hand-written export or fstab line
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
---
|
||||
status: active
|
||||
initiated: 2026-10-08
|
||||
touches:
|
||||
- 04-ISSUES/169-a-machine-shares-its-files-and-the-mesh-does-not-know/00-report.md
|
||||
- 04-ISSUES/154-a-machines-own-network-is-not-a-reach/00-report.md
|
||||
- 01-RESEARCH/027-the-system-layer-as-modules/02-candidates-and-questions.md
|
||||
- 03-DESIGN/01-to-be/26-the-seats.md
|
||||
- 03-DESIGN/01-to-be/48-a-module-says-how-it-is-healthy.md
|
||||
- 02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md
|
||||
- 02-DECISIONS/0051-shared-data-is-the-operators.md
|
||||
- 02-DECISIONS/0092-an-operator-delivers-a-pair-credential.md
|
||||
- the catalogue's module manifests
|
||||
- the controller's seat set
|
||||
became: []
|
||||
---
|
||||
|
||||
# 038 — Shares and mounts the mesh manages
|
||||
|
||||
## What is investigated
|
||||
|
||||
How the mesh shares a directory from one node and mounts it on the others, so that no machine
|
||||
carries a hand-written export or a hand-written mount line. Three kinds of source are in scope:
|
||||
|
||||
1. **A node's share.** The home server shares its storage pool over NFS. A node that wants the files
|
||||
mounts them.
|
||||
2. **A device outside the mesh that is not always there.** A gaming device on the home network shares
|
||||
a folder over SMB, and only while it is switched on. A USB disk is the same case.
|
||||
3. **What the mesh should show and say** when a mount works, when it does not, and when its source is
|
||||
simply away.
|
||||
|
||||
## Why
|
||||
|
||||
On 2026-10-08 the operator found two failed mounts on the workstation, and said two things:
|
||||
|
||||
- about the media library: *"This should be handled by the mesh. If a node exposes an NFS share, it
|
||||
should get configured on all the nodes in their fstab?"*
|
||||
- about the gaming device's share: *"This mount only works if I plug in my recalbox machine."*
|
||||
|
||||
They also set a requirement: **the home server shares its storage pool over NFS**, the workstation's
|
||||
library mount comes from that share, the export reaches only the mesh's private network, it squashes
|
||||
root, and the media modules' data (the Plex library is sacred) must not be put at risk.
|
||||
|
||||
[Issue 169](../../04-ISSUES/169-a-machine-shares-its-files-and-the-mesh-does-not-know/00-report.md)
|
||||
predicted the first failure on 2026-09-30: nothing declares the share's ports, so the packet filter
|
||||
closes them. This effort measures what happened, and proposes the decision issue 169 asked for.
|
||||
|
||||
## What was found
|
||||
|
||||
The detail is in [01-evidence.md](01-evidence.md). In short:
|
||||
|
||||
1. **The library mount times out because the home server's packet filter drops NFS.** The server
|
||||
exports the library and its NFS service runs. The mesh's filter accepts only what a module
|
||||
`listens` on, and no module listens on 2049, 111 or 445. The server's NFS counters read zero I/O
|
||||
since its last boot six days earlier, so no client has used either share since then.
|
||||
2. **The gaming device's mount fails because the gaming device is off.** It is an SMB share on the device itself,
|
||||
not a disk. The fstab line has no `nofail` and no automount, so each boot records a failed unit.
|
||||
The line also carries the share's password in a world-readable file.
|
||||
3. **The export is not where a person would look.** The server's `/etc/exports` holds only comments.
|
||||
The export comes from a property of the ZFS dataset, which writes its own file in `/etc/exports.d`.
|
||||
The library is a child dataset of the pool, so an export of the pool's root does not show the
|
||||
library's files unless it crosses into child filesystems.
|
||||
4. **User ids do not match across nodes.** The library is owned by a service account and group. The
|
||||
operator's account has the same id (1000) on every node, but on the home server that id belongs to
|
||||
a different account, which owns the pool's backup repository. NFS with `sec=sys` trusts the client's
|
||||
id. A plain export of the whole pool would give the workstation's operator account the backup
|
||||
repository.
|
||||
5. **No module, and until now no tool, covers any of this.** No module in either catalogue exports or
|
||||
mounts anything. No mesh tool read a machine's fstab, its mount units or its exports, so this effort
|
||||
read them through the login shell. A read-only `mounts` module now proposes those tools.
|
||||
|
||||
## Where it goes
|
||||
|
||||
[02-options.md](02-options.md) weighs the options. The proposed decision is
|
||||
[ADR 0263](../../02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md),
|
||||
and the to-be design is
|
||||
[52 — Shares and mounts](../../03-DESIGN/01-to-be/52-shares-and-mounts.md). Both wait for the
|
||||
operator's word. The open questions are listed at the end of the options.
|
||||
@@ -0,0 +1,134 @@
|
||||
# 038 — Evidence
|
||||
|
||||
Measured on 2026-10-08 on the four nodes, read only, through the mesh's tools: `node-service-manager`
|
||||
(`failed`, `status`, `journal`), `node-uplink.links`, `node-packet-filter.rules`,
|
||||
`disk-load.disk_filesystems`, `docker.docker_list`, and `node-login-shell.execute` for the files no
|
||||
tool read (§7). Addresses are given in documentation ranges: the home network (the underlay) as
|
||||
`192.0.2.0/24`. Node roles: the **home server**, the **workstation**, the **laptop**, the **control-node**.
|
||||
|
||||
## 1. The workstation's two failed mounts
|
||||
|
||||
`node-service-manager.failed` on the workstation lists two failed system units, both generated from
|
||||
`/etc/fstab` (their unit file is under `/run/systemd/generator`, and `mesh_declared` is false):
|
||||
|
||||
| unit | the fstab line (password removed) | what the journal says |
|
||||
|---|---|---|
|
||||
| the library | `<home server>:<library> <same path> nfs defaults,_netdev 0 0` | `Mounting timed out. Terminating.` 90 s after boot, at every boot since 2026-10-04; the last successful mount was at the boot of 2026-08-23 |
|
||||
| the gaming device | `//<device>/Share <mountpoint> cifs username=root,password=…,iocharset=utf8,sec=ntlmssp 0 0` | `mount error(113): could not connect to <device>` at every boot |
|
||||
|
||||
Neither line has `nofail` or `x-systemd.automount`. Both name the source by its underlay address.
|
||||
`remote-fs.target` is inactive on the workstation, because both of its members failed.
|
||||
|
||||
## 2. Why the library times out
|
||||
|
||||
- The home server exports the library, and its NFS and Samba services run. `nfs-server` and `smb`
|
||||
are active and enabled, and ports 2049, 111 and 445 are listening on every address.
|
||||
- **The mesh's packet filter drops them.** The mesh's table on the home server has `policy drop` on
|
||||
its input chain for the underlay link and the private network link. It accepts a port only where a
|
||||
module `listens` on it ([ADR 0045](../../02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md)).
|
||||
No rule names 2049, 111 or 445.
|
||||
- From the workstation, a TCP connection to the home server's port 2049 fails, and one to port 22 on
|
||||
the same address succeeds.
|
||||
- The home server's NFS counters (`/proc/net/rpc/nfsd`) read `io 0 0` since its boot on 2026-10-04.
|
||||
No established connection to 2049 or 445 exists. **Nobody has used either share since that boot.**
|
||||
|
||||
This is the failure [issue 169](../../04-ISSUES/169-a-machine-shares-its-files-and-the-mesh-does-not-know/00-report.md)
|
||||
predicted: the shares close silently once the filter is the sum of what modules listen on.
|
||||
|
||||
## 3. What the home server exports, and from where
|
||||
|
||||
- `/etc/exports` holds only the distribution's comments; it was last written on 2026-09-20.
|
||||
- The export comes from ZFS. The library dataset has `sharenfs=rw=@192.0.2.0/24` and `sharesmb=on`.
|
||||
ZFS writes `/etc/exports.d/zfs.exports` itself, marked *do not edit*. The kernel's export table
|
||||
(`/var/lib/nfs/etab`) holds one entry: the library, to the underlay range, `rw,sync,root_squash,
|
||||
no_all_squash,secure,no_subtree_check,sec=sys`.
|
||||
- The Samba configuration has one share, `media`, on the library, `valid users = media`,
|
||||
`force user = media`, writable.
|
||||
- **The pool is two filesystems.** The pool is mounted at its root, and the library is a child dataset
|
||||
mounted under it. An NFS export of the pool's root does not show the library's files unless the
|
||||
export crosses into child filesystems (`crossmnt`); without it, a client sees an empty directory
|
||||
where the library should be.
|
||||
|
||||
## 4. What lives on the pool, and who uses it
|
||||
|
||||
The pool's root holds five directories:
|
||||
|
||||
| directory | owner | what it is |
|
||||
|---|---|---|
|
||||
| the library (child dataset, about 90 TiB used) | the media account and group, `0775` | movies, series, anime, music, audiobooks and more |
|
||||
| downloads | the media account and group | the download clients' and the library managers' working area |
|
||||
| archive | root | the operator's archive |
|
||||
| a second Plex folder | the operator's account on the home server | an older library folder |
|
||||
| the backup repository | the operator's account on the home server, `0700` | the restic repository the backup seat writes |
|
||||
|
||||
Plex's own configuration and database live on a separate disk, not on the pool.
|
||||
|
||||
Eight running containers bind paths on the pool, all of them as plain bind mounts of local paths:
|
||||
Plex (the library's folders, all read-only), the library managers for series, movies, music and
|
||||
subtitles, two download clients, and one automation app. **Exporting the pool over NFS changes none of
|
||||
these paths and none of their owners.** An export adds a way in; it does not move or chown anything.
|
||||
The only way an export endangers this data is by what it lets a client do (§5).
|
||||
|
||||
## 5. User and group ids across the nodes
|
||||
|
||||
| node | id 1000 | the media account | the media group |
|
||||
|---|---|---|---|
|
||||
| home server | a separate account, which owns the backup repository and the older Plex folder | exists, owns the library | exists |
|
||||
| workstation | the operator | exists, same ids as on the server | exists; the operator is a member |
|
||||
| laptop | the operator | absent | absent |
|
||||
| control-node | the operator | absent | absent |
|
||||
|
||||
The library managers on the home server run as the media account and group, set by their `puid` and
|
||||
`pgid` settings.
|
||||
|
||||
With `sec=sys`, the server believes the id a client sends; `root_squash` changes only root. So:
|
||||
|
||||
- **A client's operator account (1000) is the home server's other account (1000).** An export of the
|
||||
pool's root would let it read and write the backup repository and the older Plex folder.
|
||||
- **A file written by the laptop's operator lands owned by 1000**, not by the media account, and the
|
||||
library managers may then fail to rename or delete it.
|
||||
- A device on the underlay that is not a node can claim any id. That is a reason to export to the
|
||||
private network only, where every address is a node.
|
||||
|
||||
## 6. The gaming device's share
|
||||
|
||||
- The gaming device is a separate machine on the underlay, a retro-gaming device that shares a folder named
|
||||
`Share` over SMB. It is not a USB disk. The router's DNS knows its name. It was switched off during
|
||||
the survey and did not answer a ping.
|
||||
- The workstation has `cifs-utils`, `avahi` and `nss-mdns`, and its `hosts:` line contains
|
||||
`mdns4_minimal`. **The packet filter drops inbound mDNS** on every node, as the `avahi` module's
|
||||
README already records. So discovery by mDNS cannot see the gaming device arrive today. Waiting for it
|
||||
would first need a local-link reach for `listens`, which is the question of
|
||||
[issue 154](../../04-ISSUES/154-a-machines-own-network-is-not-a-reach/00-report.md).
|
||||
- The share's password is written in `/etc/fstab`, which is mode `0644`. Every account on the
|
||||
workstation can read it.
|
||||
|
||||
## 7. What the catalogue and the tools cover
|
||||
|
||||
- **No module exports or mounts.** No manifest among the 117 modules of the core catalogue and the 12
|
||||
of the media catalogue writes an export, a Samba share, a mount unit or an fstab line.
|
||||
- **`disk-load.disk_filesystems`** lists NFS and CIFS filesystems that are mounted now, with their space.
|
||||
It cannot see a mount that failed, an fstab line, a mount unit or an export.
|
||||
- **`node-service-manager`** shows a mount unit's state and journal, but not what it mounts or with
|
||||
which options.
|
||||
- **No tool read a machine's fstab, its mount and automount units with their sources, or its NFS and
|
||||
SMB exports.** This effort read them with `node-login-shell.execute`, which runs a shell command as
|
||||
the operator account. That is a work-around for a missing tool, so a read-only `mounts` module with
|
||||
two tools, `mounts_list` and `mounts_exports`, was opened in the catalogue alongside this effort. It
|
||||
redacts passwords in mount options and never touches a network mount point, so a hung server cannot
|
||||
hang the tool.
|
||||
|
||||
## 8. Prior work
|
||||
|
||||
- **Issue 169** (open, the operator's proposal of 2026-09-30): two seats, `nfs-share` and `smb-share`,
|
||||
because the two protocols share an intent and not a contract; a consumer module that writes a
|
||||
systemd `.mount` unit from its binding; the modules that use the files never learn about NFS; the
|
||||
data-loss case of a writer starting before its mount; and identity crossing the wire.
|
||||
- **Research 027**, option M2: each client module writes its own systemd mount and automount unit, and
|
||||
`/etc/fstab` stays the machine's.
|
||||
- **Research 026**, the tools list: `exports`, `shares`, `clients`, `reload` on the server;
|
||||
`mounts`, `mount`, `unmount`, `test` on the client.
|
||||
- **ADR 0092**: a credential for something outside the mesh is an operator-delivered secret, accepted
|
||||
into the vault (`secret accept`) and never replaced by a made one.
|
||||
- **`${machine:mesh-range}`** already exists as a placeholder in two manifests, so an export line can
|
||||
name the private network's range without naming a node.
|
||||
@@ -0,0 +1,150 @@
|
||||
# 038 — Options
|
||||
|
||||
Each question below lists the options weighed and the one recommended. The recommendations together
|
||||
are [ADR 0263](../../02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md).
|
||||
|
||||
## Q1. Who writes the export
|
||||
|
||||
| option | for | against |
|
||||
|---|---|---|
|
||||
| A. Keep the ZFS `sharenfs` property, and have a module declare only the ports | nothing new on disk | the export lives in a dataset property, beside the operator's data; a module would be setting properties on data the mesh does not own (ADR 0051); the options are one string with no room for squashing |
|
||||
| B. A module owns `/etc/exports` whole | one file, one owner | the file is the machine's; a second exporter on the same node would collide |
|
||||
| **C. A module, `nfs-server`, writes its own file in `/etc/exports.d`, and the ZFS property is turned off when the module's export is live** | the mesh owns exactly its file; the distribution reads the directory; unassigning removes the file | a migration step: the property must be cleared by an act, not by hand |
|
||||
|
||||
**Recommended: C.** The module holds a node seat, `nfs-share`, so one module exports on a node, and it
|
||||
provides the provision `nfs-share` (issue 169's proposal). Samba is left as found, with its own seat
|
||||
`smb-share` later; nothing on the mesh uses it, and the gaming device's SMB share is a client case (Q6).
|
||||
|
||||
## Q2. What is exported, and to whom
|
||||
|
||||
The operator asked for the pool's root. Exporting it plainly has two problems (evidence §3 and §5): the
|
||||
library is a child filesystem, so the export needs `crossmnt` to show it; and with `sec=sys`, any client
|
||||
account with id 1000 becomes the server's own 1000, which owns the backup repository.
|
||||
|
||||
| option | for | against |
|
||||
|---|---|---|
|
||||
| A. The pool's root, `rw,root_squash`, to the underlay range | what the operator literally asked; what exists today (but for the library only) | gives the backup repository to every client's operator account; open to every device on the underlay, which can claim any id |
|
||||
| B. The pool's root, `rw,crossmnt,all_squash`, every client mapped to the media account, to the private network only | one share, as asked; every file written lands owned by the media account; the `0700` backup repository is unreadable to the media account, so it stays closed | every client writes as one account, so there is no per-person permission on the share; top-level folders other than the library are writable by every client |
|
||||
| **C. One share per subtree: the library and the downloads, each `rw,all_squash` to the media account, to the private network only; the backup repository and the archive not exported** | the backup repository is not reachable at all; ownership is right; a consumer mounts only what it needs | two shares instead of one; a later folder needs a new share entry (one setting) |
|
||||
| D. Kerberos (`sec=krb5`) with id mapping | real per-person identity | needs a KDC and keytabs on every node; far beyond the problem |
|
||||
|
||||
**Recommended: C, with B as the operator's alternative if one share is wanted.** Both use
|
||||
`all_squash` with `anonuid` and `anongid` set to the owner of the exported path, read on the server
|
||||
(`${access:<id>:uid}`, from issue 153's fix), never written as numbers in a manifest. Both export to
|
||||
`${machine:mesh-range}` only, so every client is a node, reached over the private network. `root_squash`
|
||||
is implied by `all_squash`. `sync`, `secure`, `no_subtree_check` stay as they are today. The server
|
||||
serves NFS version 4 only, so only port 2049 is opened, `from: mesh`; 111 and the mountd ports stay
|
||||
closed.
|
||||
|
||||
**What this does not touch:** the local bind mounts of Plex and the library managers (evidence §4),
|
||||
the datasets' owners and modes, and the Samba share.
|
||||
|
||||
## Q3. How a node mounts a share
|
||||
|
||||
| option | for | against |
|
||||
|---|---|---|
|
||||
| A. The mesh writes lines into `/etc/fstab` | the file people know | one shared file and one ordered list; the node-engine can place a marked block but every line competes with the operator's |
|
||||
| B. `autofs` with its own maps | mature automounter | a second daemon doing what systemd already does |
|
||||
| **C. A `.mount` unit and an `.automount` unit per share, written by one module, `mounts`, which holds the node seat `node-mounts`** | a unit is a unit: its state, its journal, its health; an automount never holds the boot; unassigning removes it; no shared file | units have to be named by the escaped mount path, which the module computes |
|
||||
|
||||
**Recommended: C.** The `.automount` is enabled under `remote-fs.target`; the `.mount` is not enabled
|
||||
on its own, so nothing mounts at boot and nothing fails at boot. The mount is made at first access and
|
||||
released after an idle time. The options carried:
|
||||
|
||||
- `x-systemd.automount` semantics through the `.automount` unit, `TimeoutIdleSec` (default 10 minutes);
|
||||
- a mount timeout, `TimeoutSec` on the `.mount` (default 30 s for a node's share, 10 s for an
|
||||
occasional one), instead of systemd's 90 s;
|
||||
- `_netdev` and an order after the private network's link is up;
|
||||
- for NFS, `vers=4.2,hard,timeo=150,retrans=3` on a read-write share (no silent write loss) and
|
||||
`soft` allowed for a read-only one, as a setting.
|
||||
|
||||
An automount also closes issue 169's data-loss case: the mount point is always an autofs mount, so a
|
||||
process that writes there either triggers the mount or gets an error. It never writes into the empty
|
||||
directory underneath.
|
||||
|
||||
## Q4. Which nodes get a share
|
||||
|
||||
The operator suggested every node. Weighed:
|
||||
|
||||
| option | for | against |
|
||||
|---|---|---|
|
||||
| A. Every node mounts every share | no assignment to make | the control-node is the stably reachable machine the controller runs on, and gains a writable path into the library for no use; the laptop, behind a corporate VPN client, would try a mount whenever a file manager looks at the path |
|
||||
| B. A node mounts a share only when a module on it requires it | precise | a person who wants the files in their file manager has no module that requires them |
|
||||
| **C. A node mounts what its `mounts` assignment lists; the list may be set once at the mesh-wide layer** | opt-in per node, and "every node" is one setting at the mesh-wide layer, with a node's own layer to leave a share out | two layers to read when asking why a node has a share; `mounts_list` says which layer gave it |
|
||||
|
||||
**Recommended: C, assigned to the workstation and the laptop, not to the control-node.** A module that needs
|
||||
the files (a player, an indexer) requires `nfs-share` as well; the controller then adds the share to
|
||||
that node's `mounts` unit set, so B is the special case of C, not a second way.
|
||||
|
||||
## Q5. Health
|
||||
|
||||
| case | finding | condition |
|
||||
|---|---|---|
|
||||
| a node's share, automount armed, server answers | healthy | none |
|
||||
| a node's share, server's 2049 unreachable from this node for 5 minutes | not healthy | `share-unreachable`, a warning: *"The home server's share `library` is not reachable from the workstation since 19:21."* |
|
||||
| a node's share, the mount failed with an error other than a timeout | not healthy | the same condition, with the error |
|
||||
| an occasional source, absent | **absent**, a state, not a finding | none |
|
||||
| an occasional source, present but the mount fails | not healthy | `share-unreachable` |
|
||||
| the server: the export table lacks a declared export, or `nfs-server` is down | not healthy | the module's ordinary health condition |
|
||||
|
||||
The `mounts` module's code probes each source's port on a timer and keeps each share's state in its
|
||||
own key-value bucket (ADR 0201): `mounted`, `armed`, `absent`, `unreachable`, with since when. Its
|
||||
health reads that bucket. Nobody reads its files.
|
||||
|
||||
## Q6. Occasional sources
|
||||
|
||||
The gaming device is not a node, so it cannot hold a module or provide a provision. Weighed:
|
||||
|
||||
| option | for | against |
|
||||
|---|---|---|
|
||||
| A. A fstab line with `nofail,x-systemd.automount` | small | a hand-written line again, and the password stays in the file |
|
||||
| **B. An external source in the `mounts` assignment's settings: protocol, name, share, mount point, presence `occasional`, and the name of a credential** | the same unit pair and tools as a node's share; the password goes to the vault | a share with no provider, so no binding: the module checks the name itself |
|
||||
| C. Wait for the device by mDNS and mount when it appears | mounts before anyone asks | the packet filter drops mDNS today (evidence §6); depends on issue 154 |
|
||||
|
||||
**Recommended: B now, C when issue 154 gives `listens` a local-link reach.** With an automount, nothing
|
||||
has to happen when the gaming device appears: the next access mounts it. The probe only keeps the state
|
||||
honest, so `mounts_list` says *"the share `retro-games` is not present since 18:02"*. A USB disk is the same kind of source,
|
||||
with presence taken from its device unit (`BindsTo=` on the disk's by-uuid device) instead of a probe.
|
||||
|
||||
**The credential:** the share's password is an operator-delivered secret, accepted into the vault with
|
||||
`secret accept` (ADR 0092), and reaches the node as a file the node-engine unseals (ADR 0086). The unit
|
||||
names it as `credentials=<that file>`. Nothing in a repository or in `/etc/fstab` holds it.
|
||||
|
||||
## Q7. Taking over the existing fstab lines
|
||||
|
||||
The two lines are the operator's. The node-engine's own `.mount` unit in `/etc/systemd/system` already
|
||||
takes precedence over the generated unit of the same name, but the line would still be read by every
|
||||
other tool and person.
|
||||
|
||||
**Recommended:** the `mounts` module gets an act, `adopt`, that for one mount point:
|
||||
|
||||
1. checks a unit pair the module placed is armed for that mount point, and refuses if not;
|
||||
2. comments out the fstab line, with a marker naming the module and the date, and keeps a copy of the
|
||||
file beside it;
|
||||
3. reloads the service manager and says what it did.
|
||||
|
||||
It runs only on a person's word. The library line is adopted after the home server's share works over
|
||||
the private network; the gaming device's after its credential is accepted.
|
||||
|
||||
## Q8. Over the bus
|
||||
|
||||
Every exchange between the modules is over NATS (research 036):
|
||||
|
||||
- the controller wires provider and consumer through the provision; the binding carries `at` (the
|
||||
server's private-network name), `path` and `options`;
|
||||
- `nfs-server` keeps its exports and the clients it sees in its bucket, and answers `test` (does it
|
||||
export this path to this node) by request and reply;
|
||||
- `mounts` keeps each share's state in its bucket; a view or the controller watches it;
|
||||
- no module reads another's files (ADR 0260).
|
||||
|
||||
## Open questions for the operator
|
||||
|
||||
1. **The pool's root, or one share per folder?** Recommended: one per folder (Q2 option C), so the
|
||||
backup repository is never exported.
|
||||
2. **Every node, or the nodes that ask?** Recommended: only the workstation and the laptop, with a
|
||||
mesh-wide setting available (Q4).
|
||||
3. **Underlay clients.** The private network only is recommended. Does anything on the home network
|
||||
that is not a node (a TV, a media player) still need the NFS or SMB share? Today the filter closes
|
||||
both, so nothing has been able to use them since 2026-10-04.
|
||||
4. **Read-write from the laptop.** Should the laptop mount the library read-only? Recommended: yes,
|
||||
read-only, since it travels.
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
topic: what runs on it
|
||||
status: proposed
|
||||
date: 2026-10-08
|
||||
deciders: jochen
|
||||
reconstructed: false
|
||||
extends: 02-DECISIONS/0051-shared-data-is-the-operators.md
|
||||
---
|
||||
|
||||
# 263. A share is a provision, a node mounts it on demand through one module, and only the nodes that ask get it
|
||||
|
||||
## Context
|
||||
|
||||
On 2026-10-08 two mounts on the workstation had failed at every boot since 2026-10-04
|
||||
([research 038](../01-RESEARCH/038-shares-and-mounts-the-mesh-manages/00-overview.md), evidence §1–§2):
|
||||
|
||||
- **The media library over NFS timed out** after 90 s. The home server exports it and its NFS service
|
||||
runs, but the mesh's packet filter drops port 2049, because no module listens on it (ADR 0045). The
|
||||
server's NFS counters read zero I/O since its boot six days earlier. This is the silent closure
|
||||
[issue 169](../04-ISSUES/169-a-machine-shares-its-files-and-the-mesh-does-not-know/00-report.md)
|
||||
predicted on 2026-09-30.
|
||||
- **A gaming device's SMB share failed** with "host unreachable", because the gaming device was off. The
|
||||
fstab line has no `nofail` and no automount, and it holds the share's password in a file every
|
||||
account can read.
|
||||
|
||||
The operator asked that the mesh handle this: a node that shares a directory should have it configured
|
||||
on the nodes that use it. They also required that the home server share its storage pool over NFS, to
|
||||
the private network only, with root squashed, and without risk to the media modules' data.
|
||||
|
||||
What was measured bears on the shape (evidence §3–§5):
|
||||
|
||||
- The export comes from a ZFS dataset property, not from `/etc/exports`.
|
||||
- The library is a child filesystem of the pool, so an export of the pool's root shows an empty
|
||||
directory there unless it crosses filesystems.
|
||||
- With `sec=sys` the server believes the client's user id. The operator's account is id 1000 on every
|
||||
node, and on the home server id 1000 is another account, which owns the backup repository.
|
||||
- Eight containers bind paths on the pool locally. An export changes none of their paths or owners.
|
||||
- No module exports or mounts anything, and no tool read fstab, mount units or exports.
|
||||
|
||||
## Considered Options
|
||||
|
||||
The full comparison is in research 038's [options](../01-RESEARCH/038-shares-and-mounts-the-mesh-manages/02-options.md).
|
||||
Rejected:
|
||||
|
||||
1. **Mesh-written lines in `/etc/fstab`.** One file shared with the operator, and one line per
|
||||
mount with everything in an option string. Rejected for a unit per mount.
|
||||
2. **`autofs` with its own maps.** A second daemon doing what systemd's automount already does.
|
||||
3. **Keep the ZFS `sharenfs` property and only open the port.** It keeps the export inside a property
|
||||
of the operator's data (ADR 0051), and it has no room for squashing every client to the library's
|
||||
owner.
|
||||
4. **Export to the home network (the underlay).** Every device there can claim any user id, and the
|
||||
underlay has no reach in the mesh yet (issue 154).
|
||||
5. **Kerberos (`sec=krb5`) for real identities.** It needs a KDC and keytabs on every node, for shares
|
||||
that have one owner.
|
||||
6. **Every node mounts every share.** The control-node is the stably reachable machine the controller runs on, and would gain a writable path
|
||||
into the library for no use. The laptop, behind a VPN client, would try a mount whenever something
|
||||
looks at the path. Kept as a setting, not as the default.
|
||||
7. **One `file-share` seat for NFS and SMB.** The two share an intent, not a contract (issue 169).
|
||||
|
||||
## Decision
|
||||
|
||||
1. **A node's share is a provision.** The module `nfs-server` holds the node seat `nfs-share` and
|
||||
provides the provision `nfs-share`. Its assignment's settings list the shares: a name, the path
|
||||
(an `access`, ADR 0051: the module never creates, chowns or removes it), and read-only or
|
||||
read-write. It writes its own file in `/etc/exports.d`, never `/etc/exports`, and listens on 2049/tcp
|
||||
from the private network. It serves NFS version 4 only, so no other port opens.
|
||||
2. **Every export squashes every client to the share's owner.** `all_squash`, with `anonuid` and
|
||||
`anongid` read from the exported path's owner on the server (`${access:<id>:uid}`), never written as
|
||||
numbers. The clients are `${machine:mesh-range}` and nothing else. A file written from any node lands
|
||||
owned by the library's owner, and no client acts as any other account on the server.
|
||||
3. **The home server exports one share per folder, not the pool's root**: the library and the
|
||||
downloads. The backup repository and the archive are not exported. (If the operator wants the pool's
|
||||
root as one share, it is exported with `crossmnt` and the same squashing, which keeps the `0700`
|
||||
backup repository closed. That is the alternative this record names, and the operator's to choose.)
|
||||
4. **A node mounts through one module.** The module `mounts` holds the node seat `node-mounts`. For each
|
||||
share it writes a `.mount` unit and an `.automount` unit, never an fstab line. Only the automount is
|
||||
enabled, so nothing mounts at boot and nothing can fail the boot. Each mount has an idle timeout
|
||||
(default 10 minutes) and a mount timeout (default 30 s; 10 s for an occasional source). A read-write
|
||||
NFS share is mounted `hard`. A mount point is always an autofs mount, so nothing writes into the
|
||||
empty directory under it (issue 169's data-loss case).
|
||||
5. **Only the nodes that ask get a share.** A node mounts what its `mounts` assignment lists, plus every
|
||||
share a module on that node requires. The list may be set once at the mesh-wide settings layer, which
|
||||
is how "every node" is said, and a node's own layer can leave a share out. The proposal is to assign
|
||||
`mounts` to the workstation and the laptop, and not to the control-node.
|
||||
6. **An occasional source is a share with presence `occasional`.** A device that is not a node, such as
|
||||
the gaming device or a USB disk, is described in the `mounts` assignment's settings. Its absence is a
|
||||
state, `absent`, not a fault. It is mounted on access when it is there, and it never fails the boot or
|
||||
leaves a failed unit. Mounting when a device appears on the network waits for issue 154 (mDNS is
|
||||
dropped by the packet filter today).
|
||||
7. **A credential for a share is an operator-delivered secret** (ADR 0092), accepted into the vault and
|
||||
unsealed on the node as a file (ADR 0086), which the mount unit names. No password is written in a
|
||||
repository, a manifest or `/etc/fstab`.
|
||||
8. **Health:** a share that should always be reachable and is not, for 5 minutes, raises one plain
|
||||
condition, `share-unreachable`, naming the share, the node and since when. An occasional source that
|
||||
is absent raises nothing. The server's health is its service and its export table holding every
|
||||
declared export.
|
||||
9. **Over the bus.** The controller wires the two through the provision. `nfs-server` keeps its exports
|
||||
and clients, and `mounts` keeps each share's state, in their own key-value buckets (ADR 0201). They
|
||||
ask each other by request and reply. Neither reads the other's files (ADR 0260).
|
||||
10. **Taking over a hand-written line is an act.** The `mounts` module's `adopt` comments out one fstab
|
||||
line, keeping a copy, only after its own unit pair for that mount point is armed, and only on a
|
||||
person's word. The ZFS `sharenfs` property is cleared the same way, by `nfs-server`, once its own
|
||||
export is live.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The workstation's library comes back over the private network, and the two failed units stop
|
||||
appearing at boot.
|
||||
- Two node seats join the mesh's closed set, `nfs-share` and `node-mounts`. That is a controller
|
||||
change.
|
||||
- What got harder: every client writes to a share as one account, so there is no per-person
|
||||
permission on it. Traffic to the library crosses the private network's encryption even on the home
|
||||
network; the workstation is on Wi-Fi, so the tunnel is not the limit there, and this is measured at
|
||||
build time.
|
||||
- The underlay's own devices lose the NFS share the property gave them. They have had no access since
|
||||
2026-10-04 anyway, because the filter has closed it. Giving them access back is issue 154's question.
|
||||
- Samba stays as found, and so does its closed port. An `smb-share` seat follows when something on the
|
||||
mesh needs it.
|
||||
|
||||
## How it is checked
|
||||
|
||||
- The `mounts` module's `mounts_list` shows every mount point's source, its unit pair, its state and
|
||||
which settings layer asked for it. `mounts_exports` shows the server's export table. A test in the
|
||||
module renders the unit pair from a binding and asserts the options above.
|
||||
- The lab composes a server and two clients and asserts: the export names only the private network's
|
||||
range with `all_squash`; a file written from a client is owned by the share's owner; the client's
|
||||
boot completes with the server stopped; and an occasional source that is absent leaves no failed unit.
|
||||
- The controller's self-check counts fstab lines of type `nfs` or `cifs` on every node through
|
||||
`mounts_list`, and reports any that are not adopted.
|
||||
|
||||
## References
|
||||
|
||||
- [Research 038](../01-RESEARCH/038-shares-and-mounts-the-mesh-manages/00-overview.md), its evidence and options
|
||||
- [Issue 169](../04-ISSUES/169-a-machine-shares-its-files-and-the-mesh-does-not-know/00-report.md), [issue 154](../04-ISSUES/154-a-machines-own-network-is-not-a-reach/00-report.md), [issue 153](../04-ISSUES/153-an-adopted-machines-data-cannot-be-placed-where-it-is/00-report.md)
|
||||
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md), [ADR 0051](0051-shared-data-is-the-operators.md), [ADR 0086](0086-a-secret-reaches-a-process-as-a-file.md), [ADR 0092](0092-an-operator-delivers-a-pair-credential.md), [ADR 0201](0201-a-module-keeps-its-current-state-in-key-value-buckets-it-declares-and-reaches-through-the-runtime.md), [ADR 0240](0240-a-module-says-how-it-is-healthy-and-the-node-engine-judges-it.md), [ADR 0260](0260-a-bar-block-shows-a-value-its-module-keeps-on-the-bus-and-no-module-reads-anothers-files.md)
|
||||
- [52 — Shares and mounts](../03-DESIGN/01-to-be/52-shares-and-mounts.md)
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
layer: to-be
|
||||
status: proposed
|
||||
code: []
|
||||
updated: 2026-10-08
|
||||
decisions:
|
||||
- 02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md
|
||||
- 02-DECISIONS/0051-shared-data-is-the-operators.md
|
||||
- 02-DECISIONS/0092-an-operator-delivers-a-pair-credential.md
|
||||
---
|
||||
|
||||
# 52 — Shares and mounts
|
||||
|
||||
**A node that shares a directory does it through one module, which provides the share as a provision
|
||||
and opens its port to the private network only. A node that wants the files gets them through one
|
||||
module, which writes a mount unit and an automount unit per share, so nothing mounts at boot and
|
||||
nothing fails at boot. Only the nodes that ask get a share. A device that is not always there is a
|
||||
share whose absence is a state, not a fault.**
|
||||
([ADR 0263](../../02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md),
|
||||
from [research 038](../../01-RESEARCH/038-shares-and-mounts-the-mesh-manages/00-overview.md).)
|
||||
|
||||
A **share** is a directory one machine offers to others over the network. A **mount** is a share made
|
||||
visible at a path on one node. An **occasional source** is a share on a device that comes and goes.
|
||||
These three words are proposed for the glossary with this design.
|
||||
|
||||
## The parts
|
||||
|
||||
```
|
||||
home server workstation, laptop (the nodes that ask)
|
||||
|
||||
nfs-server (seat nfs-share, node) mounts (seat node-mounts, node)
|
||||
├ /etc/exports.d/<its file> ├ <path>.automount enabled, idle 10 min
|
||||
│ one line per share, to the ├ <path>.mount not enabled, timeout 30 s
|
||||
│ private network's range only, │ What = binding at:path, options
|
||||
│ all_squash to the share's owner ├ credentials file (vault, ADR 0086),
|
||||
├ nfs-server.service, NFS v4 only │ for an SMB source only
|
||||
├ listens 2049/tcp from mesh ├ its process: probes each source,
|
||||
└ its bucket: exports, clients │ keeps state in its bucket
|
||||
▲ └ tools: mounts_list, mounts_exports,
|
||||
│ provision nfs-share mount, unmount, test, adopt
|
||||
│ (the controller wires it; ▲
|
||||
│ binding: at, path, options) │ settings: shares wanted, occasional
|
||||
└──────────────── request/reply on the bus ─────────┘ sources (mesh-wide and node layers)
|
||||
|
||||
gaming device (not a node) ── SMB share ──► an occasional source in the workstation's mounts settings
|
||||
```
|
||||
|
||||
| part | where | does |
|
||||
|---|---|---|
|
||||
| seats `nfs-share` and `node-mounts` | the controller's closed set | node seats, capacity 1, with their verbs |
|
||||
| `nfs-server` | the catalogue | holds `nfs-share`, provides the provision, writes its export file, runs the service, opens 2049 to the private network |
|
||||
| `mounts` | the catalogue | holds `node-mounts`, writes a unit pair per share, keeps each share's state, serves the tools |
|
||||
| the binding | the controller | gives a consumer the server's private-network name, the path and the options |
|
||||
| the credential | the vault | an operator-delivered secret per SMB source, unsealed on the node as a file |
|
||||
|
||||
## 1. The server: `nfs-server`
|
||||
|
||||
- **Settings.** A list of shares, each with a name, the path as an `access` (the operator's data; the
|
||||
module never creates, chowns or removes it), and `read-write` or `read-only`.
|
||||
- **Its file.** One file in `/etc/exports.d`, written by the node-engine. One line per share:
|
||||
the path, to `${machine:mesh-range}`, with `rw` or `ro`, `sync`, `secure`, `no_subtree_check`,
|
||||
`all_squash`, and `anonuid` and `anongid` taken from the path's owner on the server
|
||||
(`${access:<id>:uid}` and `:gid`). The pool's root is exported only if the operator asks, and then
|
||||
with `crossmnt` as well.
|
||||
- **The service.** `nfs-server.service`, running and enabled, restarted when the file changes.
|
||||
NFS version 4 only, set in the module's drop-in for `nfs.conf`. So only 2049/tcp is needed, declared in
|
||||
`listens` with `from: mesh`.
|
||||
- **The provision.** `nfs-share`, one per share, with `path` and `options` in what it serves.
|
||||
- **Health.** The service is active, and the kernel's export table holds every line of the module's
|
||||
file.
|
||||
- **Its bucket.** The exports as the kernel holds them, and the clients that have each mounted, with
|
||||
since when.
|
||||
- **Tools.** `exports` (r), `clients` (r), `reload` (a), and `test` (r): does this server export this
|
||||
path to this node.
|
||||
- **Taking over.** An act, `adopt`, clears a dataset's `sharenfs` property for a path the module now
|
||||
exports, on a person's word, and says what it did.
|
||||
|
||||
## 2. The client: `mounts`
|
||||
|
||||
- **Settings.** `shares`: the names of the `nfs-share` provisions this node mounts, each with a mount
|
||||
point and optional `read-only`. `sources`: occasional sources, each with a protocol (`smb` or `disk`),
|
||||
a name or a disk's UUID, a share name, a mount point, and the name of its credential. Both may be set
|
||||
at the mesh-wide layer, which is how "every node" is said, and changed at a node's own layer.
|
||||
- **A share a module requires.** When a module on the node requires `nfs-share`, the controller adds that
|
||||
share to the node's `mounts` set, with the mount point that module's assignment gives.
|
||||
- **The unit pair.** For each share, a `.mount` and an `.automount`, named by the escaped mount point and
|
||||
written in `/etc/systemd/system` by the node-engine:
|
||||
- the `.automount` is enabled under `remote-fs.target`, with `TimeoutIdleSec=10min`;
|
||||
- the `.mount` is not enabled; it has `TimeoutSec=30s` (10 s for an occasional source), `_netdev`,
|
||||
and an order after the private network's link;
|
||||
- for NFS: `vers=4.2`, `hard,timeo=150,retrans=3` when read-write, `soft` allowed when read-only;
|
||||
- for SMB: `credentials=<the unsealed file>`, `uid` and `gid` of the node's account that uses it;
|
||||
- for a disk: `BindsTo=` the disk's device unit, so the mount goes with the disk.
|
||||
- **States**, kept in its bucket per share, with since when: `armed` (automount waiting), `mounted`,
|
||||
`absent` (an occasional source not there), `unreachable` (should be there and is not), `failed`
|
||||
(the mount was tried and refused, with the error).
|
||||
- **Its process.** Probes each source's port every 30 s, and the device unit for a disk. It never
|
||||
touches a mount point, so a hung server cannot hang it.
|
||||
- **Health.** Every automount is armed, and every share that is not occasional is `armed` or `mounted`.
|
||||
An occasional source that is `absent` is healthy.
|
||||
- **Tools.** `mounts_list` (r), `mounts_exports` (r), `test` (r), `mount` and `unmount` (a), `adopt` (a).
|
||||
|
||||
## 3. Which nodes get which share
|
||||
|
||||
| node | gets | why |
|
||||
|---|---|---|
|
||||
| home server | none; it holds `nfs-server` | the files are local; the media modules keep their local bind mounts |
|
||||
| workstation | the library and the downloads, read-write; the gaming device as an occasional source | the operator works on the library there |
|
||||
| laptop | the library, read-only | it travels; the automount means it tries only when something looks |
|
||||
| control-node | none | the stably reachable machine the controller runs on; it has no use for the library |
|
||||
|
||||
## 4. Conditions
|
||||
|
||||
| condition | raised when | cleared when |
|
||||
|---|---|---|
|
||||
| `share-unreachable` (warning) | a share that is not occasional has been `unreachable` or `failed` for 5 minutes | the next probe reaches it |
|
||||
| the server's health | the export table lacks a declared line, or the service is down | the next look finds both right |
|
||||
|
||||
Each says the share, the node and since when: *"The home server's share `library` is not reachable
|
||||
from the workstation since 19:21."* An absent occasional source raises nothing; `mounts_list` says
|
||||
*"the share `retro-games` is not present since 18:02"*.
|
||||
|
||||
## 5. Migration
|
||||
|
||||
1. Build and deliver `mounts` (read-only first, already proposed) and `nfs-server`, and add the two seats.
|
||||
2. Assign `nfs-server` to the home server with the library and the downloads. Check `exports` and
|
||||
`test` from the workstation.
|
||||
3. `adopt` on the home server clears the library's `sharenfs` property. The underlay range leaves the
|
||||
export table; the private network's range is in it.
|
||||
4. Assign `mounts` to the workstation. Its unit pair for the library takes precedence over the unit
|
||||
generated from fstab. Check `mounts_list` and a file written from the workstation.
|
||||
5. `adopt` on the workstation comments out the library's fstab line, keeping a copy of the file.
|
||||
6. Accept the gaming device's credential into the vault with `secret accept`, add the gaming device as an
|
||||
occasional source, and `adopt` its fstab line.
|
||||
7. Assign `mounts` to the laptop, read-only.
|
||||
|
||||
Every `adopt` is a person's act. Nothing in this migration touches the library's files, owners or
|
||||
modes, or the media modules' bind mounts.
|
||||
|
||||
## Not in this design
|
||||
|
||||
- **Samba.** Kept as found. An `smb-share` seat follows when a node needs it.
|
||||
- **Clients on the underlay.** NFS for devices that are not nodes waits on issue 154.
|
||||
- **Mounting when a device appears.** Waits on a local-link reach (issue 154), because the packet
|
||||
filter drops mDNS. The automount already mounts on the next access.
|
||||
@@ -51,6 +51,7 @@ document is written and this one's status becomes `implemented`.
|
||||
| [`49-the-mesh-in-domains.md`](49-the-mesh-in-domains.md) | **In progress.** Every concept belongs to one of ten domains, which owns its one word; the glossary is organised by them and is the authority, a retired word is named on its replacement's line with its scope, and two checks hold this repository's documents and the catalogue's tool descriptions to it | [ADR 0244](../../02-DECISIONS/0244-the-mesh-is-described-in-domains-and-one-word-names-one-thing.md), [ADR 0006](../../02-DECISIONS/0006-the-substrate-and-the-control-plane.md), [ADR 0008](../../02-DECISIONS/0008-a-context-owns-its-store.md) |
|
||||
| [`50-split-dns-on-a-machine-with-a-vpn-client.md`](50-split-dns-on-a-machine-with-a-vpn-client.md) | **In progress.** A machine whose VPN client pushes servers of its own runs a resolver of its own, on a node seat, that routes the VPN's domains over its link and every other name to the mesh's resolvers; the VPN client's module carries the adapter | [ADR 0247](../../02-DECISIONS/0247-a-machine-with-a-vpn-client-routes-names-by-domain-through-a-resolver-of-its-own.md), [ADR 0223](../../02-DECISIONS/0223-the-mesh-has-two-resolvers-and-a-machine-lists-only-them.md) |
|
||||
| [`51-the-registries-keep-what-is-named.md`](51-the-registries-keep-what-is-named.md) | **In progress.** The artifact store, the package registry and each machine's container runtime get tools that say what they hold, what names each thing and what could go; every removal is a dry run unless a person asks and says why, and what decides is always a record | [ADR 0251](../../02-DECISIONS/0251-the-registries-say-what-they-hold-and-keep-what-is-named-and-a-machine-keeps-the-image-it-runs-and-the-one-before.md), [ADR 0189](../../02-DECISIONS/0189-the-store-keeps-what-the-records-name.md) |
|
||||
| [`52-shares-and-mounts.md`](52-shares-and-mounts.md) | **Proposed.** A node shares a directory through `nfs-server`, which provides it as a provision to the private network only, squashing every client to the share's owner; a node that asks mounts it through `mounts`, a mount unit and an automount per share, so nothing mounts or fails at boot; a device that comes and goes is an occasional source whose absence is a state | [ADR 0263](../../02-DECISIONS/0263-a-share-is-a-provision-a-node-mounts-it-on-demand-and-only-nodes-that-ask-get-it.md), [ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md), [ADR 0092](../../02-DECISIONS/0092-an-operator-delivers-a-pair-credential.md) |
|
||||
|
||||
## Not yet written
|
||||
|
||||
|
||||
Reference in New Issue
Block a user