Keep what ssh reads itself and linked key folders in ~/.ssh, and fail the sweep loudly (issue 1020, ADR 0417)
mesh/merge-gate head 05e6b583e2e3 merged into trunk ce56b491cffd — pass: builds ssh-client → ace, g14, novox, shanks; no bus step; every machine composes w…
mesh/repo-check head 05e6b583e2e3 merged into trunk ce56b491cffd — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered

The sweep from issue 1017 moved known_hosts2, rc and environment, which ssh
and sshd read by default, and moved a link to a folder of keys, breaking every
login through it. It could move a file a tool was still writing. When ~/.local
was on another file system, every sweep failed at its first move and left an
empty folder each round, seen only in the journal. Its list was written after
the moves with errors ignored.

Now those files and linked key folders stay; a link whose target, or the folder
it names, cannot be checked is left and named; a copy of authorized_keys is
moved at any depth; a path changed in the last minute, at any depth, waits for
the next sweep; a retired folder on another file system fails the sweep before
anything is made; the list names each move before it is made; and each sweep's
outcome is kept for ssh_client_check, which shows a failed sweep as a problem.
A key's first line is read only so far.

The catalogue's new checks/sshdir refuses any manifest but ssh-client naming a
path under ~/.ssh (ADR 0415 §6). The README cites §7 for config.d.
This commit is contained in:
2026-10-17 13:36:27 +00:00
parent ce56b491cf
commit 05e6b583e2
9 changed files with 702 additions and 33 deletions
@@ -8,6 +8,7 @@ import (
"bufio"
"context"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
@@ -24,6 +25,8 @@ type Client struct {
UID int
Run Runner
Now func() time.Time
// Settle is how long a path in ~/.ssh is left by the sweep after it changed (sweep.go): zero in tests.
Settle time.Duration
}
// NewClient is the client the bundle serves with: the operator's home as the runtime names it.
@@ -32,7 +35,7 @@ func NewClient() *Client {
if home == "" {
home, _ = os.UserHomeDir()
}
return &Client{Home: home, UID: os.Getuid(), Run: ExecRunner, Now: time.Now}
return &Client{Home: home, UID: os.Getuid(), Run: ExecRunner, Now: time.Now, Settle: time.Minute}
}
func (c *Client) ssh(name string) string { return filepath.Join(c.Home, ".ssh", name) }
@@ -173,7 +176,7 @@ func header(path string) bool {
return false
}
defer f.Close()
line, _ := bufio.NewReader(f).ReadString('\n')
line, _ := bufio.NewReader(io.LimitReader(f, 256)).ReadString('\n') // a key's first line is short; a large file's may not be
return strings.HasPrefix(line, "-----BEGIN") && strings.Contains(line, "PRIVATE KEY-----")
}
@@ -813,6 +816,22 @@ func (c *Client) Check(ctx context.Context, scan bool) (map[string]any, error) {
if err != nil {
add("problem", filepath.Join(c.Home, RetiredDir), "cannot be read: %v", err)
}
for _, r := range retired {
for _, m := range r.Moved {
if m.Missing {
add("problem", m.To, "the sweep of %s lists %s as moved here, but nothing is here: it may still be in ~/.ssh", r.At, m.From)
}
}
}
last, err := c.LastSwept()
switch {
case err != nil:
add("problem", filepath.Join(c.Home, LastSweepFile), "cannot be read: %v", err)
case last == nil:
notChecked = append(notChecked, "the sweep: it has not run on this account yet")
case last.Error != "":
add("problem", dir, "the last sweep (%s) failed: %s", last.At, last.Error)
}
problems := 0
for _, x := range f {
if x.Severity == "problem" {