From acf075fa63ccdbd9b7a820b7ddb20f45aa798178 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Sat, 17 Oct 2026 12:38:16 +0000 Subject: [PATCH] ssh-client keeps ~/.ssh to what it knows: sweep every other path into a kept folder (issue 1017, ADR 0415) 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//, a folder never shared with another sweep, with a list beside it, which ssh_client_check shows. Nothing is deleted. --- modules/ssh-client/README.md | 38 +-- .../ssh-client/cmd/ssh-client-tools/client.go | 6 +- .../ssh-client/cmd/ssh-client-tools/main.go | 13 +- .../cmd/ssh-client-tools/ssh_test.go | 11 + .../ssh-client/cmd/ssh-client-tools/sweep.go | 269 +++++++++++++++++ .../cmd/ssh-client-tools/sweep_test.go | 271 ++++++++++++++++++ 6 files changed, 589 insertions(+), 19 deletions(-) create mode 100644 modules/ssh-client/cmd/ssh-client-tools/sweep.go create mode 100644 modules/ssh-client/cmd/ssh-client-tools/sweep_test.go diff --git a/modules/ssh-client/README.md b/modules/ssh-client/README.md index 8c40d398..8b92fd9c 100644 --- a/modules/ssh-client/README.md +++ b/modules/ssh-client/README.md @@ -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//`, keeping its path relative to `~/.ssh`, and the list +of what moved is written beside it as `.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-