Files
mesh-catalog/modules/ssh-client/README.md
T
jochen dde9c264f5 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.
2026-10-04 12:38:40 +02:00

110 lines
7.0 KiB
Markdown

# 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-<time>`. It refuses the last key (a lockout) and a key in a mesh region (a push would write it back) |
| `ssh_client_known_host` | r/a | known entries against what the host offers now: `matches`, `changed`, `not known` or `unreachable`. With `refresh`, it replaces the host's entries through `ssh-keygen -R` (which keeps `known_hosts.old`) with what was scanned. That trusts whatever answers |
| `ssh_client_test` | r | one batch-mode connection that runs only `true`: the address reached, the method and key that authenticated, or why not and which keys were offered. A key with a passphrase works only through an agent. The runtime has none in its environment, so the tool looks for the account's agent socket under `/run/user/<uid>` and names the one it used, or says there was none |
## Tests
```
go test ./...
```
The tests use a temporary home and a fake runner, with public keys made for the tests only. They cover:
- reading order and source across includes, with an operator line after the include being global
again;
- the declared region parsed as ssh reads it;
- duplicates;
- keys: fingerprints computed as `ssh-keygen -l` does, RSA size, passphrase asked and never prompted,
a mismatched `.pub`;
- `authorized` never printing a key;
- `revoke`: the backup, the mode kept, a lockout refused, a mesh region refused;
- `known_host` matching, changed and refreshed, and an unreachable host never refreshed;
- `test`: success, and failure with the agent named;
- `check`'s findings;
- that the tools served are the manifest's `tools`.