Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add923c74a | ||
|
|
d4a6008bd6 |
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module docker
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module sshclient
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user