From add923c74a8e66d6f8463887dafa20aabed319e8 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:37:44 +0200 Subject: [PATCH] ssh-client: the mesh's region first in ~/.ssh/config, its hosts in config.d, tools in Go The region at the end let earlier Host lines win over the mesh's (research 027/03). A roster fact cannot be placed at the start, so the region holds one Include of config.d, and the hosts are config.d/00-mesh, read first. Eight tools; authorized_keys and known_hosts stay found until the controller holds those facts. --- modules/ssh-client/README.md | 109 +++ .../ssh-client/cmd/ssh-client-tools/client.go | 844 ++++++++++++++++++ .../ssh-client/cmd/ssh-client-tools/config.go | 246 +++++ .../ssh-client/cmd/ssh-client-tools/keys.go | 110 +++ .../ssh-client/cmd/ssh-client-tools/main.go | 138 +++ .../ssh-client/cmd/ssh-client-tools/runner.go | 61 ++ .../cmd/ssh-client-tools/ssh_test.go | 409 +++++++++ modules/ssh-client/go.mod | 5 + modules/ssh-client/go.sum | 2 + modules/ssh-client/module.json | 49 +- 10 files changed, 1969 insertions(+), 4 deletions(-) create mode 100644 modules/ssh-client/README.md create mode 100644 modules/ssh-client/cmd/ssh-client-tools/client.go create mode 100644 modules/ssh-client/cmd/ssh-client-tools/config.go create mode 100644 modules/ssh-client/cmd/ssh-client-tools/keys.go create mode 100644 modules/ssh-client/cmd/ssh-client-tools/main.go create mode 100644 modules/ssh-client/cmd/ssh-client-tools/runner.go create mode 100644 modules/ssh-client/cmd/ssh-client-tools/ssh_test.go create mode 100644 modules/ssh-client/go.mod create mode 100644 modules/ssh-client/go.sum diff --git a/modules/ssh-client/README.md b/modules/ssh-client/README.md new file mode 100644 index 0000000..8c40d39 --- /dev/null +++ b/modules/ssh-client/README.md @@ -0,0 +1,109 @@ +# ssh-client + +The operator account's `~/.ssh` as a module (novox/hq research 027/03, ADR 0182; to-be 42 phase 1, +item 9). `sshd` is the machine's side. + +## What it declares, by ADR 0182's classes + +| 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/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 | + +### Why an include, not Host blocks in the region + +ssh takes the first value it finds for each option. Research 027/03 asks that the mesh's hosts come +before anything else in `~/.ssh/config`. Before this change, the region was written at the end: a +predecessor's hosts above it won, for the same machines. + +A roster fact cannot be placed at the start. The controller gives facts no `at`, and the host leaves +an existing region where it is. So the region at the start holds only the include, and the hosts +are a whole file in `config.d` that the include reads first. `00-` sorts it before any other drop-in. + +ssh restores the including file's section after an `Include` (OpenSSH `readconf.c`). So an operator +line just below the region is global again, as it was at the top of the file. This module's tests +parse the declared region and check it, and `ssh -G` was compared before and after on every machine. + +The old region, `ssh-client.fact-ssh-config` at the end of `~/.ssh/config`, is no longer declared, so +the host removes it, and only it, at the first push. + +## The gap: authorized keys and known hosts + +Research 027/03 gives the mesh a region in `authorized_keys` (the operator's keys as the mesh records +them) and one in `known_hosts` (every machine's host key). **The controller holds neither fact.** + +- A roster template sees each machine's name, mesh name, address and account, and nothing else + (mesh-controller `roster.go`). +- No machine fact carries an ssh host key. +- The only key the controller knows for the operator is a sealing key for secrets, not an ssh key. + +So both files stay *found*, and this module invents nothing. + +**What would close it:** + +1. The host reports its machine's public host keys in its profile, and the roster gains a field for + them. +2. The operator's public keys become a mesh record: per account, with a comment saying whose and + where. + +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 mesh removes nothing it did not make. After the first push, a machine that had the predecessor's +layout still holds: + +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. + +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. + +## Tools + +They run as the operator account and never escalate. No answer carries a key: fingerprints only. + +| tool | | what | +|---|---|---| +| `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_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-