Compare commits

...
Author SHA1 Message Date
jochen add923c74a 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:37:44 +02:00
jochen d4a6008bd6 docker: the container runtime as a module, with its tools in Go
Claims node-container-runtime (ADR 0207). Owns the packages, the socket and a weekly
prune of dangling images and unused build cache. Serves 18 tools over every container,
marking the mesh's. daemon.json, docker.service and the docker group are left to a
proposed change: dnsmasq and zsh declare them today, and the controller refuses a
second declaration (README).
2026-10-04 12:37:44 +02:00
19 changed files with 4058 additions and 4 deletions
+166
View File
@@ -0,0 +1,166 @@
# docker
The container runtime as a module (novox/hq to-be 42 phase 1, item 8; research 027/01–02; ADR 0166,
ADR 0207). It claims the node seat `node-container-runtime`. That seat carries no verbs yet: its verbs,
and the host creating containers through its holder, wait on ADR 0166's acceptance. Until then the
tools below are the module's own.
## What it declares
| resource | what | the host's rule |
|---|---|---|
| `package` | `docker` | installed if absent; never uninstalled when the module goes |
| `buildx` | `docker-buildx` | the same. Only the build machine has it today; `docker build` needs it for BuildKit everywhere |
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
| `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module |
| `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) |
The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It
takes no volume, no container and no image a container uses, so it never touches a container the mesh
holds. It runs at idle priority, at a random point in the hour after the weekly mark. A run missed
while the machine was off happens at the next boot.
**Capabilities:** `package-manager`, `service-manager`, `privileged`. It does not declare
`container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and
the module that installs the daemon cannot require it.
## What it does not declare yet, and why
Three things this module should own are already declared by other modules on every machine. The
controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package`
(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would
make the module unassignable everywhere. The refusals were checked against the controller's own
check:
```
zsh and docker both declare the name "${machine:account}"
dnsmasq and docker both declare the path "/etc/docker/daemon.json"
dnsmasq and docker both declare the unit "docker.service"
```
### 1. `/etc/docker/daemon.json` and `docker.service` (issue 190)
Today the file has three writers. Each writes into it (`into: json`, ADR 0102) and reloads the
service:
- **`dnsmasq`** writes `dns` and `live-restore`, through `dnsmasq.runtime-dns` and `dnsmasq.runtime`.
- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes
`insecure-registries`. The collision check does not see generated resources.
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand.
**The change proposed, in one merge:**
1. `dnsmasq` drops its `runtime-dns` and `runtime` resources.
2. `docker` adds the two resources below:
```json
{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json",
"content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true, \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"100m\", \"max-file\": \"5\"}}\n"},
{"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", "boot": "enabled", "reload-on": ["daemon"]}
```
The service is **reloaded, never restarted**: a restart stops every container. The daemon reads
`live-restore` on a reload. It reads `dns`, `log-driver` and `log-opts` only at its next start, so
they apply then (to containers created afterwards, for the log keys). With `live-restore` on, that
start keeps every container running.
**Why one merge, and only after this module is on every machine:**
- In one apply, the host first gives back the resources that are no longer declared, then applies
the new ones (mesh-host `apply.go`).
- `dnsmasq` gives back `dns` and `live-restore` to what they held before it, and `docker` sets them
again in the same apply. The daemon is reloaded once, after both steps.
- A machine pushed the new `dnsmasq` *without* this module would keep its pre-mesh values for both
keys. On one machine that is `live-restore: false`, and the next daemon restart there would stop
every container.
**Later:** the controller hands the registry to this module as a value, and the overlay stops
generating its two resources (issue 190, steps 2 and 5). Until then the overlay keeps writing its one
key beside this module's. The host merges disjoint keys correctly; the mesh-host `into.go` record is
per resource.
### 2. The operator account's membership of the `docker` group
The right shape is the host's `user` shape. Its `groups` are additive: the host runs
`usermod --append` and never takes a group away.
```json
{"id": "group", "type": "user", "name": "${machine:account}", "groups": ["docker"]}
```
`zsh` already declares a `user` resource for the same account (its login shell). The controller
compares `name` across modules, so the two collide.
**The change proposed (mesh-controller, `checkResources`):** judge a `user` resource by the fields it
sets, not by its name:
- `shell` and `home` stay single-owner;
- `groups` may be declared by any number of modules, because the host only adds them.
Then this module declares the resource above, and no module has to carry another's group.
Today the operator account is in the group on every machine, by hand. Nothing is lost while it waits.
## The bootstrap's runtime
On the machine the mesh was first installed on, the foundation bundle declared `package docker`
(`container-runtime`) and `docker.service` running and enabled (`container-runtime-running`). ADR 0207
§5 exempts them.
- The host records them under their bare ids, with origin *carried*. A mesh declaration's orphan pass
never sees them (mesh-host `store.go`).
- So `docker.package` here is a **second record of the same package**. The apply says "already
installed", and neither record ever uninstalls it.
- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step
1 would add a second record of that unit. Its found state is *running*, because genesis started
it, so undeclaring this module would leave the daemon running.
## Tools
The tools run as the operator account. If the daemon's socket refuses that account, a call is asked
again through `sudo -n` (a process keeps the groups it started with). Every call has a 20 s bound.
A failure is an error naming how it failed, never an empty answer.
**Every container on the machine is in scope.** A container the mesh holds carries the host's label
`mesh-host.id` (its value names the assignment), and every answer says `mesh_held`.
| tool | | what |
|---|---|---|
| `docker_list` | r | every container: image, state, health, restarts, ports, mounts, compose project, `mesh_held`; filter by owner, state or name |
| `docker_inspect` | r | one container whole, **environment values left out** (names kept) |
| `docker_logs` | r | the last lines of both streams, merged in order, with timestamps (default 200, at most 2000) |
| `docker_stats` | r | CPU, memory, I/O and process count per running container, heaviest first |
| `docker_start` / `docker_stop` / `docker_restart` | a | one container. On a mesh-held one, the answer says the host restores its declared state at its next apply |
| `docker_top` | r | the processes inside one container |
| `docker_images` | r | images, largest first, with the containers using each; `dangling`, `unused` or `used` |
| `docker_prune` | a | dangling images and build cache, and stopped containers the mesh does not hold if `containers` is true. **A dry run unless `dry_run` is false. Never a volume** |
| `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each |
| `docker_networks` | r | networks, subnets, and the containers on each |
| `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked |
| `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise |
| `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet |
| `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list |
| `docker_problems` | r | unhealthy, restarting, dead, killed for memory, failed, or restarted five times or more |
| `docker_ports` | r | every published port, and the containers on the host's network |
## Tests
```
go test ./...
```
The tests run against a fake runner and cover:
- escalation through `sudo -n` on a refused socket, and never as root;
- each failure named by its cause;
- a name or id never read as an option;
- mesh-held marking;
- the environment left out of `inspect`;
- the restore note on a mesh-held act;
- prune being a dry run by default and never reaching a volume, a mesh container or `--volumes`;
- the log merge;
- size parsing;
- what the daemon has not yet taken;
- event filtering;
- volume ownership;
- that the tools served are exactly the manifest's `tools`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,360 @@
package main
import (
"context"
"encoding/json"
"errors"
"io/fs"
"os"
"reflect"
"strings"
"testing"
"time"
)
type call struct {
name string
args []string
}
// fake answers each command by the first rule whose prefix matches "name arg arg…".
type fake struct {
rules []rule
calls []call
}
type rule struct {
prefix string
ran Ran
}
func (f *fake) on(prefix string, r Ran) *fake { f.rules = append(f.rules, rule{prefix, r}); return f }
func (f *fake) run(_ context.Context, name string, args ...string) Ran {
f.calls = append(f.calls, call{name, args})
line := strings.Join(append([]string{name}, args...), " ")
for _, r := range f.rules {
if strings.HasPrefix(line, r.prefix) {
return r.ran
}
}
return Ran{Status: 1, Stderr: "unexpected: " + line}
}
func (f *fake) ran(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(strings.Join(append([]string{c.name}, c.args...), " "), prefix) {
return true
}
}
return false
}
func client(f *fake, uid int) *Client {
return &Client{Run: f.run, UID: uid, ReadFile: func(string) ([]byte, error) { return nil, fs.ErrNotExist },
Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }}
}
const held = `{"Id":"aaaaaaaaaaaaaaaa","Name":"/mesh-web","Created":"2026-10-01T00:00:00Z","Image":"sha256:img1",
"Config":{"Image":"web:1","Labels":{"mesh-host.id":"hello-web.server","mesh-host.spec":"x"},"Env":["PASSWORD=hunter2","PATH=/bin"]},
"State":{"Status":"running","Running":true,"StartedAt":"2026-10-01T00:00:01Z","FinishedAt":"0001-01-01T00:00:00Z","Health":{"Status":"healthy"}},
"HostConfig":{"RestartPolicy":{"Name":"unless-stopped"},"NetworkMode":"bridge"},
"NetworkSettings":{"Ports":{"80/tcp":[{"HostIp":"0.0.0.0","HostPort":"8080"}]}},
"Mounts":[{"Type":"volume","Name":"webdata","Destination":"/data","RW":true}]}`
const stray = `{"Id":"bbbbbbbbbbbbbbbb","Name":"/dev-db","Created":"2026-09-01T00:00:00Z","Image":"sha256:img2",
"Config":{"Image":"postgres:16","Labels":{"com.docker.compose.project":"dev","com.docker.compose.project.working_dir":"/home/op/dev"}},
"State":{"Status":"exited","ExitCode":1,"FinishedAt":"2026-09-02T00:00:00Z"},
"HostConfig":{"RestartPolicy":{"Name":"no"}},"NetworkSettings":{"Ports":{}},
"Mounts":[{"Type":"volume","Name":"dbdata","Destination":"/var/lib/postgresql/data","RW":true}]}`
func machine() *fake {
return (&fake{}).
on("docker ps --all --quiet --no-trunc", Ran{Stdout: "aaaaaaaaaaaaaaaa\nbbbbbbbbbbbbbbbb\n"}).
on("docker container inspect aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbb", Ran{Stdout: "[" + held + "," + stray + "]"}).
on("docker container inspect mesh-web", Ran{Stdout: "[" + held + "]"}).
on("docker container inspect dev-db", Ran{Stdout: "[" + stray + "]"})
}
func TestARefusedSocketIsAskedAgainThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) {
denied := Ran{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get ...: dial unix /var/run/docker.sock: connect: permission denied\n"}
f := (&fake{}).on("docker ", denied).on("sudo -n docker info", Ran{Stdout: "{}"})
if _, err := client(f, 1000).docker(context.Background(), "info", "--format", "{{json .}}"); err != nil {
t.Fatal(err)
}
if !f.ran("sudo -n docker info --format") {
t.Fatalf("not escalated: %+v", f.calls)
}
f = (&fake{}).on("docker ", denied)
if _, err := client(f, 0).docker(context.Background(), "info"); err == nil || f.ran("sudo") {
t.Fatalf("root escalated or answered: %v %+v", err, f.calls)
}
}
func TestFailuresAreNamedByHowTheyFailed(t *testing.T) {
denied := Ran{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock\n"}
cases := map[string]*fake{
"may not escalate without a prompt": (&fake{}).on("docker ", denied).on("sudo ", Ran{Status: 1, Stderr: "sudo: a password is required\n"}),
"sudo is not installed": (&fake{}).on("docker ", denied).on("sudo ", Ran{Status: 127, Err: "ENOENT"}),
"docker is not installed": (&fake{}).on("docker ", Ran{Status: 127, Err: "ENOENT"}),
"daemon is not answering": (&fake{}).on("docker ", Ran{Status: 1, Stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?\n"}),
"did not answer: no answer within": (&fake{}).on("docker ", Ran{Status: 124, Err: "no answer within 20 s"}),
"docker info failed (3): boom": (&fake{}).on("docker ", Ran{Status: 3, Stderr: "boom\n"}),
}
for want, f := range cases {
_, err := client(f, 1000).docker(context.Background(), "info")
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("want %q, got %v", want, err)
}
}
}
func TestANameIsNeverAnOption(t *testing.T) {
for _, bad := range []string{"--help", "-v", "", "a b", "x;y"} {
if _, err := Ref(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
for _, good := range []string{"mesh-web", "aaaaaaaaaaaa", "registry.mesh.internal:5100/x@sha256:abc", "dev_db.1"} {
if _, err := Ref(good); err != nil {
t.Errorf("%q refused: %v", good, err)
}
}
f := machine()
for _, verb := range []string{"start", "stop", "restart"} {
if _, err := client(f, 1000).Act(context.Background(), verb, "--rm"); err == nil {
t.Errorf("%s took an option", verb)
}
}
if len(f.calls) != 0 {
t.Fatalf("docker was called: %+v", f.calls)
}
}
func TestEveryContainerIsListedAndTheMeshsAreMarked(t *testing.T) {
c := client(machine(), 1000)
all, err := c.Containers(context.Background(), "", "", "")
if err != nil || len(all) != 2 {
t.Fatalf("%v %+v", err, all)
}
web, db := all[1], all[0]
if !web.MeshHeld || web.HeldBy != "hello-web.server" || web.Module != "hello-web" || web.Health != "healthy" {
t.Errorf("held: %+v", web)
}
if !reflect.DeepEqual(web.Ports, []string{"0.0.0.0:8080->80/tcp"}) || web.Mounts[0].Name != "webdata" {
t.Errorf("ports/mounts: %+v", web)
}
if db.MeshHeld || db.Compose != "dev" || db.ComposeDir != "/home/op/dev" || db.FinishedAt == "" {
t.Errorf("stray: %+v", db)
}
mesh, _ := c.Containers(context.Background(), "mesh", "", "")
other, _ := c.Containers(context.Background(), "other", "", "")
if len(mesh) != 1 || mesh[0].Name != "mesh-web" || len(other) != 1 || other[0].Name != "dev-db" {
t.Errorf("held filter: %+v / %+v", mesh, other)
}
if _, err := c.Containers(context.Background(), "mine", "", ""); err == nil {
t.Error("an unknown held filter was accepted")
}
}
func TestNoContainersIsAnEmptyListAndAFailureIsAnError(t *testing.T) {
got, err := client((&fake{}).on("docker ps", Ran{}), 1000).Containers(context.Background(), "", "", "")
if err != nil || got == nil || len(got) != 0 {
t.Fatalf("%v %v", got, err)
}
if _, err := client((&fake{}).on("docker ps", Ran{Status: 1, Stderr: "Cannot connect to the Docker daemon\n"}), 1000).Containers(context.Background(), "", "", ""); err == nil {
t.Fatal("a daemon that does not answer read as no containers")
}
}
func TestInspectLeavesTheEnvironmentsValuesOut(t *testing.T) {
got, err := client(machine(), 1000).Inspect(context.Background(), "mesh-web")
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(got)
if strings.Contains(string(b), "hunter2") || !strings.Contains(string(b), `"PASSWORD"`) || got["mesh_held"] != true {
t.Fatalf("%s", b)
}
}
func TestActingOnAMeshContainerSaysTheHostRestoresIt(t *testing.T) {
f := machine().on("docker stop", Ran{}).on("docker start", Ran{})
got, err := client(f, 1000).Act(context.Background(), "stop", "mesh-web")
if err != nil {
t.Fatal(err)
}
if !f.ran("docker stop --time 10 mesh-web") || got["mesh_held"] != true || !strings.Contains(got["note"].(string), "host restores") {
t.Fatalf("%v %+v", got, f.calls)
}
got, _ = client(f, 1000).Act(context.Background(), "start", "dev-db")
if _, noted := got["note"]; noted || got["mesh_held"] != false {
t.Fatalf("a stray was noted: %v", got)
}
}
func TestPruneIsADryRunByDefaultAndNeverTouchesAVolumeOrAMeshContainer(t *testing.T) {
f := machine().
on("docker image ls --no-trunc --filter dangling=true", Ran{Stdout: `{"ID":"sha256:dead","Size":"1.5GB"}` + "\n"}).
on("docker system df --format", Ran{Stdout: `{"Type":"Build Cache","TotalCount":"3","Size":"2GB","Reclaimable":"1GB"}` + "\n"}).
on("docker image prune", Ran{Stdout: "Deleted Images:\nx\n\nTotal reclaimed space: 1.5GB\n"}).
on("docker builder prune", Ran{Stdout: "Total:\t1GB\n"}).
on("docker container rm", Ran{})
c := client(f, 1000)
got, err := c.Prune(context.Background(), PruneAsk{Images: true, BuildCache: true, Containers: true, DryRun: true})
if err != nil {
t.Fatal(err)
}
if f.ran("docker image prune") || f.ran("docker builder prune") || f.ran("docker container rm") {
t.Fatalf("a dry run removed something: %+v", f.calls)
}
if got["images"].(map[string]any)["dangling"] != 1 || !reflect.DeepEqual(got["containers"].(map[string]any)["stopped_not_held"], []string{"dev-db"}) {
t.Fatalf("%v", got)
}
got, err = c.Prune(context.Background(), PruneAsk{Images: true, BuildCache: true, Containers: true, OlderThanH: 24})
if err != nil {
t.Fatal(err)
}
if !f.ran("docker image prune --force --filter until=24h") || !f.ran("docker builder prune --force --filter until=24h") || !f.ran("docker container rm dev-db") {
t.Fatalf("not pruned: %+v", f.calls)
}
for _, c := range f.calls {
line := strings.Join(c.args, " ")
if strings.Contains(line, "volume") || strings.Contains(line, "mesh-web") && c.args[0] != "container" || strings.Contains(line, "--volumes") || strings.Contains(line, "--all") && c.args[0] != "ps" {
t.Errorf("prune reached too far: %s", line)
}
}
if got["images"].(map[string]any)["reclaimed"] != "1.5GB" || got["build_cache"].(map[string]any)["reclaimed"] != "1GB" {
t.Errorf("reclaimed: %v", got)
}
}
func TestLogsMergeBothStreamsInOrderAndKeepTheTail(t *testing.T) {
f := (&fake{}).on("docker logs", Ran{Stdout: "2026-10-04T10:00:01Z out one\n2026-10-04T10:00:03Z out two\n", Stderr: "2026-10-04T10:00:02Z err one\n"})
got, err := client(f, 1000).Logs(context.Background(), "web", 2, "30m")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got["lines"], []string{"2026-10-04T10:00:02Z err one", "2026-10-04T10:00:03Z out two"}) {
t.Fatalf("%v", got["lines"])
}
if !f.ran("docker logs --timestamps --tail 2 --since 30m web") {
t.Fatalf("%+v", f.calls)
}
if _, err := client(f, 1000).Logs(context.Background(), "web", 2, "--follow"); err == nil {
t.Fatal("since took an option")
}
f = (&fake{}).on("docker logs", Ran{Status: 1, Stderr: "Error response from daemon: No such container: nope\n"})
if _, err := client(f, 1000).Logs(context.Background(), "nope", 2, ""); err == nil {
t.Fatal("a missing container read as no lines")
}
}
func TestSizesAreReadAsDockerPrintsThem(t *testing.T) {
for in, want := range map[string]int64{"0B": 0, "55.63GB": 55630000000, "33.2MiB": 34812723, "1.5kB": 1500, "12MB (34%)": 12000000, "N/A": -1} {
if got := Bytes(in); got != want {
t.Errorf("%s: %d, want %d", in, got, want)
}
}
}
func TestDaemonConfigSaysWhatTheDaemonHasNotTakenYet(t *testing.T) {
f := (&fake{}).on("docker info", Ran{Stdout: `{"ServerVersion":"29.8.2","LiveRestoreEnabled":false,"LoggingDriver":"json-file","RegistryConfig":{"IndexConfigs":{"docker.io":{"Secure":true},"registry.mesh.internal:5100":{"Secure":false}}}}`})
c := client(f, 1000)
c.ReadFile = func(string) ([]byte, error) {
return []byte(`{"live-restore": true, "dns": ["10.0.0.1"], "log-driver": "local"}`), nil
}
got, err := c.DaemonConfig(context.Background())
if err != nil {
t.Fatal(err)
}
pending := strings.Join(got["pending"].([]string), "\n")
if !strings.Contains(pending, "live-restore is true in the file and false") || !strings.Contains(pending, "log-driver is local") {
t.Errorf("pending: %s", pending)
}
if !reflect.DeepEqual(got["daemon"].(map[string]any)["InsecureRegistries"], []string{"registry.mesh.internal:5100"}) {
t.Errorf("registries: %v", got["daemon"])
}
if !reflect.DeepEqual(got["read_only_at_start"], []string{"dns", "log-driver"}) {
t.Errorf("start-only: %v", got["read_only_at_start"])
}
c.ReadFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
got, _ = c.DaemonConfig(context.Background())
if !strings.HasPrefix(got["file_state"].(string), "absent") {
t.Errorf("absent: %v", got["file_state"])
}
c.ReadFile = func(string) ([]byte, error) { return nil, errors.New("permission denied") }
got, _ = c.DaemonConfig(context.Background())
if !strings.HasPrefix(got["file_state"].(string), "unreadable") {
t.Errorf("unreadable: %v", got["file_state"])
}
}
func TestEventsAreABoundedWindowWithoutExecNoise(t *testing.T) {
out := `{"Type":"container","Action":"exec_start: pg_isready","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"db"}},"timeNano":1}
{"Type":"container","Action":"die","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"web","mesh-host.id":"hello-web.server","exitCode":"137"}},"timeNano":2}
`
f := (&fake{}).on("docker events", Ran{Stdout: out})
got, err := client(f, 1000).Events(context.Background(), 30, "container", 10, false)
if err != nil {
t.Fatal(err)
}
evs := got["events"].([]map[string]any)
if len(evs) != 1 || evs[0]["action"] != "die" || evs[0]["mesh_held"] != true || evs[0]["exit_code"] != "137" {
t.Fatalf("%v", evs)
}
if !f.ran("docker events --since 30m --until 0s --format {{json .}} --filter type=container") {
t.Fatalf("%+v", f.calls)
}
if _, err := client(f, 1000).Events(context.Background(), 30, "secret", 10, false); err == nil {
t.Fatal("an unknown type was accepted")
}
}
func TestVolumesSayWhoMountsThemAndWhetherTheMeshDoes(t *testing.T) {
f := machine().
on("docker volume ls --quiet", Ran{Stdout: "webdata\ndbdata\nloose\n"}).
on("docker volume inspect", Ran{Stdout: `[{"Name":"webdata","Driver":"local"},{"Name":"dbdata","Driver":"local"},{"Name":"loose","Driver":"local","Labels":{"com.docker.volume.anonymous":""}}]`})
got, err := client(f, 1000).Volumes(context.Background(), false, false)
if err != nil {
t.Fatal(err)
}
vols := got["volumes"].([]map[string]any)
if vols[0]["mesh_held"] != true || vols[1]["mesh_held"] != false || len(vols[2]["mounted_by"].([]map[string]any)) != 0 || vols[2]["anonymous"] != true {
t.Fatalf("%v", vols)
}
got, _ = client(f, 1000).Volumes(context.Background(), true, false)
if got["count"] != 1 {
t.Fatalf("unmounted: %v", got)
}
}
func TestImagesNameTheirUsers(t *testing.T) {
f := machine().on("docker image ls", Ran{Stdout: `{"ID":"sha256:img1","Repository":"web","Tag":"1","Size":"100MB"}
{"ID":"sha256:img3","Repository":"<none>","Tag":"<none>","Size":"2GB"}
`})
got, err := client(f, 1000).Images(context.Background(), "", "", 10)
if err != nil {
t.Fatal(err)
}
imgs := got["images"].([]Image)
if imgs[0].ID != "sha256:img3" || !imgs[0].Dangling || imgs[1].UsedBy[0] != "mesh-web" || !imgs[1].MeshUsed {
t.Fatalf("%+v", imgs)
}
got, _ = client(f, 1000).Images(context.Background(), "unused", "", 10)
if got["count"] != 1 {
t.Fatalf("unused: %v", got)
}
}
func TestProblemsNameWhyAndUnlabelledIsTheCleanupList(t *testing.T) {
c := client(machine(), 1000)
p, err := c.Problems(context.Background())
if err != nil || len(p) != 1 || p[0]["name"] != "dev-db" || p[0]["why"].([]string)[0] != "exited 1" {
t.Fatalf("%v %v", p, err)
}
u, err := c.Unlabelled(context.Background())
if err != nil || u["count"] != 1 {
t.Fatalf("%v %v", u, err)
}
}
+279
View File
@@ -0,0 +1,279 @@
// docker's Go tools bundle (novox/hq ADR 0188, ADR 0193): a process the node's tool runtime launches
// and speaks MCP over stdio to, through the Go SDK. It answers for every container on this machine —
// the mesh's and every other — and for the runtime's images, networks, volumes, events and
// configuration. It runs as the operator account (ADR 0175 §4); docker.go says how it reaches the
// daemon's socket. The host applies the module's resources; these tools answer about the runtime.
package main
import (
"context"
"fmt"
"math"
"os"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): docker.
if err := stdio.Serve("", tools(NewClient())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var containerArg = map[string]any{"type": "string", "description": "the container's name or id"}
func tools(c *Client) []stdio.Tool {
ctx := context.Background()
act := func(verb, description string) stdio.Tool {
return stdio.Tool{
Name: "docker_" + verb, Description: description,
Input: map[string]any{"container": containerArg},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
return c.Act(ctx, verb, ref)
},
}
}
return []stdio.Tool{
{
Name: "docker_list",
Description: "Every container on this machine — the mesh's and every other — with its image, state, health, restarts, " +
"published ports, mounts, compose project, and mesh_held/held_by (the assignment that holds it).",
Input: map[string]any{
"held": map[string]any{"type": "string", "enum": []string{"all", "mesh", "other"}, "description": "whose: all (default), the mesh's, or the others"},
"state": map[string]any{"type": "string", "description": "only containers in this state (running, exited, created, restarting, paused, dead)"},
"match": map[string]any{"type": "string", "description": "only containers whose name or image contains this"},
},
Run: func(args map[string]any) (any, error) {
list, err := c.Containers(ctx, optional(args, "held"), optional(args, "state"), optional(args, "match"))
if err != nil {
return nil, err
}
return map[string]any{"count": len(list), "containers": list}, nil
},
},
{
Name: "docker_inspect",
Description: "One container whole, as docker inspects it, with mesh_held; its environment's values are left out (names kept), because that is where a container's secrets are.",
Input: map[string]any{"container": containerArg},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
return c.Inspect(ctx, ref)
},
},
{
Name: "docker_logs",
Description: "The last lines one container wrote, both streams merged in order, each with its timestamp (default 200, at most 2000 lines; a line is cut at 4 KiB).",
Input: map[string]any{
"container": containerArg,
"lines": map[string]any{"type": "integer", "description": "how many lines from the end (default 200, at most 2000)"},
"since": map[string]any{"type": "string", "description": "only lines since then: a duration such as 30m or 2h, or a time"},
},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
n, err := bounded(args, "lines", 200, 2000)
if err != nil {
return nil, err
}
return c.Logs(ctx, ref, n, optional(args, "since"))
},
},
{
Name: "docker_stats",
Description: "What the running containers use now — CPU, memory, network and disk I/O, processes — the heaviest by memory first; or one container's.",
Input: map[string]any{"container": map[string]any{"type": "string", "description": "one container (optional)"}},
Run: func(args map[string]any) (any, error) {
stats, err := c.Stats(ctx, optional(args, "container"))
if err != nil {
return nil, err
}
return map[string]any{"count": len(stats), "containers": stats}, nil
},
},
act("start", "Start one container. A container the mesh holds is started too, and the answer says the host restores what its declaration says at its next apply."),
act("stop", "Stop one container (ten seconds, then killed). For a container the mesh holds, the answer says the host will start it again at its next apply if its declaration says running."),
act("restart", "Restart one container (ten seconds to stop, then killed); the answer says whether the mesh holds it."),
{
Name: "docker_top",
Description: "The processes running inside one container: pid, user, elapsed time, CPU, resident memory and command.",
Input: map[string]any{"container": containerArg},
Run: func(args map[string]any) (any, error) {
ref, err := text(args, "container")
if err != nil {
return nil, err
}
return c.Top(ctx, ref)
},
},
{
Name: "docker_images",
Description: "The images on this machine, the largest first, each with its size and the containers using it (and whether one of them is the mesh's). " +
"filter: all, dangling, unused or used.",
Input: map[string]any{
"filter": map[string]any{"type": "string", "enum": []string{"all", "dangling", "unused", "used"}, "description": "which images (default all)"},
"match": map[string]any{"type": "string", "description": "only images whose repository:tag contains this"},
"limit": map[string]any{"type": "integer", "description": "how many to show (default 100, at most 1000); count says how many matched"},
},
Run: func(args map[string]any) (any, error) {
n, err := bounded(args, "limit", 100, 1000)
if err != nil {
return nil, err
}
return c.Images(ctx, optional(args, "filter"), optional(args, "match"), n)
},
},
{
Name: "docker_prune",
Description: "Reclaim space: dangling images and unused build cache, and — only when containers is true — stopped containers the mesh does not hold. " +
"Never a volume, never a container the mesh holds, never an image a container uses. A dry run by default: it lists what would go; dry_run false removes it.",
Input: map[string]any{
"dry_run": map[string]any{"type": "boolean", "description": "list only (default true)"},
"images": map[string]any{"type": "boolean", "description": "dangling images (default true)"},
"build_cache": map[string]any{"type": "boolean", "description": "build cache nothing refers to (default true)"},
"containers": map[string]any{"type": "boolean", "description": "stopped containers the mesh does not hold (default false); what they mounted is kept"},
"older_than_hours": map[string]any{"type": "integer", "description": "only what is older than this many hours (default 0: any age)"},
},
Run: func(args map[string]any) (any, error) {
older := 0
if v, ok := args["older_than_hours"]; ok && v != nil && v != float64(0) {
n, err := bounded(args, "older_than_hours", 0, 24*365)
if err != nil {
return nil, err
}
older = n
}
return c.Prune(ctx, PruneAsk{
DryRun: flag(args, "dry_run", true), Images: flag(args, "images", true), BuildCache: flag(args, "build_cache", true),
Containers: flag(args, "containers", false), OlderThanH: older,
})
},
},
{
Name: "docker_disk_usage",
Description: "What the runtime takes on disk (docker system df -v): per kind — images, containers, volumes, build cache — the total, the active and the reclaimable, and the largest of each.",
Input: map[string]any{"top": map[string]any{"type": "integer", "description": "how many of the largest per kind (default 10, at most 100)"}},
Run: func(args map[string]any) (any, error) {
n, err := bounded(args, "top", 10, 100)
if err != nil {
return nil, err
}
return c.DiskUsage(ctx, n)
},
},
{
Name: "docker_networks",
Description: "Every network the runtime has: driver, scope, subnets and gateway, and the running containers on it with their addresses and whether the mesh holds them.",
Run: func(map[string]any) (any, error) { return c.Networks(ctx) },
},
{
Name: "docker_volumes",
Description: "Every volume with the containers mounting it, whether the mesh holds any of them, whether it is anonymous, its compose project, and — when sizes is true (slower) — its size.",
Input: map[string]any{
"unmounted": map[string]any{"type": "boolean", "description": "only volumes no container mounts (default false)"},
"sizes": map[string]any{"type": "boolean", "description": "measure each volume (default false: it walks every volume)"},
},
Run: func(args map[string]any) (any, error) {
return c.Volumes(ctx, flag(args, "unmounted", false), flag(args, "sizes", false))
},
},
{
Name: "docker_events",
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked.",
Input: map[string]any{
"minutes": map[string]any{"type": "integer", "description": "how far back (default 60, at most 1440)"},
"type": map[string]any{"type": "string", "description": "only one kind: container, image, network, volume, daemon, plugin or builder"},
"limit": map[string]any{"type": "integer", "description": "the latest this many (default 200, at most 2000)"},
"execs": map[string]any{"type": "boolean", "description": "include exec_* events (default false: health checks make many)"},
},
Run: func(args map[string]any) (any, error) {
minutes, err := bounded(args, "minutes", 60, 1440)
if err != nil {
return nil, err
}
limit, err := bounded(args, "limit", 200, 2000)
if err != nil {
return nil, err
}
return c.Events(ctx, minutes, optional(args, "type"), limit, flag(args, "execs", false))
},
},
{
Name: "docker_daemon_config",
Description: "The runtime's configuration: /etc/docker/daemon.json as it is on disk, the daemon's essentials as it runs now (docker info: version, storage and logging drivers, " +
"live restore, root directory, insecure registries, warnings), and where the two differ — keys a reload or only a restart would take.",
Run: func(map[string]any) (any, error) { return c.DaemonConfig(ctx) },
},
{
Name: "docker_unlabelled",
Description: "The containers the mesh does not hold — the cleanup list — each with its image, state, compose project and directory, ports and mounts.",
Run: func(map[string]any) (any, error) { return c.Unlabelled(ctx) },
},
{
Name: "docker_problems",
Description: "Every container that is not well: unhealthy, restarting, dead, killed for memory, exited with a failure, or restarted five times or more — with whether the mesh holds it.",
Run: func(map[string]any) (any, error) {
p, err := c.Problems(ctx)
if err != nil {
return nil, err
}
return map[string]any{"count": len(p), "containers": p}, nil
},
},
{
Name: "docker_ports",
Description: "Every port the containers publish on this machine (address:port -> container port), and the containers on the host's network, which publish whatever they listen on.",
Run: func(map[string]any) (any, error) {
p, err := c.Ports(ctx)
if err != nil {
return nil, err
}
return map[string]any{"count": len(p), "ports": p}, nil
},
},
}
}
func text(args map[string]any, key string) (string, error) {
s, _ := args[key].(string)
if s = strings.TrimSpace(s); s == "" {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
func optional(args map[string]any, key string) string {
s, _ := args[key].(string)
return strings.TrimSpace(s)
}
func flag(args map[string]any, key string, def bool) bool {
if b, ok := args[key].(bool); ok {
return b
}
return def
}
// bounded is a whole number argument, defaulted when absent and held to a ceiling.
func bounded(args map[string]any, key string, def, most int) (int, error) {
v, ok := args[key]
if !ok || v == nil {
return def, nil
}
f, ok := v.(float64)
if !ok || f != math.Trunc(f) || f < 1 {
return 0, fmt.Errorf("%s must be a whole number of at least 1", key)
}
return int(math.Min(f, float64(most))), nil
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
)
// Ran is what a command did: its output, its exit status, and why it never ran to an answer.
type Ran struct {
Stdout string
Stderr string
Status int
// Err is "ENOENT" when the program is not installed, or says it was ended for taking too long.
Err string
}
// Runner runs one command, so every tool can be tested without a daemon.
type Runner func(ctx context.Context, name string, args ...string) Ran
// CallTimeout is how long one docker command may take: below the runtime's thirty-second call
// limit, so a daemon that hangs is answered as such rather than as a call the runtime gave up on.
const CallTimeout = 20 * time.Second
// ExecRunner runs a command on this machine, bounded by CallTimeout.
func ExecRunner(ctx context.Context, name string, args ...string) Ran {
ctx, cancel := context.WithTimeout(ctx, CallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
r := Ran{Stdout: out.String(), Stderr: errb.String()}
var exitErr *exec.ExitError
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout/time.Second))
case errors.Is(err, exec.ErrNotFound):
r.Status, r.Err = 127, "ENOENT"
case errors.As(err, &exitErr):
r.Status = exitErr.ExitCode()
case err != nil:
r.Status, r.Err = 1, err.Error()
}
return r
}
func firstLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
@@ -0,0 +1,60 @@
package main
import (
"encoding/json"
"os"
"reflect"
"sort"
"strings"
"testing"
)
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Tools []string `json:"tools"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
served := []string{}
for _, tool := range tools(client(&fake{}, 1000)) {
if !strings.HasPrefix(tool.Name, "docker_") || tool.Description == "" || tool.Run == nil {
t.Errorf("tool %q", tool.Name)
}
served = append(served, tool.Name)
}
sort.Strings(served)
listed := append([]string{}, m.Tools...)
sort.Strings(listed)
if !reflect.DeepEqual(served, listed) {
t.Fatalf("served %v, manifest %v", served, listed)
}
}
func TestNumbersAreDefaultedAndBounded(t *testing.T) {
if n, _ := bounded(map[string]any{}, "lines", 200, 2000); n != 200 {
t.Error(n)
}
if n, _ := bounded(map[string]any{"lines": float64(99999)}, "lines", 200, 2000); n != 2000 {
t.Error(n)
}
for _, bad := range []any{float64(0), float64(-1), float64(1.5), "10"} {
if _, err := bounded(map[string]any{"lines": bad}, "lines", 200, 2000); err == nil {
t.Errorf("%v accepted", bad)
}
}
}
func TestAStopFromTheToolNeedsAContainer(t *testing.T) {
for _, tool := range tools(client(&fake{}, 1000)) {
if tool.Name == "docker_stop" {
if _, err := tool.Run(map[string]any{}); err == nil {
t.Fatal("a stop without a container was accepted")
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
module docker
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+94
View File
@@ -0,0 +1,94 @@
{
"module": "docker",
"version": "1",
"capabilities": [
"package-manager",
"service-manager",
"privileged"
],
"claims": [
{
"name": "node-container-runtime",
"scope": "node"
}
],
"tools": [
"docker_list",
"docker_inspect",
"docker_logs",
"docker_stats",
"docker_start",
"docker_stop",
"docker_restart",
"docker_top",
"docker_images",
"docker_prune",
"docker_disk_usage",
"docker_networks",
"docker_volumes",
"docker_events",
"docker_daemon_config",
"docker_unlabelled",
"docker_problems",
"docker_ports"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "docker"
},
{
"id": "buildx",
"type": "package",
"package": "docker-buildx"
},
{
"id": "socket",
"type": "service",
"unit": "docker.socket",
"state": "running",
"boot": "enabled"
},
{
"id": "prune-service",
"type": "file",
"path": "/etc/systemd/system/docker-prune.service",
"mode": "0644",
"content": "# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push.\n[Unit]\nDescription=Prune dangling images and unused build cache (the mesh's docker module)\n# Never volumes, never a container, never an image a container uses: dangling\n# images and build cache nothing refers to, unused for a week. What a person\n# prunes beyond that is docker_prune's, by hand.\nAfter=docker.service\nConditionPathExists=/run/docker.sock\n\n[Service]\nType=oneshot\nNice=19\nIOSchedulingClass=idle\nExecStart=/usr/bin/docker image prune --force --filter until=168h\nExecStart=/usr/bin/docker builder prune --force --filter until=168h\n"
},
{
"id": "prune-timer",
"type": "file",
"path": "/etc/systemd/system/docker-prune.timer",
"mode": "0644",
"content": "# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push.\n[Unit]\nDescription=Weekly prune of dangling images and unused build cache (the mesh's docker module)\n\n[Timer]\nOnCalendar=weekly\nRandomizedDelaySec=1h\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n"
},
{
"id": "prune",
"type": "service",
"unit": "docker-prune.timer",
"state": "running",
"boot": "enabled",
"restart-on": [
"prune-service",
"prune-timer"
]
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/docker-tools",
"binary": "docker-tools",
"loads": [
"docker-tools"
]
}
]
}
}
+109
View File
@@ -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-<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`.
@@ -0,0 +1,844 @@
package main
// The operator account's ~/.ssh, asked and — for one authorized key and one known host — changed.
// The node's tool runtime runs as that account (novox/hq ADR 0175 §4), so nothing here escalates:
// every file it touches is the account's own. Private keys are never read here (keys.go).
import (
"bufio"
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
)
// Client answers about one home's ~/.ssh.
type Client struct {
Home string
UID int
Run Runner
Now func() time.Time
}
// NewClient is the client the bundle serves with: the operator's home as the runtime names it.
func NewClient() *Client {
home := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME"))
if home == "" {
home, _ = os.UserHomeDir()
}
return &Client{Home: home, UID: os.Getuid(), Run: ExecRunner, Now: time.Now}
}
func (c *Client) ssh(name string) string { return filepath.Join(c.Home, ".ssh", name) }
var hostPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.:-]*$`)
// HostArg is a host's name as an argument: never something ssh would read as an option.
func HostArg(s string) (string, error) {
s = strings.TrimSpace(s)
if !hostPattern.MatchString(s) || len(s) > 253 {
return "", fmt.Errorf("%q is not a host name", s)
}
return s, nil
}
// ---- hosts -----------------------------------------------------------------------------------
// Hosts is every Host and Match section with where it came from, in the order ssh reads them.
func (c *Client) Hosts() (map[string]any, error) {
p, err := Parse(c.Home)
if err != nil {
return nil, err
}
return map[string]any{"sections": p.Sections, "global": p.Global, "includes": p.Includes, "files": p.Files,
"duplicates": p.Duplicates(), "problems": p.Problems,
"note": "ssh takes the first value it finds for each option: an earlier section wins over a later one for the same host"}, nil
}
// Resolve is what ssh would use for one host, as `ssh -G` computes it.
func (c *Client) Resolve(ctx context.Context, host string) (map[string]any, error) {
host, err := HostArg(host)
if err != nil {
return nil, err
}
r := c.Run(ctx, nil, "ssh", "-G", host)
if r.Status != 0 || r.Err != "" {
return nil, named("ssh -G", r)
}
keep := map[string]bool{"hostname": true, "user": true, "port": true, "identityfile": true, "proxyjump": true,
"proxycommand": true, "identitiesonly": true, "stricthostkeychecking": true, "userknownhostsfile": true,
"forwardagent": true, "controlmaster": true, "controlpath": true, "addkeystoagent": true, "identityagent": true}
out := map[string]any{"host": host}
for _, l := range strings.Split(r.Stdout, "\n") {
k, v, _ := strings.Cut(strings.TrimSpace(l), " ")
if keep[k] {
if prev, ok := out[k]; ok {
out[k] = fmt.Sprint(prev) + ", " + v
} else {
out[k] = v
}
}
}
if p, err := Parse(c.Home); err == nil {
matched := []string{}
for _, s := range p.Sections {
if s.Kind == "host" && matchesAny(host, s.Patterns) {
matched = append(matched, fmt.Sprintf("%s:%d (%s)", s.Source, s.Line, s.From))
}
}
out["sections_matching"] = matched
}
return out, nil
}
// matchesAny is ssh's Host matching: globs with * and ?, a leading ! negates.
func matchesAny(host string, patterns []string) bool {
hit := false
for _, p := range patterns {
neg := strings.HasPrefix(p, "!")
ok, _ := filepath.Match(strings.TrimPrefix(p, "!"), host)
if ok && neg {
return false
}
hit = hit || ok
}
return hit
}
func named(what string, r Ran) error {
switch {
case r.Err == "ENOENT":
return fmt.Errorf("%s: the program is not installed on this machine", what)
case r.Err != "":
return fmt.Errorf("%s did not answer: %s", what, r.Err)
}
if l := firstLine(r.Stderr + "\n" + r.Stdout); l != "" {
return fmt.Errorf("%s failed (%d): %s", what, r.Status, l)
}
return fmt.Errorf("%s failed with status %d", what, r.Status)
}
// ---- keys --------------------------------------------------------------------------------------
// Key is one private key under ~/.ssh, described by its public half.
type Key struct {
Path string `json:"path"`
Type string `json:"type"`
Bits int `json:"bits"`
Fingerprint string `json:"fingerprint"`
Comment string `json:"comment"`
Mode string `json:"mode"`
AgeDays int `json:"age_days"`
Passphrase string `json:"passphrase"` // "yes", "none" or why it could not be told
OfferedBy string `json:"offered_by"` // "default name", "IdentityFile at …", or ""
Weak string `json:"weak,omitempty"`
PublicMissing bool `json:"public_half_missing,omitempty"`
// PublicMismatch: the .pub beside the key is another key's — ssh offers the private key, and
// whoever installs the .pub into an authorized_keys installs the wrong one.
PublicMismatch string `json:"public_half_mismatch,omitempty"`
}
var notKeys = regexp.MustCompile(`^(config|known_hosts|authorized_keys|environment|rc)(\..*|-.*)?$|\.pub$`)
var defaultKeys = map[string]bool{"id_rsa": true, "id_ecdsa": true, "id_ecdsa_sk": true, "id_ed25519": true, "id_ed25519_sk": true, "id_dsa": true}
// privateKeys are the files directly under ~/.ssh whose first line is a private key's PEM header.
func (c *Client) privateKeys() ([]string, error) {
entries, err := os.ReadDir(c.ssh(""))
if err != nil {
return nil, fmt.Errorf("cannot read %s: %v", c.ssh(""), err)
}
out := []string{}
for _, e := range entries {
if !e.Type().IsRegular() || notKeys.MatchString(e.Name()) {
continue
}
if header(c.ssh(e.Name())) {
out = append(out, c.ssh(e.Name()))
}
}
return out, nil
}
// header reads a file's first line only — never more of a key — and says whether it is a private
// key's.
func header(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
line, _ := bufio.NewReader(f).ReadString('\n')
return strings.HasPrefix(line, "-----BEGIN") && strings.Contains(line, "PRIVATE KEY-----")
}
// Keys is every private key under ~/.ssh with its type, size, fingerprint, age, whether it has a
// passphrase, and whether ssh offers it by itself.
func (c *Client) Keys(ctx context.Context) ([]Key, error) {
paths, err := c.privateKeys()
if err != nil {
return nil, err
}
identity := map[string]string{}
if p, err := Parse(c.Home); err == nil {
for _, s := range p.Sections {
if f := s.Options["identityfile"]; f != "" {
f = strings.Replace(f, "~", c.Home, 1)
if !filepath.IsAbs(f) {
f = c.ssh(f)
}
identity[f] = fmt.Sprintf("IdentityFile at %s:%d", s.Source, s.Line)
}
}
}
keys := []Key{}
for _, path := range paths {
k := Key{Path: path}
if info, err := os.Stat(path); err == nil {
k.Mode = fmt.Sprintf("%04o", info.Mode().Perm())
k.AgeDays = int(c.Now().Sub(info.ModTime()).Hours() / 24)
}
if pub, err := os.ReadFile(path + ".pub"); err == nil {
if pk, err := ParseKeyLine(string(pub)); err == nil {
k.Type, k.Bits, k.Fingerprint, k.Comment, k.Weak = pk.Type, pk.Bits, pk.Fingerprint, pk.Comment, pk.Weak
}
} else {
k.PublicMissing = true
// ssh-keygen reads the public half an OpenSSH private key carries unencrypted.
r := c.Run(ctx, nil, "ssh-keygen", "-l", "-f", path)
if r.Status == 0 {
f := strings.Fields(r.Stdout)
if len(f) >= 2 {
k.Fingerprint = f[1]
k.Type = strings.Trim(f[len(f)-1], "()")
}
}
}
var derived string
k.Passphrase, derived = c.passphrase(ctx, path)
if derived != "" {
if pk, err := ParseKeyLine(derived); err == nil {
if k.Fingerprint != "" && !k.PublicMissing && pk.Fingerprint != k.Fingerprint {
k.PublicMismatch = fmt.Sprintf("the key is %s; its .pub is %s", pk.Fingerprint, k.Fingerprint)
}
if k.Fingerprint == "" || k.PublicMismatch != "" {
k.Type, k.Bits, k.Fingerprint, k.Weak = pk.Type, pk.Bits, pk.Fingerprint, pk.Weak
}
}
}
switch {
case identity[path] != "":
k.OfferedBy = identity[path]
case defaultKeys[filepath.Base(path)]:
k.OfferedBy = "default name: ssh offers it to every host"
}
keys = append(keys, k)
}
return keys, nil
}
// passphrase asks ssh-keygen to derive the public key with an empty passphrase: it succeeds only on
// a key with none. What it prints is the public key — public, and used only to compare with the .pub.
func (c *Client) passphrase(ctx context.Context, path string) (string, string) {
r := c.Run(ctx, nil, "ssh-keygen", "-y", "-P", "", "-f", path)
switch {
case r.Status == 0 && r.Err == "":
return "none", strings.TrimSpace(r.Stdout)
case strings.Contains(r.Stderr, "incorrect passphrase") || strings.Contains(r.Stderr, "passphrase"):
return "yes", ""
case r.Err == "ENOENT":
return "unknown: ssh-keygen is not installed", ""
}
return "unknown: " + firstLine(r.Stderr), ""
}
// ---- authorized_keys -----------------------------------------------------------------------------
// AuthorizedKey is one line of authorized_keys, by fingerprint.
type AuthorizedKey struct {
PublicKey
Line int `json:"line"`
InMesh bool `json:"in_mesh_region,omitempty"`
}
// Authorized is who may log in as this account by key: each line's fingerprint, type, size,
// comment and options — never the key itself.
func (c *Client) Authorized() (map[string]any, error) {
keys, bad, err := c.readAuthorized()
if err != nil {
return nil, err
}
seen := map[string]int{}
dups := []string{}
for _, k := range keys {
seen[k.Fingerprint]++
if seen[k.Fingerprint] == 2 {
dups = append(dups, k.Fingerprint)
}
}
return map[string]any{"file": c.ssh("authorized_keys"), "count": len(keys), "keys": keys, "duplicates": dups, "unreadable_lines": bad}, nil
}
func (c *Client) readAuthorized() ([]AuthorizedKey, []int, error) {
raw, err := os.ReadFile(c.ssh("authorized_keys"))
if err != nil {
if os.IsNotExist(err) {
return []AuthorizedKey{}, []int{}, nil
}
return nil, nil, err
}
keys, bad := []AuthorizedKey{}, []int{}
inMesh := false
for n, line := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(line)
switch {
case strings.HasPrefix(t, "# BEGIN mesh "):
inMesh = true
continue
case strings.HasPrefix(t, "# END mesh "):
inMesh = false
continue
case t == "" || strings.HasPrefix(t, "#"):
continue
}
k, err := ParseKeyLine(t)
if err != nil {
bad = append(bad, n+1)
continue
}
keys = append(keys, AuthorizedKey{PublicKey: k, Line: n + 1, InMesh: inMesh})
}
return keys, bad, nil
}
// Revoke takes every line carrying one key out of authorized_keys, after keeping the file as it
// was beside it. It refuses a key the mesh's region carries (the next push would put it back), a
// fingerprint it does not find, and taking the last key (that would lock the account out of ssh).
func (c *Client) Revoke(fingerprint string) (map[string]any, error) {
fingerprint = strings.TrimSpace(fingerprint)
if !strings.HasPrefix(fingerprint, "SHA256:") {
fingerprint = "SHA256:" + fingerprint
}
path := c.ssh("authorized_keys")
keys, _, err := c.readAuthorized()
if err != nil {
return nil, err
}
drop := map[int]bool{}
var removed []AuthorizedKey
for _, k := range keys {
if k.Fingerprint == fingerprint {
if k.InMesh {
return nil, fmt.Errorf("%s is in the mesh's region of authorized_keys (line %d): the next push writes it back; take it out of the mesh's record instead", fingerprint, k.Line)
}
drop[k.Line] = true
removed = append(removed, k)
}
}
if len(removed) == 0 {
return nil, fmt.Errorf("no key in %s has the fingerprint %s (ssh_client_authorized lists them)", path, fingerprint)
}
if len(removed) == len(keys) {
return nil, fmt.Errorf("%s is the only key that may log in as this account: revoking it would lock ssh out", fingerprint)
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
backup := fmt.Sprintf("%s.revoked-%s", path, c.Now().UTC().Format("20060102T150405Z"))
if err := os.WriteFile(backup, raw, 0o600); err != nil {
return nil, fmt.Errorf("could not keep the file before changing it, so it was left as it is: %v", err)
}
lines := strings.Split(string(raw), "\n")
kept := make([]string, 0, len(lines))
for n, l := range lines {
if !drop[n+1] {
kept = append(kept, l)
}
}
if err := atomicWrite(path, []byte(strings.Join(kept, "\n")), info.Mode().Perm()); err != nil {
return nil, err
}
return map[string]any{"revoked": removed, "file": path, "backup": backup, "remaining": len(keys) - len(removed)}, nil
}
func atomicWrite(path string, data []byte, mode fs.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
// ---- known_hosts -------------------------------------------------------------------------------
// knownFor is what known_hosts holds for one host (port 22, or [host]:port), by fingerprint.
func (c *Client) knownFor(ctx context.Context, host string, port int) ([]PublicKey, error) {
name := host
if port != 22 {
name = fmt.Sprintf("[%s]:%d", host, port)
}
file := c.ssh("known_hosts")
if _, err := os.Stat(file); os.IsNotExist(err) {
return []PublicKey{}, nil
}
r := c.Run(ctx, nil, "ssh-keygen", "-F", name, "-f", file)
if r.Err != "" {
return nil, named("ssh-keygen -F", r)
}
// Exit 1 with nothing printed is "not found".
keys := []PublicKey{}
for _, l := range strings.Split(r.Stdout, "\n") {
if l = strings.TrimSpace(l); l == "" || strings.HasPrefix(l, "#") {
continue
}
if k, err := ParseKeyLine(l); err == nil {
k.Comment, k.Options = "", ""
keys = append(keys, k)
}
}
return keys, nil
}
// scan asks a host for its keys now.
func (c *Client) scan(ctx context.Context, host string, port int) ([]PublicKey, []string, error) {
r := c.Run(ctx, nil, "ssh-keyscan", "-T", "5", "-p", fmt.Sprint(port), host)
if r.Err != "" {
return nil, nil, named("ssh-keyscan", r)
}
keys, lines := []PublicKey{}, []string{}
for _, l := range strings.Split(r.Stdout, "\n") {
if l = strings.TrimSpace(l); l == "" || strings.HasPrefix(l, "#") {
continue
}
if k, err := ParseKeyLine(l); err == nil {
k.Comment, k.Options = "", ""
keys = append(keys, k)
lines = append(lines, l)
}
}
if len(keys) == 0 {
return nil, nil, fmt.Errorf("%s:%d gave no host key: %s", host, port, orElse(firstLine(r.Stderr), "nothing answered within 5 s"))
}
return keys, lines, nil
}
func orElse(s, def string) string {
if s == "" {
return def
}
return s
}
// compare says how what is known stands against what the host offers now.
func compare(known, live []PublicKey) string {
if len(known) == 0 {
return "not known: the first connection would ask"
}
byType := map[string]string{}
for _, k := range live {
byType[k.Type] = k.Fingerprint
}
matched := false
for _, k := range known {
fp, offered := byType[k.Type]
if offered && fp != k.Fingerprint {
return "changed: the host offers a different key than known_hosts holds — ssh refuses it until the entry is refreshed"
}
matched = matched || offered
}
if !matched {
return "no common type: known_hosts holds a key of a type the host no longer offers"
}
return "matches"
}
// KnownHost is what known_hosts holds for a host and what the host offers now; with refresh, the
// host's entries are replaced by what it offers (ssh-keygen keeps known_hosts.old). A refresh
// trusts whatever answers now, so it is for a host whose key is known to have changed.
func (c *Client) KnownHost(ctx context.Context, host string, port int, refresh bool) (map[string]any, error) {
host, err := HostArg(host)
if err != nil {
return nil, err
}
if port < 1 || port > 65535 {
return nil, fmt.Errorf("port %d is not a TCP port", port)
}
known, err := c.knownFor(ctx, host, port)
if err != nil {
return nil, err
}
answer := map[string]any{"host": host, "port": port, "known": known}
live, lines, scanErr := c.scan(ctx, host, port)
if scanErr != nil {
answer["offered"] = nil
answer["state"] = "unreachable: " + scanErr.Error()
} else {
answer["offered"] = live
answer["state"] = compare(known, live)
}
if !refresh {
return answer, nil
}
if scanErr != nil {
return nil, fmt.Errorf("not refreshed: %v", scanErr)
}
name := host
if port != 22 {
name = fmt.Sprintf("[%s]:%d", host, port)
}
file := c.ssh("known_hosts")
if len(known) > 0 {
if r := c.Run(ctx, nil, "ssh-keygen", "-R", name, "-f", file); r.Status != 0 || r.Err != "" {
return nil, named("ssh-keygen -R", r)
}
answer["backup"] = file + ".old"
}
f, err := os.OpenFile(file, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, err
}
defer f.Close()
if _, err := f.WriteString(strings.Join(lines, "\n") + "\n"); err != nil {
return nil, err
}
answer["refreshed"] = true
answer["known"] = live
answer["state"] = "matches"
return answer, nil
}
// ---- test ----------------------------------------------------------------------------------------
// agentSockets are where an agent of the account listens when the runtime's environment names
// none: the keyring's, then a user unit's.
func (c *Client) agentSockets() []string {
run := fmt.Sprintf("/run/user/%d", c.UID)
return []string{run + "/gcr/ssh", run + "/keyring/ssh", run + "/ssh-agent.socket", run + "/openssh_agent"}
}
// Test connects to a host in batch mode — no prompt, nothing run but `true` — and says how it
// authenticated or why it could not.
func (c *Client) Test(ctx context.Context, host string) (map[string]any, error) {
host, err := HostArg(host)
if err != nil {
return nil, err
}
var env []string
agent := os.Getenv("SSH_AUTH_SOCK")
if agent == "" {
for _, s := range c.agentSockets() {
if info, err := os.Stat(s); err == nil && info.Mode()&fs.ModeSocket != 0 {
agent = s
env = []string{"SSH_AUTH_SOCK=" + s}
break
}
}
}
start := c.Now()
r := c.Run(ctx, env, "ssh", "-v", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=yes", host, "true")
answer := map[string]any{"host": host, "elapsed_ms": c.Now().Sub(start).Milliseconds()}
if agent != "" {
answer["agent"] = agent
} else {
answer["agent"] = "none: a key with a passphrase cannot be used from here"
}
if r.Err != "" {
if r.Err == "ENOENT" {
return nil, fmt.Errorf("ssh is not installed on this machine")
}
answer["ok"], answer["why"] = false, r.Err
return answer, nil
}
log := r.Stderr
if m := regexp.MustCompile(`Authenticated to (\S+) \(([^)]*)\) using "([^"]+)"`).FindStringSubmatch(log); m != nil {
answer["ok"], answer["authenticated_to"], answer["address"], answer["method"] = r.Status == 0, m[1], m[2], m[3]
} else {
answer["ok"] = false
}
if m := regexp.MustCompile(`Server accepts key: (\S+) (\S+) (SHA256:\S+)`).FindStringSubmatch(log); m != nil {
answer["key"] = map[string]string{"file": m[1], "type": m[2], "fingerprint": m[3]}
}
if m := regexp.MustCompile(`Connecting to \S+ \[([^\]]+)\] port (\d+)`).FindStringSubmatch(log); m != nil {
answer["connected_to"] = m[1] + ":" + m[2]
}
if answer["ok"] == true {
return answer, nil
}
reasons := []struct{ pattern, why string }{
{"Could not resolve hostname", "the name does not resolve"},
{"Connection refused", "nothing listens for ssh there"},
{"Connection timed out", "no answer within 8 s"},
{"No route to host", "no route to the host"},
{"Host key verification failed", "the host's key is not the one known_hosts holds, or it is not known (ssh_client_known_host)"},
{"REMOTE HOST IDENTIFICATION HAS CHANGED", "the host's key changed (ssh_client_known_host compares and refreshes)"},
{"No ED25519 host key is known", "the host is not in known_hosts (ssh_client_known_host refresh adds it)"},
{"Permission denied", "no key it offered was accepted"},
}
for _, rr := range reasons {
if strings.Contains(log, rr.pattern) {
answer["why"] = rr.why
break
}
}
if _, ok := answer["why"]; !ok {
answer["why"] = orElse(lastMeaningful(log), fmt.Sprintf("ssh exited %d", r.Status))
}
offered := []string{}
for _, m := range regexp.MustCompile(`Offering public key: (\S+)`).FindAllStringSubmatch(log, -1) {
offered = append(offered, m[1])
}
answer["offered"] = offered
if regexp.MustCompile(`(?i)read_passphrase|passphrase`).MatchString(log) || agent == "" {
answer["note"] = "a key protected by a passphrase is offered only through an agent; this ran with " + fmt.Sprint(answer["agent"])
}
return answer, nil
}
func lastMeaningful(log string) string {
ls := strings.Split(strings.TrimSpace(log), "\n")
for i := len(ls) - 1; i >= 0; i-- {
if l := strings.TrimSpace(ls[i]); l != "" && !strings.HasPrefix(l, "debug1:") {
return l
}
}
return ""
}
// ---- check ---------------------------------------------------------------------------------------
// Finding is one thing check found wrong, or worth a look.
type Finding struct {
Severity string `json:"severity"` // "problem" or "note"
Path string `json:"path,omitempty"`
What string `json:"what"`
}
// Check is everything about ~/.ssh worth a person's attention: modes, keys without a passphrase or
// weak or old, the mesh's include, duplicate hosts, stale known_hosts entries for the mesh's
// machines, authorized keys, and debris. It also says what it did not check.
func (c *Client) Check(ctx context.Context, scan bool) (map[string]any, error) {
dir := c.ssh("")
info, err := os.Stat(dir)
if err != nil {
return nil, fmt.Errorf("there is no %s: %v", dir, err)
}
f := []Finding{}
add := func(sev, path, what string, args ...any) {
f = append(f, Finding{Severity: sev, Path: path, What: fmt.Sprintf(what, args...)})
}
if info.Mode().Perm() != 0o700 {
add("problem", dir, "mode %04o, not 0700", info.Mode().Perm())
}
if st, err := os.Stat(c.ssh("config.d")); err != nil {
add("problem", c.ssh("config.d"), "absent: the mesh's own hosts and other modules' drop-ins live there")
} else if st.Mode().Perm() != 0o700 {
add("problem", c.ssh("config.d"), "mode %04o, not 0700", st.Mode().Perm())
}
// Modes, file by file.
entries, _ := os.ReadDir(dir)
keys, _ := c.privateKeys()
isKey := map[string]bool{}
for _, k := range keys {
isKey[k] = true
}
debris := []string{}
for _, e := range entries {
p := c.ssh(e.Name())
st, err := os.Lstat(p)
if err != nil {
continue
}
mode := st.Mode().Perm()
switch {
case st.Mode()&fs.ModeSymlink != 0:
add("note", p, "a symbolic link: ssh follows it, and what it points at is not under ~/.ssh's modes")
case st.IsDir():
if mode&0o022 != 0 {
add("problem", p, "a directory writable by others (%04o)", mode)
}
case isKey[p] && mode&0o077 != 0:
add("problem", p, "a private key readable by others (%04o): ssh refuses to use it", mode)
case e.Name() == "authorized_keys" && mode&0o077 != 0:
add("note", p, "mode %04o: 0600 is enough, and sshd refuses it once group- or world-writable", mode)
case mode&0o022 != 0:
add("problem", p, "writable by others (%04o): ssh refuses a configuration others can write", mode)
}
if regexp.MustCompile(`\.(bak|old|orig)\b|\.bak-|^removed-|\.revoked-`).MatchString(e.Name()) {
debris = append(debris, e.Name())
}
}
// Keys.
keyList, err := c.Keys(ctx)
if err != nil {
return nil, err
}
byFingerprint := map[string][]string{}
for _, k := range keyList {
if k.Fingerprint != "" {
byFingerprint[k.Fingerprint] = append(byFingerprint[k.Fingerprint], k.Path)
}
}
for fp, paths := range byFingerprint {
if len(paths) > 1 {
sort.Strings(paths)
add("note", "", "one key under %d names (%s): %s — revoking one revokes all", len(paths), fp, strings.Join(paths, ", "))
}
}
for _, k := range keyList {
if k.Passphrase == "none" {
add("problem", k.Path, "a private key with no passphrase: whoever reads the file can use it")
}
if k.Weak != "" {
add("problem", k.Path, "%s", k.Weak)
}
if k.AgeDays > 3*365 {
add("note", k.Path, "%d days old", k.AgeDays)
}
if k.OfferedBy == "" {
add("note", k.Path, "no IdentityFile names it and its name is not a default: ssh offers it only from an agent")
}
if k.PublicMissing {
add("note", k.Path, "its public half (.pub) is missing")
}
if k.PublicMismatch != "" {
add("problem", k.Path+".pub", "is not this key's public half: %s", k.PublicMismatch)
}
}
// The configuration.
p, perr := Parse(c.Home)
if perr != nil {
add("problem", c.ssh("config"), "%v", perr)
} else {
if !c.meshIncludeFirst() {
add("problem", c.ssh("config"), "the mesh's region is not the first thing in the file, or brings in no config.d: hosts above it win over the mesh's")
}
if _, err := os.Stat(c.ssh(MeshFile)); err != nil {
add("problem", c.ssh(MeshFile), "absent: the mesh's own hosts are not here")
}
for _, d := range p.Duplicates() {
add("problem", "", "Host %v is defined %d times; the first wins: %v", d["host"], len(d["defined_at"].([]string)), d["defined_at"])
}
for _, prob := range p.Problems {
add("problem", "", "%s", prob)
}
}
// authorized_keys.
auth, _, aerr := c.readAuthorized()
if aerr != nil {
add("problem", c.ssh("authorized_keys"), "%v", aerr)
}
for _, k := range auth {
if k.Weak != "" {
add("problem", c.ssh("authorized_keys"), "line %d (%s): %s", k.Line, k.Fingerprint, k.Weak)
}
if k.Comment == "" {
add("note", c.ssh("authorized_keys"), "line %d (%s) has no comment: nothing says whose it is", k.Line, k.Fingerprint)
}
}
// known_hosts for the mesh's machines.
stale := []map[string]any{}
notChecked := []string{"what any private key is used for elsewhere", "authorized_keys against the mesh's record: the mesh has no record of the operator's keys yet"}
if perr == nil {
hosts := p.MeshHosts()
if !scan {
notChecked = append(notChecked, "known_hosts against what the mesh's machines offer now (scan was false)")
}
var mu sync.Mutex
var wg sync.WaitGroup
for _, h := range hosts {
wg.Add(1)
go func(h map[string]string) {
defer wg.Done()
known, err := c.knownFor(ctx, h["hostname"], 22)
state := ""
switch {
case err != nil:
state = "unread: " + err.Error()
case !scan && len(known) == 0:
state = "not known: the first connection would ask"
case !scan:
return
default:
live, _, serr := c.scan(ctx, h["hostname"], 22)
if serr != nil {
state = "unreachable: " + serr.Error()
} else if s := compare(known, live); s != "matches" {
state = s
} else {
return
}
}
mu.Lock()
stale = append(stale, map[string]any{"host": h["host"], "hostname": h["hostname"], "state": state})
mu.Unlock()
}(h)
}
wg.Wait()
sort.Slice(stale, func(a, b int) bool { return fmt.Sprint(stale[a]["host"]) < fmt.Sprint(stale[b]["host"]) })
for _, s := range stale {
add("problem", c.ssh("known_hosts"), "%s (%s): %s", s["host"], s["hostname"], s["state"])
}
}
if len(debris) > 0 {
add("note", dir, "backups and retired copies lie beside the live files: %s", strings.Join(debris, ", "))
}
problems := 0
for _, x := range f {
if x.Severity == "problem" {
problems++
}
}
return map[string]any{"ok": problems == 0, "problems": problems, "findings": f, "keys": keyList, "debris": debris, "not_checked": notChecked}, nil
}
// meshIncludeFirst is whether ~/.ssh/config begins with the mesh's region and it includes config.d.
func (c *Client) meshIncludeFirst() bool {
raw, err := os.ReadFile(c.ssh("config"))
if err != nil {
return false
}
inRegion, sawInclude := false, false
for _, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
switch {
case t == "":
continue
case strings.HasPrefix(t, "# BEGIN mesh ssh-client."):
inRegion = true
case strings.HasPrefix(t, "# END mesh "):
return inRegion && sawInclude
case !inRegion:
return false
case strings.HasPrefix(strings.ToLower(t), "include ") && strings.Contains(t, "config.d/"):
sawInclude = true
}
}
return false
}
@@ -0,0 +1,246 @@
package main
// ~/.ssh/config as ssh reads it: first match wins, Include brings files in where it stands, and a
// Host or Match line opens a section that runs to the next. Every Host is answered with where it
// came from (novox/hq research 027/03):
//
// - "mesh": the file this module writes, ~/.ssh/config.d/00-mesh (a Host per machine of the mesh),
// or a region between the mesh's markers in ~/.ssh/config;
// - "drop-in": another file in ~/.ssh/config.d — a module's (it begins with the mesh's header) or
// one found there;
// - "operator": a line of ~/.ssh/config outside the mesh's region, or a file it includes.
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// MeshFile is the file this module writes with the mesh's Host blocks, under ~/.ssh.
const MeshFile = "config.d/00-mesh"
// MeshHeader is the first line of every file the mesh writes whole.
const MeshHeader = "# Generated by the mesh."
// Section is one Host or Match section.
type Section struct {
Kind string `json:"kind"` // "host" or "match"
Patterns []string `json:"patterns"`
Source string `json:"source"` // the file, relative to ~/.ssh where it is under it
Line int `json:"line"`
From string `json:"from"` // mesh, drop-in or operator
Mesh bool `json:"mesh_written"`
Order int `json:"order"` // the order ssh reads it in: an earlier one wins
Options map[string]string `json:"options"`
}
// Parsed is a whole configuration, read as ssh reads it.
type Parsed struct {
Sections []Section `json:"sections"`
// Global is what is set outside any section, in reading order, with where.
Global []string `json:"global"`
Includes []string `json:"includes"`
// Files are every file read, in order.
Files []string `json:"files"`
Problems []string `json:"problems"`
}
// Parse reads ~/.ssh/config and what it includes.
func Parse(home string) (*Parsed, error) {
p := &Parsed{Sections: []Section{}, Global: []string{}, Includes: []string{}, Files: []string{}, Problems: []string{}}
main := filepath.Join(home, ".ssh", "config")
if _, err := os.Stat(main); err != nil {
return nil, fmt.Errorf("there is no %s: %v", main, err)
}
r := &reader{home: home, out: p}
r.file(main, 0, false)
return p, nil
}
type reader struct {
home string
out *Parsed
current *Section
}
func (r *reader) rel(path string) string {
if rel, err := filepath.Rel(filepath.Join(r.home, ".ssh"), path); err == nil && !strings.HasPrefix(rel, "..") {
return rel
}
return path
}
// from is who a line in a file belongs to.
func (r *reader) from(path string, inMeshRegion, meshWritten bool) string {
rel := r.rel(path)
switch {
case rel == MeshFile || inMeshRegion:
return "mesh"
case strings.HasPrefix(rel, "config.d/"):
return "drop-in"
case meshWritten:
return "mesh"
}
return "operator"
}
func (r *reader) file(path string, depth int, fromMesh bool) {
if depth > 16 {
r.out.Problems = append(r.out.Problems, fmt.Sprintf("%s: included more than 16 deep — ssh refuses that", r.rel(path)))
return
}
raw, err := os.ReadFile(path)
if err != nil {
r.out.Problems = append(r.out.Problems, fmt.Sprintf("%s: %v", r.rel(path), err))
return
}
r.out.Files = append(r.out.Files, r.rel(path))
text := string(raw)
meshWritten := strings.HasPrefix(text, MeshHeader)
inRegion := false
// ssh keeps the including file's section across an Include (readconf.c restores it), and an
// included file starts outside any section.
outer := r.current
r.current = nil
for n, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "# BEGIN mesh ") {
inRegion = true
continue
}
if strings.HasPrefix(trimmed, "# END mesh ") {
inRegion = false
continue
}
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
key, args := split(trimmed)
from := r.from(path, inRegion || fromMesh, meshWritten)
switch strings.ToLower(key) {
case "host", "match":
kind := strings.ToLower(key)
r.out.Sections = append(r.out.Sections, Section{Kind: kind, Patterns: args, Source: r.rel(path), Line: n + 1,
From: from, Mesh: meshWritten || from == "mesh", Order: len(r.out.Sections), Options: map[string]string{}})
r.current = &r.out.Sections[len(r.out.Sections)-1]
case "include":
for _, arg := range args {
target := arg
if strings.HasPrefix(target, "~/") {
target = filepath.Join(r.home, target[2:])
} else if !filepath.IsAbs(target) {
target = filepath.Join(r.home, ".ssh", target)
}
r.out.Includes = append(r.out.Includes, fmt.Sprintf("%s:%d %s", r.rel(path), n+1, arg))
matches, _ := filepath.Glob(target)
sort.Strings(matches)
for _, m := range matches {
if info, err := os.Stat(m); err == nil && info.Mode().IsRegular() {
idx := -1
if r.current != nil {
idx = r.current.Order
}
r.file(m, depth+1, from == "mesh" && !strings.HasPrefix(r.rel(m), "config.d/"))
if idx >= 0 {
r.current = &r.out.Sections[idx]
} else {
r.current = nil
}
}
}
}
default:
if r.current == nil {
r.out.Global = append(r.out.Global, fmt.Sprintf("%s:%d %s", r.rel(path), n+1, trimmed))
} else if _, set := r.current.Options[strings.ToLower(key)]; !set {
r.current.Options[strings.ToLower(key)] = strings.Join(args, " ")
}
}
}
if outer != nil {
r.current = &r.out.Sections[outer.Order]
} else {
r.current = nil
}
}
// split is one configuration line's keyword and arguments: whitespace or one '=' between, and
// double quotes keep spaces in an argument.
func split(line string) (string, []string) {
var words []string
var cur strings.Builder
quoted, have := false, false
for _, ch := range line {
switch {
case ch == '"':
quoted, have = !quoted, true
case !quoted && (ch == ' ' || ch == '\t' || (ch == '=' && len(words) == 0)):
if have {
words = append(words, cur.String())
cur.Reset()
have = false
}
default:
cur.WriteRune(ch)
have = true
}
}
if have {
words = append(words, cur.String())
}
if len(words) == 0 {
return "", nil
}
return words[0], words[1:]
}
// Duplicates are Host patterns defined in more than one section: the first wins for every option
// it sets, which is the trap a predecessor's block above the mesh's fell into.
func (p *Parsed) Duplicates() []map[string]any {
seen := map[string][]Section{}
for _, s := range p.Sections {
if s.Kind != "host" {
continue
}
for _, pat := range s.Patterns {
if pat == "*" {
continue
}
seen[pat] = append(seen[pat], s)
}
}
out := []map[string]any{}
keys := make([]string, 0, len(seen))
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if len(seen[k]) < 2 {
continue
}
where := []string{}
for _, s := range seen[k] {
where = append(where, fmt.Sprintf("%s:%d (%s)", s.Source, s.Line, s.From))
}
out = append(out, map[string]any{"host": k, "defined_at": where, "wins": where[0]})
}
return out
}
// MeshHosts are the machines the mesh's own file names, each with the name it is reached by.
func (p *Parsed) MeshHosts() []map[string]string {
out := []map[string]string{}
for _, s := range p.Sections {
if s.Kind == "host" && s.Source == MeshFile && len(s.Patterns) > 0 {
name := s.Options["hostname"]
if name == "" {
name = s.Patterns[0]
}
out = append(out, map[string]string{"host": s.Patterns[0], "hostname": name, "user": s.Options["user"]})
}
}
return out
}
@@ -0,0 +1,110 @@
package main
// Keys, by their public half only. A fingerprint is computed here from the public key's bytes, as
// ssh-keygen -l does (SHA256 of the key blob, unpadded base64); a private key is never read by this
// process — its first line, the PEM header, says it is one, and ssh-keygen is asked about it.
import (
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"fmt"
"math/big"
"strings"
)
// KeyTypes are the public key algorithms an authorized_keys or known_hosts line can carry.
var KeyTypes = map[string]bool{
"ssh-ed25519": true, "ssh-rsa": true, "ssh-dss": true,
"ecdsa-sha2-nistp256": true, "ecdsa-sha2-nistp384": true, "ecdsa-sha2-nistp521": true,
"sk-ssh-ed25519@openssh.com": true, "sk-ecdsa-sha2-nistp256@openssh.com": true,
}
// PublicKey is one public key as a line carries it.
type PublicKey struct {
Type string `json:"type"`
Bits int `json:"bits"`
Fingerprint string `json:"fingerprint"`
Comment string `json:"comment"`
Options string `json:"options,omitempty"`
Weak string `json:"weak,omitempty"`
}
// ParseKeyLine reads one authorized_keys line (options, type, key, comment) or a public key file's.
func ParseKeyLine(line string) (PublicKey, error) {
fields := fieldsQuoted(strings.TrimSpace(line))
for i, f := range fields {
if !KeyTypes[f] || i+1 >= len(fields) {
continue
}
k := PublicKey{Type: f, Options: strings.Join(fields[:i], " "), Comment: strings.Join(fields[i+2:], " ")}
blob, err := base64.StdEncoding.DecodeString(fields[i+1])
if err != nil {
return k, fmt.Errorf("the key after %s is not base64", f)
}
sum := sha256.Sum256(blob)
k.Fingerprint = "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:])
k.Bits = bitsOf(f, blob)
switch {
case f == "ssh-dss":
k.Weak = "DSA: refused by current OpenSSH"
case f == "ssh-rsa" && k.Bits > 0 && k.Bits < 3072:
k.Weak = fmt.Sprintf("RSA of %d bits: below 3072", k.Bits)
}
return k, nil
}
return PublicKey{}, fmt.Errorf("no public key on this line")
}
// fieldsQuoted splits on spaces outside double quotes, as sshd reads an options field.
func fieldsQuoted(s string) []string {
var out []string
var cur strings.Builder
quoted := false
for _, ch := range s {
switch {
case ch == '"':
quoted = !quoted
cur.WriteRune(ch)
case (ch == ' ' || ch == '\t') && !quoted:
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
default:
cur.WriteRune(ch)
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// bitsOf reads a key's size from its blob: the RSA modulus, the curve, or ed25519's fixed 256.
func bitsOf(kind string, blob []byte) int {
strs := [][]byte{}
for len(blob) >= 4 {
n := binary.BigEndian.Uint32(blob)
if int(n) > len(blob)-4 {
break
}
strs = append(strs, blob[4:4+n])
blob = blob[4+n:]
}
switch {
case strings.Contains(kind, "ed25519"):
return 256
case kind == "ssh-rsa" && len(strs) >= 3:
return new(big.Int).SetBytes(strs[2]).BitLen()
case kind == "ssh-dss" && len(strs) >= 2:
return new(big.Int).SetBytes(strs[1]).BitLen()
case strings.Contains(kind, "nistp256"):
return 256
case strings.Contains(kind, "nistp384"):
return 384
case strings.Contains(kind, "nistp521"):
return 521
}
return 0
}
@@ -0,0 +1,138 @@
// ssh-client's Go tools bundle (novox/hq ADR 0188, ADR 0193; research 027/03): a process the node's
// tool runtime launches and speaks MCP over stdio to, through the Go SDK. It answers for the
// operator account's ~/.ssh — its hosts and where each came from, its keys, who may log in, the
// hosts it knows — and changes two things on request: one authorized key revoked, one known host
// refreshed. It runs as the operator account (ADR 0175 §4) and never reads a private key.
package main
import (
"context"
"fmt"
"math"
"os"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): ssh-client.
if err := stdio.Serve("", tools(NewClient())); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var hostArg = map[string]any{"type": "string", "description": "the host, as you would give it to ssh"}
func tools(c *Client) []stdio.Tool {
ctx := context.Background()
return []stdio.Tool{
{
Name: "ssh_client_hosts",
Description: "Every Host and Match section ssh reads for this account, in the order it reads them (an earlier one wins), each with its file and line " +
"and where it came from: mesh (the mesh's own file or region), drop-in (another file in ~/.ssh/config.d) or operator; with duplicates and what is set outside any section.",
Run: func(map[string]any) (any, error) { return c.Hosts() },
},
{
Name: "ssh_client_resolve",
Description: "What ssh would use for one host (ssh -G): host name, user, port, identity files, proxy, host-key checking — and which sections matched it.",
Input: map[string]any{"host": hostArg},
Run: func(args map[string]any) (any, error) {
h, err := text(args, "host")
if err != nil {
return nil, err
}
return c.Resolve(ctx, h)
},
},
{
Name: "ssh_client_check",
Description: "Everything about ~/.ssh worth a look: modes of the directory and every file, private keys with no passphrase or weak or old, the mesh's region first with its include, " +
"duplicate hosts, known_hosts entries for the mesh's machines that are missing or stale (scanned live unless scan is false), authorized keys without a comment, and debris. Says what it did not check.",
Input: map[string]any{"scan": map[string]any{"type": "boolean", "description": "ask each of the mesh's machines for its host key now (default true; 5 s each, in parallel)"}},
Run: func(args map[string]any) (any, error) { return c.Check(ctx, flag(args, "scan", true)) },
},
{
Name: "ssh_client_keys",
Description: "Every private key under ~/.ssh by its public half: type, size, fingerprint, comment, mode, age, whether it has a passphrase, and whether ssh offers it by itself. The key itself is never read.",
Run: func(map[string]any) (any, error) {
k, err := c.Keys(ctx)
if err != nil {
return nil, err
}
return map[string]any{"count": len(k), "keys": k}, nil
},
},
{
Name: "ssh_client_authorized",
Description: "Who may log in as this account by key: each line of authorized_keys by fingerprint, type, size, comment and options — never the key itself — with duplicates and unreadable lines.",
Run: func(map[string]any) (any, error) { return c.Authorized() },
},
{
Name: "ssh_client_revoke",
Description: "Take one key out of authorized_keys by its fingerprint (every line carrying it), keeping the file as it was beside it first. " +
"Refuses the last key (that would lock ssh out) and a key in the mesh's region (the next push would write it back).",
Input: map[string]any{"fingerprint": map[string]any{"type": "string", "description": "SHA256:… as ssh_client_authorized lists it"}},
Run: func(args map[string]any) (any, error) {
fp, err := text(args, "fingerprint")
if err != nil {
return nil, err
}
return c.Revoke(fp)
},
},
{
Name: "ssh_client_known_host",
Description: "What known_hosts holds for one host and what the host offers now, by fingerprint: matches, changed, not known or unreachable. With refresh true, the host's entries are replaced " +
"by what it offers now (ssh-keygen keeps known_hosts.old) — which trusts whatever answers, so only for a host whose key is known to have changed.",
Input: map[string]any{
"host": hostArg,
"port": map[string]any{"type": "integer", "description": "the ssh port (default 22)"},
"refresh": map[string]any{"type": "boolean", "description": "replace its entries with what it offers now (default false)"},
},
Run: func(args map[string]any) (any, error) {
h, err := text(args, "host")
if err != nil {
return nil, err
}
port := 22
if v, ok := args["port"].(float64); ok {
if v != math.Trunc(v) {
return nil, fmt.Errorf("port must be a whole number")
}
port = int(v)
}
return c.KnownHost(ctx, h, port, flag(args, "refresh", false))
},
},
{
Name: "ssh_client_test",
Description: "Can this machine reach a host and log in: one batch-mode connection (no prompt, runs only `true`), answering the address reached, the method and key that authenticated, " +
"or why it failed and which keys were offered. A key with a passphrase works only through an agent; the answer names the agent it used, or that there was none.",
Input: map[string]any{"host": hostArg},
Run: func(args map[string]any) (any, error) {
h, err := text(args, "host")
if err != nil {
return nil, err
}
return c.Test(ctx, h)
},
},
}
}
func text(args map[string]any, key string) (string, error) {
s, _ := args[key].(string)
if s = strings.TrimSpace(s); s == "" {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
func flag(args map[string]any, key string, def bool) bool {
if b, ok := args[key].(bool); ok {
return b
}
return def
}
@@ -0,0 +1,61 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// Ran is what a command did: its output, its exit status, and why it never ran to an answer.
type Ran struct {
Stdout string
Stderr string
Status int
// Err is "ENOENT" when the program is not installed, or says it was ended for taking too long.
Err string
}
// Runner runs one command with extra environment words, so every tool can be tested without ssh.
type Runner func(ctx context.Context, env []string, name string, args ...string) Ran
// CallTimeout bounds one command: below the runtime's thirty-second call limit.
const CallTimeout = 20 * time.Second
// ExecRunner runs a command on this machine, with no terminal and nothing on its stdin, bounded by
// CallTimeout.
func ExecRunner(ctx context.Context, env []string, name string, args ...string) Ran {
ctx, cancel := context.WithTimeout(ctx, CallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), env...)
var out, errb bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errb
err := cmd.Run()
r := Ran{Stdout: out.String(), Stderr: errb.String()}
var exitErr *exec.ExitError
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout/time.Second))
case errors.Is(err, exec.ErrNotFound):
r.Status, r.Err = 127, "ENOENT"
case errors.As(err, &exitErr):
r.Status = exitErr.ExitCode()
case err != nil:
r.Status, r.Err = 1, err.Error()
}
return r
}
func firstLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
@@ -0,0 +1,409 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"sort"
"strings"
"testing"
"time"
)
// Public keys made for these tests only; their private halves were never kept.
const (
edPub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOXSwIW0g4H2OOL8D3K21xsmE9p6f9xaj2os361ZKx2A op@laptop"
rsaPub = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDPb48PksTrIhBQxAVc7r1WHzOVQXa5XlwvUNLt0mkcZlSv4K9nHdKRK3LET7Tkuw2PgLhN4ttb058GGILQh24uD2/dfr7R5cCQTS6Z4iRTvTk2EG/HU9RKaNUJWrQc8kKQmx4+H4IgKIarqSpRw/ZTTyyylBV6+xNSMuZGJAHTZNN9Wn6VHlJEE5/IV95Uj+RqEO4+q7RtQC0dV+GWKUXvT5BFEpoU3AfS8yAgGwyotz8RxJktwel6YnafaHD8iNlj1rLo6k9HDXSKAEWk3hBjwO0YIquw6YuJFXGkoY72lbLt+h8/1sfTjjvZosRlWkejkAsU5W/Nb0Y37nt1nXwR old@ci"
edFP = "SHA256:qgWtIXM3wAqg5va+Mzzx7SJGwA7etBQT6Z7Bs3fLVCY"
rsaFP = "SHA256:6Fb092rWEQVP4y+ewi3r2hORvWlm6iFkfHT6BNB9T/Q"
)
type call struct {
env []string
name string
args []string
}
type fake struct {
answer func(c call) Ran
calls []call
}
func (f *fake) run(_ context.Context, env []string, name string, args ...string) Ran {
c := call{env, name, args}
f.calls = append(f.calls, c)
if f.answer == nil {
return Ran{Status: 1}
}
return f.answer(c)
}
func home(t *testing.T, files map[string]string) string {
t.Helper()
h := t.TempDir()
if err := os.MkdirAll(filepath.Join(h, ".ssh", "config.d"), 0o700); err != nil {
t.Fatal(err)
}
os.Chmod(filepath.Join(h, ".ssh"), 0o700)
for name, content := range files {
mode := os.FileMode(0o600)
if strings.HasSuffix(name, ".pub") {
mode = 0o644
}
p := filepath.Join(h, ".ssh", name)
os.MkdirAll(filepath.Dir(p), 0o700)
if err := os.WriteFile(p, []byte(strings.ReplaceAll(content, "HOME", h)), mode); err != nil {
t.Fatal(err)
}
}
return h
}
func client(h string, f *fake) *Client {
return &Client{Home: h, UID: 4242, Run: f.run, Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }}
}
// The layout this module writes: its region first, bringing in config.d; its own hosts in 00-mesh;
// a found predecessor file and a module's drop-in beside it; the operator's lines below.
var layout = map[string]string{
"config": "# BEGIN mesh ssh-client.config\n# the mesh's region\nInclude ~/.ssh/config.d/*\n# END mesh ssh-client.config\n\n" +
"ServerAliveInterval 30\nHost *\n AddKeysToAgent yes\nHost ace\n User wrong\nHost box\n HostName 192.0.2.7\n IdentityFile ~/.ssh/id_box\n",
"config.d/00-mesh": "# Generated by the mesh. Do not edit\n\nHost ace ace.internal\n HostName ace.internal\n User ace\n\nHost shanks shanks.internal\n HostName shanks.internal\n User op\n",
"config.d/mesh": "Host ace\n\tHostName ace.internal\n\tUser ace\n",
"config.d/work": "# Generated by the mesh. Do not edit\nHost forge.example\n Port 2222\n",
}
func TestHostsSayWhereEachCameFromInTheOrderSshReadsThem(t *testing.T) {
c := client(home(t, layout), &fake{})
got, err := c.Hosts()
if err != nil {
t.Fatal(err)
}
var who []string
for _, s := range got["sections"].([]Section) {
who = append(who, s.Patterns[0]+"@"+s.Source+"/"+s.From)
}
want := []string{"ace@config.d/00-mesh/mesh", "shanks@config.d/00-mesh/mesh", "ace@config.d/mesh/drop-in",
"forge.example@config.d/work/drop-in", "*@config/operator", "ace@config/operator", "box@config/operator"}
if !reflect.DeepEqual(who, want) {
t.Fatalf("order/source:\n%v\nwant\n%v", who, want)
}
if !reflect.DeepEqual(got["global"], []string{"config:6 ServerAliveInterval 30"}) {
t.Errorf("an operator line after the include read as part of the last included section: %v", got["global"])
}
dups := got["duplicates"].([]map[string]any)
if len(dups) != 1 || dups[0]["host"] != "ace" || dups[0]["wins"] != "config.d/00-mesh:3 (mesh)" {
t.Errorf("duplicates: %v", dups)
}
sections := got["sections"].([]Section)
if !sections[3].Mesh || sections[2].Mesh {
t.Errorf("a drop-in under the mesh's header is the mesh's; one without is found: %+v %+v", sections[3], sections[2])
}
}
func TestTheMeshsRegionMustComeFirstAndBringInConfigD(t *testing.T) {
h := home(t, layout)
if !client(h, &fake{}).meshIncludeFirst() {
t.Fatal("the written layout was not recognised")
}
os.WriteFile(filepath.Join(h, ".ssh", "config"), []byte("Host early\n User x\n"+layout["config"]), 0o600)
if client(h, &fake{}).meshIncludeFirst() {
t.Fatal("a host above the mesh's region passed")
}
}
func TestKeysAreDescribedByTheirPublicHalfAndAPassphraseIsAskedNotRead(t *testing.T) {
files := map[string]string{"config": layout["config"], "id_ed25519": "-----BEGIN OPENSSH PRIVATE KEY-----\nnot a key\n",
"id_ed25519.pub": edPub + "\n", "id_box": "-----BEGIN OPENSSH PRIVATE KEY-----\nnot a key\n", "id_box.pub": rsaPub + "\n",
"stray": "-----BEGIN RSA PRIVATE KEY-----\nnot a key\n", "notes.txt": "hello\n"}
f := &fake{answer: func(c call) Ran {
if c.name == "ssh-keygen" && c.args[0] == "-y" {
if strings.HasSuffix(c.args[len(c.args)-1], "id_box") {
return Ran{Status: 1, Stderr: "Load key \"id_box\": incorrect passphrase supplied to decrypt private key\n"}
}
if strings.HasSuffix(c.args[len(c.args)-1], "id_ed25519") {
return Ran{Stdout: edPub + "\n"}
}
}
if c.name == "ssh-keygen" && c.args[0] == "-l" {
return Ran{Stdout: "256 " + edFP + " no comment (ED25519)\n"}
}
return Ran{Status: 1, Stderr: "unexpected"}
}}
keys, err := client(home(t, files), f).Keys(context.Background())
if err != nil {
t.Fatal(err)
}
by := map[string]Key{}
for _, k := range keys {
by[filepath.Base(k.Path)] = k
}
if len(keys) != 3 {
t.Fatalf("%+v", keys)
}
if k := by["id_ed25519"]; k.Fingerprint != edFP || k.Passphrase != "none" || !strings.HasPrefix(k.OfferedBy, "default name") || k.Comment != "op@laptop" {
t.Errorf("ed25519: %+v", k)
}
if k := by["id_box"]; k.Passphrase != "yes" || k.Bits != 2048 || k.Weak == "" || !strings.HasPrefix(k.OfferedBy, "IdentityFile at config:") {
t.Errorf("box: %+v", k)
}
if k := by["stray"]; !k.PublicMissing || k.OfferedBy != "" || k.Fingerprint != edFP {
t.Errorf("stray: %+v", k)
}
for _, c := range f.calls {
if c.name == "ssh-keygen" && c.args[0] == "-y" && !reflect.DeepEqual(c.args[:3], []string{"-y", "-P", ""}) {
t.Errorf("a passphrase check could prompt: %v", c.args)
}
}
}
func TestAPublicHalfThatIsAnotherKeysIsFound(t *testing.T) {
files := map[string]string{"config": layout["config"], "id_ed25519": "-----BEGIN OPENSSH PRIVATE KEY-----\n", "id_ed25519.pub": rsaPub + "\n"}
f := &fake{answer: func(c call) Ran { return Ran{Stdout: edPub + "\n"} }}
keys, _ := client(home(t, files), f).Keys(context.Background())
if len(keys) != 1 || keys[0].PublicMismatch == "" || keys[0].Fingerprint != edFP {
t.Fatalf("%+v", keys)
}
}
func TestFingerprintsAreSshKeygens(t *testing.T) {
k, err := ParseKeyLine("from=\"10.0.0.0/8,192.0.2.1\",no-pty " + edPub)
if err != nil || k.Fingerprint != edFP || k.Bits != 256 || k.Comment != "op@laptop" || !strings.HasPrefix(k.Options, "from=") {
t.Fatalf("%+v %v", k, err)
}
k, _ = ParseKeyLine(rsaPub)
if k.Fingerprint != rsaFP || k.Bits != 2048 || !strings.Contains(k.Weak, "below 3072") {
t.Fatalf("%+v", k)
}
if _, err := ParseKeyLine("ssh-ed25519 !!!notbase64"); err == nil {
t.Fatal("garbage accepted")
}
}
func TestAuthorizedListsFingerprintsNeverKeys(t *testing.T) {
h := home(t, map[string]string{"config": layout["config"], "authorized_keys": "# mine\n" + edPub + "\n" + rsaPub + "\nnonsense line\n" + edPub + "\n"})
got, err := client(h, &fake{}).Authorized()
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(got)
if strings.Contains(string(b), "AAAA") {
t.Fatalf("a key was printed: %s", b)
}
if got["count"] != 3 || !reflect.DeepEqual(got["duplicates"], []string{edFP}) || !reflect.DeepEqual(got["unreadable_lines"], []int{4}) {
t.Fatalf("%s", b)
}
}
func TestRevokeKeepsTheFileFirstAndRefusesALockout(t *testing.T) {
original := "# mine\n" + edPub + "\n" + rsaPub + "\n"
h := home(t, map[string]string{"config": layout["config"], "authorized_keys": original})
c := client(h, &fake{})
got, err := c.Revoke(rsaFP)
if err != nil {
t.Fatal(err)
}
now, _ := os.ReadFile(filepath.Join(h, ".ssh", "authorized_keys"))
if string(now) != "# mine\n"+edPub+"\n" {
t.Fatalf("after: %q", now)
}
kept, _ := os.ReadFile(got["backup"].(string))
if string(kept) != original {
t.Fatal("the backup is not the file as it was")
}
if info, _ := os.Stat(filepath.Join(h, ".ssh", "authorized_keys")); info.Mode().Perm() != 0o600 {
t.Errorf("mode changed: %v", info.Mode())
}
if _, err := c.Revoke(edFP); err == nil || !strings.Contains(err.Error(), "lock ssh out") {
t.Fatalf("the last key was revoked: %v", err)
}
if _, err := c.Revoke("SHA256:nothing"); err == nil {
t.Fatal("an unknown fingerprint was accepted")
}
h = home(t, map[string]string{"authorized_keys": "# BEGIN mesh ssh-client.authorized\n" + rsaPub + "\n# END mesh ssh-client.authorized\n" + edPub + "\n"})
if _, err := client(h, &fake{}).Revoke(rsaFP); err == nil || !strings.Contains(err.Error(), "mesh's region") {
t.Fatalf("a key of the mesh's region was revoked: %v", err)
}
}
func scanOf(host, pub string) string { return host + " " + pub + "\n" }
func TestKnownHostComparesWhatIsKnownWithWhatIsOffered(t *testing.T) {
h := home(t, map[string]string{"config": layout["config"], "known_hosts": "ace.internal " + edPub + "\n"})
offered := edPub
f := &fake{answer: func(c call) Ran {
switch {
case c.name == "ssh-keygen" && c.args[0] == "-F":
return Ran{Stdout: "# Host ace.internal found: line 1\nace.internal " + edPub + "\n"}
case c.name == "ssh-keyscan":
return Ran{Stdout: scanOf("ace.internal", offered)}
case c.name == "ssh-keygen" && c.args[0] == "-R":
return Ran{}
}
return Ran{Status: 1}
}}
c := client(h, f)
got, err := c.KnownHost(context.Background(), "ace.internal", 22, false)
if err != nil || got["state"] != "matches" {
t.Fatalf("%v %v", got, err)
}
offered = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZha2VrZXlmYWtla2V5ZmFrZWtleWZha2VrZXlmYWs="
got, _ = c.KnownHost(context.Background(), "ace.internal", 22, false)
if !strings.HasPrefix(got["state"].(string), "changed") {
t.Fatalf("%v", got["state"])
}
got, err = c.KnownHost(context.Background(), "ace.internal", 22, true)
if err != nil || got["refreshed"] != true {
t.Fatalf("%v %v", got, err)
}
after, _ := os.ReadFile(filepath.Join(h, ".ssh", "known_hosts"))
if !strings.Contains(string(after), offered) {
t.Fatalf("not appended: %s", after)
}
sawRemove := false
for _, c := range f.calls {
if c.name == "ssh-keygen" && reflect.DeepEqual(c.args[:2], []string{"-R", "ace.internal"}) {
sawRemove = true
}
if c.name == "ssh-keyscan" && !reflect.DeepEqual(c.args[:4], []string{"-T", "5", "-p", "22"}) {
t.Errorf("an unbounded scan: %v", c.args)
}
}
if !sawRemove {
t.Fatal("the old entry was not removed through ssh-keygen (which keeps known_hosts.old)")
}
if _, err := c.KnownHost(context.Background(), "-oProxyCommand=x", 22, false); err == nil {
t.Fatal("an option was taken for a host")
}
}
func TestAnUnreachableHostIsNotRefreshed(t *testing.T) {
h := home(t, map[string]string{"known_hosts": ""})
f := &fake{answer: func(c call) Ran {
if c.name == "ssh-keyscan" {
return Ran{Status: 1}
}
return Ran{Status: 1}
}}
got, err := client(h, f).KnownHost(context.Background(), "gone.example", 22, false)
if err != nil || !strings.HasPrefix(got["state"].(string), "unreachable") {
t.Fatalf("%v %v", got, err)
}
if _, err := client(h, f).KnownHost(context.Background(), "gone.example", 22, true); err == nil {
t.Fatal("refreshed from nothing")
}
}
func TestTestSaysHowItAuthenticatedOrWhyNot(t *testing.T) {
ok := "debug1: Connecting to ace.internal [10.0.0.2] port 22.\ndebug1: Server accepts key: /h/.ssh/id_ed25519 ED25519 " + edFP + "\nAuthenticated to ace.internal ([10.0.0.2]:22) using \"publickey\".\n"
f := &fake{answer: func(c call) Ran { return Ran{Stderr: ok} }}
got, err := client(t.TempDir(), f).Test(context.Background(), "ace")
if err != nil || got["ok"] != true || got["method"] != "publickey" || got["connected_to"] != "10.0.0.2:22" {
t.Fatalf("%v %v", got, err)
}
args := strings.Join(f.calls[0].args, " ")
if !strings.Contains(args, "BatchMode=yes") || !strings.Contains(args, "StrictHostKeyChecking=yes") || !strings.HasSuffix(args, "ace true") {
t.Fatalf("not a batch test: %s", args)
}
denied := "debug1: Offering public key: /h/.ssh/id_box RSA " + rsaFP + "\nop@ace.internal: Permission denied (publickey).\n"
f = &fake{answer: func(c call) Ran { return Ran{Status: 255, Stderr: denied} }}
got, _ = client(t.TempDir(), f).Test(context.Background(), "ace")
if got["ok"] != false || got["why"] != "no key it offered was accepted" || !reflect.DeepEqual(got["offered"], []string{"/h/.ssh/id_box"}) {
t.Fatalf("%v", got)
}
if !strings.Contains(got["note"].(string), "agent") {
t.Fatalf("no word on the agent: %v", got)
}
}
func TestCheckFindsWhatIsWrongAndSaysWhatItDidNotCheck(t *testing.T) {
files := map[string]string{"config": "Host early\n User x\n" + layout["config"], "config.d/00-mesh": layout["config.d/00-mesh"],
"config.d/mesh": layout["config.d/mesh"], "id_ed25519": "-----BEGIN OPENSSH PRIVATE KEY-----\n", "id_ed25519.pub": edPub + "\n",
"authorized_keys": rsaPub + "\n", "known_hosts": "", "config.bak-1": "x"}
h := home(t, files)
os.Chmod(filepath.Join(h, ".ssh", "config"), 0o666)
os.Chmod(filepath.Join(h, ".ssh", "id_ed25519"), 0o644)
f := &fake{answer: func(c call) Ran {
switch {
case c.name == "ssh-keygen" && c.args[0] == "-y":
return Ran{Stdout: edPub}
case c.name == "ssh-keyscan":
return Ran{Stdout: scanOf("x", edPub)}
}
return Ran{Status: 1}
}}
got, err := client(h, f).Check(context.Background(), true)
if err != nil {
t.Fatal(err)
}
var whats []string
for _, x := range got["findings"].([]Finding) {
whats = append(whats, x.What)
}
all := strings.Join(whats, "\n")
for _, want := range []string{"writable by others (0666)", "private key readable by others (0644)", "no passphrase",
"not the first thing in the file", "Host ace is defined 3 times", "RSA of 2048 bits", "not known: the first connection would ask", "config.bak-1"} {
if !strings.Contains(all, want) {
t.Errorf("missing %q in:\n%s", want, all)
}
}
if got["ok"] != false || len(got["not_checked"].([]string)) == 0 {
t.Errorf("%v", got)
}
}
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Tools []string `json:"tools"`
}
json.Unmarshal(raw, &m)
served := []string{}
for _, tool := range tools(client(t.TempDir(), &fake{})) {
if !strings.HasPrefix(tool.Name, "ssh_client_") || tool.Description == "" {
t.Errorf("tool %q", tool.Name)
}
served = append(served, tool.Name)
}
sort.Strings(served)
sort.Strings(m.Tools)
if !reflect.DeepEqual(served, m.Tools) {
t.Fatalf("served %v, manifest %v", served, m.Tools)
}
}
// The module's own region, as the manifest declares it, read by ssh: the mesh's hosts first, a
// drop-in next, the operator's lines after, and an operator line after the region global again.
func TestTheManifestsRegionIsWhatSshReads(t *testing.T) {
raw, _ := os.ReadFile("../../module.json")
var m struct {
Resources []map[string]any `json:"resources"`
}
json.Unmarshal(raw, &m)
var region string
for _, r := range m.Resources {
if r["id"] == "config" {
region = r["content"].(string)
if r["into"] != "block" || r["at"] != "start" {
t.Fatalf("the region is not written into the start: %v", r)
}
}
}
if !strings.Contains(region, "Include ~/.ssh/config.d/*") {
t.Fatalf("no include: %q", region)
}
h := home(t, map[string]string{"config": "# BEGIN mesh ssh-client.config\n" + region + "# END mesh ssh-client.config\n\nCompression yes\nHost ace\n User wrong\n",
"config.d/00-mesh": layout["config.d/00-mesh"]})
p, err := Parse(h)
if err != nil {
t.Fatal(err)
}
if p.Sections[0].Source != MeshFile || p.Sections[0].Options["user"] != "ace" || len(p.Global) != 1 || !strings.HasSuffix(p.Global[0], " Compression yes") {
t.Fatalf("%+v %v", p.Sections, p.Global)
}
}
+5
View File
@@ -0,0 +1,5 @@
module sshclient
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+45 -4
View File
@@ -1,6 +1,16 @@
{
"module": "ssh-client",
"version": "1",
"tools": [
"ssh_client_hosts",
"ssh_client_resolve",
"ssh_client_check",
"ssh_client_keys",
"ssh_client_authorized",
"ssh_client_revoke",
"ssh_client_known_host",
"ssh_client_test"
],
"resources": [
{
"id": "ssh-dir",
@@ -8,14 +18,45 @@
"path": "${machine:account-home}/.ssh",
"owner": "${machine:account}",
"mode": "0700"
},
{
"id": "config-d",
"type": "directory",
"path": "${machine:account-home}/.ssh/config.d",
"owner": "${machine:account}",
"mode": "0700"
},
{
"id": "config",
"type": "file",
"path": "${machine:account-home}/.ssh/config",
"owner": "${machine:account}",
"mode": "0600",
"into": "block",
"at": "start",
"content": "# The mesh's region (module ssh-client, novox/hq research 027/03). It comes first because ssh\n# takes the first value it finds for each option. It brings in ~/.ssh/config.d/ in name order:\n# 00-mesh, the mesh's Host per machine, then each file another module places there. Replaced at\n# every push. Everything below it is yours, kept as you wrote it, and applies to every host the\n# files above leave unsettled.\nInclude ~/.ssh/config.d/*\n"
}
],
"facts": {
"ssh-config": {
"path": ".ssh/config",
"mesh-hosts": {
"path": ".ssh/config.d/00-mesh",
"home": true,
"shared": true,
"template": "# The mesh's Host blocks — every other node, so `ssh <node>` reaches it as the\n# right account. This region is replaced whenever a node joins, leaves or is\n# renamed; the rest of this file is yours and is kept untouched.\n{{range .Machines}}{{if ne .Name $.Node}}\nHost {{.Name}} {{.FQDN}}\n HostName {{.FQDN}}\n{{if .Account}} User {{.Account}}\n{{end}}{{end}}{{end}}"
"template": "# Generated by the mesh. Do not edit — module ssh-client writes this file whenever a machine\n# joins, leaves or is renamed. ~/.ssh/config includes it first, so these hosts win over every\n# later line; a host of your own goes below the mesh's region in ~/.ssh/config.\n{{range .Machines}}{{if ne .Name $.Node}}\nHost {{.Name}} {{.FQDN}}\n HostName {{.FQDN}}\n{{if .Account}} User {{.Account}}\n{{end}}{{end}}{{end}}"
}
},
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/ssh-client-tools",
"binary": "ssh-client-tools",
"loads": [
"ssh-client-tools"
]
}
]
}
}