ssh-client keeps ~/.ssh to what it knows: sweep every other path into a kept folder (issue 1017, ADR 0415)
mesh/merge-gate head acf075fa63cc merged into trunk 9675babf528b — pass: builds ssh-client → ace, g14, novox, shanks; no bus step; every machine composes w…
mesh/repo-check head acf075fa63cc merged into trunk 9675babf528b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered

ssh-client owns ~/.ssh, but treated every file it did not place as found, so a
predecessor's drop-in and old copies stayed there for weeks. Its process now
sweeps at start and every ten minutes: keys at any depth (and links to them),
authorized_keys, authorized_keys2 and sockets stay, and a folder holding one is
gone into; everything else moves to ~/.local/state/ssh-client/retired/<UTC
time>/, a folder never shared with another sweep, with a list beside it, which
ssh_client_check shows. Nothing is deleted.
This commit is contained in:
2026-10-17 12:38:16 +00:00
parent a7d526838a
commit acf075fa63
6 changed files with 589 additions and 19 deletions
+21 -17
View File
@@ -8,12 +8,15 @@ item 9). `sshd` is the machine's side.
| path | class | how |
|---|---|---|
| `~/.ssh/` | owned | directory, the account's, mode 0700 |
| `~/.ssh/config.d/` | owned | directory, the account's, mode 0700. This is ssh's own drop-in directory: another module that needs a host (a forge, a work bastion) places its own file here |
| `~/.ssh/config.d/` | owned | directory, the account's, mode 0700. It holds only the files this module declares (ADR 0415 §6). As every module's owned path, ssh-client is the only module that writes under `~/.ssh`: another module that needs a host there provides it through a provision, and this module writes it (ADR 0349 §1; the forge's host comes from `forge-account`). A host of the operator's own goes in `~/.ssh/config` below the mesh's region |
| `~/.ssh/config.d/10-forge` | owned | the forge's host, with the machine's own key (ADR 0399) |
| `~/.ssh/mesh_ed25519`, `.pub` | written by the module's own process | the machine's account's key for the forge, made once (keykeeper.go) |
| `~/.ssh/config.d/00-mesh` | owned | a Host block per other machine of the mesh (a roster fact), regenerated whenever a machine joins, leaves or is renamed. It begins with the mesh's header |
| `~/.ssh/config` | written into, **at the start** | the mesh's region, `# BEGIN mesh ssh-client.config` … `# END …`. It holds one `Include ~/.ssh/config.d/*`. Every line below the region is the operator's, kept byte for byte |
| `~/.ssh/authorized_keys` | found | see *The gap* |
| `~/.ssh/known_hosts` | found | see *The gap* |
| private keys | found | never read, never written. `ssh_client_keys` and `ssh_client_check` read a file's first line to recognise a key, and ask `ssh-keygen` about it |
| private keys, public keys, certificates | kept | never read, never written, never moved, at any depth and whatever their name. `ssh_client_keys` and `ssh_client_check` read a file's first line to recognise a key, and ask `ssh-keygen` about it |
| anything else | swept | moved out of `~/.ssh` by the sweep (below) |
### Why an include, not Host blocks in the region
@@ -54,23 +57,24 @@ So both files stay *found*, and this module invents nothing.
Each region is then one more `into: block` resource here. Until then, `ssh_client_check` reads the
files as they are, and `ssh_client_revoke` / `ssh_client_known_host` act on them by hand.
## The one-off clean-up (ADR 0182: the operator removes a predecessor's output, once)
## The sweep: `~/.ssh` holds only what this module knows (ADR 0415, issue 1017)
The mesh removes nothing it did not make. After the first push, a machine that had the predecessor's
layout still holds:
The module owns `~/.ssh`, so it keeps it to what it knows. Its process sweeps when it starts and every
ten minutes. It knows the paths in the table above, every key (a private key by its first line, a
public key or certificate by its line, at any depth and whatever its name) and sockets (ssh's own
runtime). Every other path is moved, whole, into
`~/.local/state/ssh-client/retired/<UTC time>/`, keeping its path relative to `~/.ssh`, and the list
of what moved is written beside it as `<UTC time>.json`. Nothing is deleted. A folder that holds a key
is gone into rather than moved whole, so the key stays where it is. A file or folder that cannot be read to
tell whether it holds a key is left and named in the log.
1. **The predecessor's `~/.ssh/config.d/mesh`.** Its hosts repeat the mesh's, with the same values,
plus one forge host that no module carries yet. Move that host to your own part of
`~/.ssh/config`, or to a work module's drop-in, then delete the file.
2. **The predecessor's header at the top of `~/.ssh/config`**, now just below the mesh's region: the
comment beginning *"Mesh Host blocks are GENERATED"* and its `Include ~/.ssh/config.d/mesh` line.
3. **A predecessor's block of mesh hosts marked *managed by sshd***, on the machines that still have
one.
4. **Backups beside the live files** (`config.bak-*`, `known_hosts.old`, `removed-*`). `ssh_client_check`
lists them as debris.
So the predecessor's `config.d/mesh`, `config.bak-*`, `known_hosts.old`, `removed-*` and
`authorized_keys.revoked-*` leave `~/.ssh` at the first sweep. Each move is logged by the process, and
`ssh_client_check` lists every sweep that moved something, with where each path lies now.
Until you do, `ssh_client_hosts` and `ssh_client_check` list the repeated hosts as duplicates. The
mesh's still win, because they are read first.
The predecessor's lines inside `~/.ssh/config`, below the mesh's region (its header comment and its
`Include ~/.ssh/config.d/mesh`), are the operator's part of a written-into file and stay. ssh skips an
`Include` that matches no file.
## Tools
@@ -80,7 +84,7 @@ They run as the operator account and never escalate. No answer carries a key: fi
|---|---|---|
| `ssh_client_hosts` | r | every Host and Match section in the order ssh reads it, each with file, line and source (`mesh`, `drop-in`, `operator`); duplicates; what is set outside any section |
| `ssh_client_resolve` | r | `ssh -G` for one host: what ssh would use, and which sections matched |
| `ssh_client_check` | r | modes of the directory and every file; keys with no passphrase (`ssh-keygen -y -P ""`, output used only to compare with the `.pub`), weak, old, unused, or whose `.pub` is another key's; one key under several names; the mesh's region first with its include; duplicate hosts; `known_hosts` for the mesh's machines, missing or stale (scanned live, 5 s each in parallel, unless `scan` is false); authorized keys without a comment; debris. It says what it did not check |
| `ssh_client_check` | r | modes of the directory and every file; keys with no passphrase (`ssh-keygen -y -P ""`, output used only to compare with the `.pub`), weak, old, unused, or whose `.pub` is another key's; one key under several names; the mesh's region first with its include; duplicate hosts; `known_hosts` for the mesh's machines, missing or stale (scanned live, 5 s each in parallel, unless `scan` is false); authorized keys without a comment; debris; what the sweep moved out of `~/.ssh` and where each lies now. It says what it did not check |
| `ssh_client_keys` | r | every private key, by its public half: type, size, fingerprint, comment, mode, age, passphrase, whether ssh offers it by itself |
| `ssh_client_authorized` | r | `authorized_keys` by fingerprint, type, size, comment and options |
| `ssh_client_revoke` | a | removes every line with one fingerprint, keeping the file first as `authorized_keys.revoked-<time>`. It refuses the last key (a lockout) and a key in a mesh region (a push would write it back) |