diff --git a/03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md b/03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md index 7f5216d..989eeed 100644 --- a/03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md +++ b/03-DESIGN/01-to-be/29-a-node-has-operator-accounts.md @@ -1,8 +1,11 @@ --- layer: to-be -status: proposed -code: [] -updated: 2026-09-27 +status: in-progress +code: + - mesh-controller internal/inventory + - mesh-controller internal/catalogue + - mesh-controller cmd/mesh-controller +updated: 2026-10-01 decisions: - 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md - 02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md @@ -14,10 +17,10 @@ decisions: # 29 — A node has operator accounts, and the mesh owns what lives under a home **The mesh models machines but not the people on them.** A node record holds its name, its -address, its mode — and nothing about *who a person is* on it: `jochens` on novox, `ace` on ace, -`jochen` on shanks and g14. That username is not incidental. It decides who a file under `~` is -owned by, who a user service runs as, and — the case that surfaced this — which account `ssh -` logs in as. The predecessor knew it (its per-node `user:`, and the modules that wrote a +address, its mode — and nothing about *who a person is* on it: one login name on the build node, +another on the home-server, a third on both workstations. That username is not incidental. It +decides who a file under `~` is owned by, who a user service runs as, and — the case that surfaced +this — which account `ssh ` logs in as. The predecessor knew it (its per-node `user:`, and the modules that wrote a person's `~/.ssh/config`, `~/.zshrc`, `~/.config`); the mesh, taking those over, kept the machine facts and dropped the human one. @@ -28,8 +31,9 @@ Several things are missing, and they are one idea. A node has one or more **operator accounts**: the human logins on it. At minimum a name; the mesh already knows the node and its address, so `@` is then a complete answer to "who am I, where." It is the mesh's to hold because everything below is derived from it, and -because it is exactly the fact that was silently lost — `ssh ace` failed to `ace` because nothing -in the mesh said ace's account is `ace`. +because it is exactly the fact that was silently lost — `ssh home-server` logged in under the +workstation's own name, because nothing in the mesh said the home-server's account is a different +one. ## 2. A resource may live under a home, owned by its account @@ -65,14 +69,14 @@ create `~/.ssh` at `0700`, chown it to the account, and own the files it places **The boundary — and it is the reason this is safe:** `~/.ssh` is the one directory where a wrong declaration locks a person out of their own machine. So the mesh's *found-vs-owned* semantics -([ADR 0126](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md), +([ADR 0118](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md), adoption) apply *inside* the home directory. The mesh **owns** the directory and the files above; it **holds as found — never rewrites, never removes** — the operator's own contents: their **private keys** and their **personal drop-ins** (`config.d/personal`, the personal `Host` aliases a workstation carries, exactly as `hosts.local` is the home the mesh never rewrites for `/etc/hosts`). Reconcile removing an unassigned `config.d/mesh` is fine; the same logic aimed at `id_ed25519` or an operator's own `authorized_keys` entry is a lockout. This is the login-channel cousin of the rule -[ADR 0125](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) draws for the uplink and the sshd +[ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) draws for the uplink and the sshd module draws for the firewall: **the mesh must never be able to arrange the one failure that severs its own way back in.** The carve-out is not a convenience; it is that rule, in `~/.ssh`. @@ -108,12 +112,12 @@ found-vs-owned boundary of §3 is exactly what guarantees nothing already there None of this needs a node to discover the mesh, and none of it needs a control-plane module of its own. The ssh files are **roster facts** -([ADR 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md)): once the +([ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md)): once the roster view carries a node's **host key** and its **account** beside its name and address, the `ssh-client` module ships a template for `known_hosts`, `config` and `authorized_keys`, and the controller renders each node's copy from the full roster and pushes it. The mesh owns the data; the module owns ssh's format; the control plane gains no ssh syntax. It is the same act as composing a -peer list or `/etc/hosts` — which is why there is **no novox-only "mesh-ssh" module**: the +peer list or `/etc/hosts` — which is why there is **no control-node-only "mesh-ssh" module**: the centralization is the controller's composition, not a module that runs somewhere. Only non-secret facts travel (names, addresses, accounts, host keys, the CA public key); the private key stays the operator's, placed as an operator-owned file, referenced by path. @@ -129,6 +133,44 @@ operator's, placed as an operator-owned file, referenced by path. They meet at the account and the CA, not at a bespoke module. The `sshd` server side already exists; the client/identity side and the CA are the open pieces. +## What has shipped, and what has not + +*Recorded 2026-10-01 from the controller's main branch, not from intent.* + +**Built (mesh-controller, merged 2026-09-27):** + +- **§1, the account as a node fact.** A node record carries an operator account and, optionally, + its home. Empty is a real state — a freshly enrolled or headless machine has no operator account + known yet — and an empty home means *derive it* (the superuser's home for the superuser, the + conventional per-user home otherwise), so the common case needs no entry. The controller's node + command sets it. One account per node is what exists; "one or several" below is still open. +- **§2, resources under a home.** The account and its home are offered as machine facts, and a + resource's *path and owner* resolve placeholders exactly as its content does — so a module places + a file under a person's home, owned by that person, naming neither. A roster file may say it lives + under the home: it is rendered per node, placed under that node's account's home, chowned to the + account, and a node with no account gets none. +- **§5, the composed ssh config.** The roster rendering carries each node's account, so the + `ssh-client` template can emit a `Host` block per node with the right login name. Composed + end-to-end in the controller's tests. + +**Written but not shipped:** the `ssh-client` catalogue module itself exists on a branch of the +module repository; its pull request was closed with a hold until this design is deployed, and +nothing has deployed it since. The predecessor's generator still writes every workstation's ssh +client blocks today — which is where [issue 172](../../04-ISSUES/172-the-ssh-client-block-matches-one-spelling-of-a-machine/00-report.md) +was found. + +**Not built:** the SSH CA and certificates (§4), `known_hosts` and `authorized_keys` as roster files, +the found-vs-owned boundary inside `~/.ssh` (§3 — the controller has no rule yet that refuses to +rewrite a private key), adoption of existing keys, the ssh-agent as a user service, and user-scoped +services in general. The host vocabulary still has no user-scope unit at all; a workstation's +per-user daemons (a bar watchdog, a config reloader, an audio service masked per user) have no form +the mesh can send. + +**A gap this surfaced:** §1 shipped as code before it had a decision record. The account as a node +fact, the home as a placement root, and what the mesh may and may not do under a home are each a +decision this document names but no record states. They are the next records to write, before the +family of §2 modules is built. + ## Why now, and why not yet **Why it matters:** when HAL retires, the generators that keep `~/.ssh`, shell config and the @@ -137,7 +179,7 @@ alias and its trust, and a fresh machine has no operator dotfiles at all — the service and leave the human unable to work on the box. **Why not build it reflexively:** it is a real addition to the node model, the resource model, and -the seat set, and must be gotten right. The mechanism half is now settled — ADR 0128 is what lets +the seat set, and must be gotten right. The mechanism half is now settled — ADR 0120 is what lets the ssh files be templates with no control-plane format — so what remains to decide here is the model: @@ -156,15 +198,18 @@ model: unnecessary, and forwarding an agent into a node exposes the operator's keys to that node's root — so prefer certificates and `ProxyJump` over forwarding. -**Not urgent, not blocking.** ssh and dotfiles work today because HAL's generators still run as the -substrate. This becomes load-bearing in the node-by-node retirement phase, not before — which is the -right time to build it, once the account and CA model are decided here. +**Now load-bearing.** The migration of every node to the mesh is complete; what remains of the +predecessor is exactly the user environment this design covers — ssh config, dotfiles, the desktop +stack and the per-user services of the two workstations. Those generators are the last thing +keeping the predecessor running, so the model questions above are no longer deferred: the account +record, the home as a placement root, user-scoped services and the one-off steps a hook used to run +each need a decision before the modules that replace the generators can be written. ## References - The gap was found generating `~/.ssh/config` from the *HAL* registry (`hal/terminal`'s postConfigure hook), which the nox mesh has no equivalent for. -- [ADR 0128](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster +- [ADR 0120](../../02-DECISIONS/0120-a-roster-fact-carries-its-format-as-a-template.md) — the roster fact mechanism that renders the ssh files, format owned by the module. - [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) — the system-path placement this mirrors for home paths. @@ -173,6 +218,6 @@ right time to build it, once the account and CA model are decided here. - [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md) — the CA key is a secret the vault makes; [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md) — short-lived certs as rotation. -- [ADR 0125](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md), - [ADR 0126](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md) — +- [ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md), + [ADR 0118](../../02-DECISIONS/0118-undeclaring-gives-a-unit-back-the-state-it-was-found-in.md) — the never-sever-the-channel rule and the found-vs-owned semantics, applied here to `~/.ssh`. diff --git a/03-DESIGN/01-to-be/README.md b/03-DESIGN/01-to-be/README.md index 94c194d..d9afdda 100644 --- a/03-DESIGN/01-to-be/README.md +++ b/03-DESIGN/01-to-be/README.md @@ -38,7 +38,7 @@ document is written and this one's status becomes `implemented`. | [`26-the-seats.md`](26-the-seats.md) | **Proposed.** What a mesh can have one of, who fills each, and a seat's holder answering for the provision it delivers — including the `git` seat a build's source can live on | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)), [ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md), [ADR 0109](../../02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md) | | [`27-a-module-requires-the-mesh-resolves.md`](27-a-module-requires-the-mesh-resolves.md) | **Proposed.** One concept for everything a module needs: a requirement with a contract, answered by one of four kinds of provider, resolved at assignment or refused. Retires settings, placeholders, facts and paths in definitions | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md), [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md), [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)) | | [`28-building-the-bus.md`](28-building-the-bus.md) | **Proposed.** The five steps of the bus work in the order their dependencies allow, each ending at a bed — with the surface measured, so no step's size is a guess | [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md), [ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md), [ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md) | -| [`29-a-node-has-operator-accounts.md`](29-a-node-has-operator-accounts.md) | **Proposed.** The mesh models machines but not the humans on them: a node gains operator accounts, and a resource may live under a home owned by its account — what would own ~/.ssh, dotfiles and ~/.config when HAL retires | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md) | +| [`29-a-node-has-operator-accounts.md`](29-a-node-has-operator-accounts.md) | **In progress.** A node has an operator account and a resource may live under its home — built in the controller; the ssh-client module, the SSH CA, the `~/.ssh` boundary and user-scoped services are not. The account fact still wants its decision record | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0051](../../02-DECISIONS/0051-shared-data-is-the-operators.md) | | [`32-what-a-module-declares.md`](32-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0126](../../02-DECISIONS/0126-a-module-declares-its-own-seats.md), [ADR 0127](../../02-DECISIONS/0127-amqp-is-a-provision-not-the-bus.md), superseded by [ADR 0131](../../02-DECISIONS/0131-everything-on-the-mesh-speaks-to-the-broker-seat.md) (superseding [ADR 0125](../../02-DECISIONS/0125-the-bus-is-the-only-broker.md)), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) |