ssh-client keeps ~/.ssh to what it knows: sweep every other path into a kept folder (issue 1017, ADR 0415) #346
@@ -8,12 +8,15 @@ item 9). `sshd` is the machine's side.
|
||||
| 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/` | owned | directory, the account's, mode 0700. It holds only the files this module declares (ADR 0415 §6). As every module's owned path, ssh-client is the only module that writes under `~/.ssh`: another module that needs a host there provides it through a provision, and this module writes it (ADR 0349 §1; the forge's host comes from `forge-account`). A host of the operator's own goes in `~/.ssh/config` below the mesh's region |
|
||||
| `~/.ssh/config.d/10-forge` | owned | the forge's host, with the machine's own key (ADR 0399) |
|
||||
| `~/.ssh/mesh_ed25519`, `.pub` | written by the module's own process | the machine's account's key for the forge, made once (keykeeper.go) |
|
||||
| `~/.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 |
|
||||
| private keys, public keys, certificates | kept | never read, never written, never moved, at any depth and whatever their name. `ssh_client_keys` and `ssh_client_check` read a file's first line to recognise a key, and ask `ssh-keygen` about it |
|
||||
| anything else | swept | moved out of `~/.ssh` by the sweep (below) |
|
||||
|
||||
### Why an include, not Host blocks in the region
|
||||
|
||||
@@ -54,23 +57,24 @@ So both files stay *found*, and this module invents nothing.
|
||||
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 sweep: `~/.ssh` holds only what this module knows (ADR 0415, issue 1017)
|
||||
|
||||
The mesh removes nothing it did not make. After the first push, a machine that had the predecessor's
|
||||
layout still holds:
|
||||
The module owns `~/.ssh`, so it keeps it to what it knows. Its process sweeps when it starts and every
|
||||
ten minutes. It knows the paths in the table above, every key (a private key by its first line, a
|
||||
public key or certificate by its line, at any depth and whatever its name) and sockets (ssh's own
|
||||
runtime). Every other path is moved, whole, into
|
||||
`~/.local/state/ssh-client/retired/<UTC time>/`, keeping its path relative to `~/.ssh`, and the list
|
||||
of what moved is written beside it as `<UTC time>.json`. Nothing is deleted. A folder that holds a key
|
||||
is gone into rather than moved whole, so the key stays where it is. A file or folder that cannot be read to
|
||||
tell whether it holds a key is left and named in the log.
|
||||
|
||||
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.
|
||||
So the predecessor's `config.d/mesh`, `config.bak-*`, `known_hosts.old`, `removed-*` and
|
||||
`authorized_keys.revoked-*` leave `~/.ssh` at the first sweep. Each move is logged by the process, and
|
||||
`ssh_client_check` lists every sweep that moved something, with where each path lies now.
|
||||
|
||||
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.
|
||||
The predecessor's lines inside `~/.ssh/config`, below the mesh's region (its header comment and its
|
||||
`Include ~/.ssh/config.d/mesh`), are the operator's part of a written-into file and stay. ssh skips an
|
||||
`Include` that matches no file.
|
||||
|
||||
## Tools
|
||||
|
||||
@@ -80,7 +84,7 @@ They run as the operator account and never escalate. No answer carries a key: fi
|
||||
|---|---|---|
|
||||
| `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_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; what the sweep moved out of `~/.ssh` and where each lies now. 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) |
|
||||
|
||||
@@ -809,13 +809,17 @@ func (c *Client) Check(ctx context.Context, scan bool) (map[string]any, error) {
|
||||
if len(debris) > 0 {
|
||||
add("note", dir, "backups and retired copies lie beside the live files: %s", strings.Join(debris, ", "))
|
||||
}
|
||||
retired, err := c.Retired()
|
||||
if err != nil {
|
||||
add("problem", filepath.Join(c.Home, RetiredDir), "cannot be read: %v", err)
|
||||
}
|
||||
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
|
||||
return map[string]any{"ok": problems == 0, "problems": problems, "findings": f, "keys": keyList, "debris": debris, "retired": retired, "not_checked": notChecked}, nil
|
||||
}
|
||||
|
||||
// meshIncludeFirst is whether ~/.ssh/config begins with the mesh's region and it includes config.d.
|
||||
|
||||
@@ -52,7 +52,8 @@ func tools(c *Client) []stdio.Tool {
|
||||
{
|
||||
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.",
|
||||
"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 what the sweep moved out of ~/.ssh because this module does not know it, with where each lies now (ADR 0415). 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)) },
|
||||
},
|
||||
@@ -149,10 +150,20 @@ func startKeeper() {
|
||||
if node == "" || err != nil || c.Home == "" {
|
||||
return
|
||||
}
|
||||
go keepSwept(c)
|
||||
k := &KeyKeeper{Home: c.Home, Node: node, Account: u.Username, Run: ExecRunner, State: keysState{}, Now: time.Now}
|
||||
k.Keep(context.Background(), func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[ssh-client] "+f+"\n", a...) })
|
||||
}
|
||||
|
||||
// keepSwept sweeps ~/.ssh (sweep.go) now and every KeyRound.
|
||||
func keepSwept(c *Client) {
|
||||
say := func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[ssh-client] "+f+"\n", a...) }
|
||||
for {
|
||||
c.SweepOnce(time.Now(), say)
|
||||
time.Sleep(KeyRound)
|
||||
}
|
||||
}
|
||||
|
||||
type keysState struct{}
|
||||
|
||||
func (keysState) Put(key string, value any) error {
|
||||
|
||||
@@ -363,6 +363,17 @@ func TestCheckFindsWhatIsWrongAndSaysWhatItDidNotCheck(t *testing.T) {
|
||||
if got["ok"] != false || len(got["not_checked"].([]string)) == 0 {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
|
||||
// ADR 0415 §4: the check lists what the sweep moved, and where it lies now.
|
||||
c := client(h, f)
|
||||
if _, err := c.Sweep(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = c.Check(context.Background(), false)
|
||||
retired := got["retired"].([]Sweep)
|
||||
if len(retired) != 1 || retired[0].At != "20261017T120000Z" || len(retired[0].Moved) != 2 {
|
||||
t.Errorf("retired = %+v, want config.d/mesh and config.bak-1 in one sweep", retired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package main
|
||||
|
||||
// The sweep (novox/hq ADR 0415, issue 1017): ssh-client owns ~/.ssh, so it keeps it to what it knows.
|
||||
// Every path it does not know is moved, whole, into ~/.local/state/ssh-client/retired/<UTC time>/,
|
||||
// keeping its path relative to ~/.ssh. Nothing is deleted. A key (private, public or a certificate),
|
||||
// at any depth and whatever its name, and authorized_keys are never moved (design 29 §3: moving one
|
||||
// can lock the operator out); a folder holding a key is gone into rather than moved whole. A socket
|
||||
// is ssh's own runtime and stays. The process sweeps when it starts and every KeyRound (main.go).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Declared are the paths under ~/.ssh this module declares (module.json) or its own process makes
|
||||
// (keykeeper.go). A test checks the manifest against this list.
|
||||
var Declared = map[string]bool{
|
||||
"config": true,
|
||||
"known_hosts": true,
|
||||
"config.d": true,
|
||||
"config.d/00-mesh": true,
|
||||
"config.d/10-forge": true,
|
||||
KeyFile: true,
|
||||
KeyFile + ".pub": true,
|
||||
"authorized_keys": true,
|
||||
"authorized_keys2": true, // sshd's second AuthorizedKeysFile by default: moving it can lock the operator out
|
||||
}
|
||||
|
||||
// copyOf is a copy of authorized_keys or known_hosts (authorized_keys.revoked-*, known_hosts.old): it
|
||||
// carries key lines but is no key, and sshd and ssh never read it.
|
||||
var copyOf = regexp.MustCompile(`^(authorized_keys|known_hosts)[^/]+$`)
|
||||
|
||||
// RetiredDir is where the sweep moves what it does not know, under the account's home.
|
||||
const RetiredDir = ".local/state/ssh-client/retired"
|
||||
|
||||
// Moved is one path a sweep moved out of ~/.ssh.
|
||||
type Moved struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
// Sweep is what one sweep did: what it moved, and what it left because it could not tell what it is.
|
||||
type Sweep struct {
|
||||
At string `json:"at"`
|
||||
Moved []Moved `json:"moved"`
|
||||
Left []string `json:"left,omitempty"`
|
||||
}
|
||||
|
||||
// Sweep moves every path under ~/.ssh this module does not know into a retired folder named by now,
|
||||
// and writes beside that folder the list of what it moved (<time>.json). It moves nothing when
|
||||
// everything is known, and makes nothing then.
|
||||
func (c *Client) Sweep(now time.Time) (Sweep, error) {
|
||||
dir := c.ssh("")
|
||||
s := Sweep{At: now.UTC().Format("20060102T150405Z"), Moved: []Moved{}}
|
||||
if _, err := os.Lstat(dir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return s, nil
|
||||
}
|
||||
return s, err
|
||||
}
|
||||
var unknown []string
|
||||
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if p == dir {
|
||||
return err
|
||||
}
|
||||
s.Left = append(s.Left, p) // a folder that cannot be read may hold a key
|
||||
return fs.SkipDir
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, p)
|
||||
switch {
|
||||
case rel == "." || Declared[rel] || d.Type()&fs.ModeSocket != 0:
|
||||
return nil
|
||||
case d.IsDir():
|
||||
if holdsKey(p) {
|
||||
return nil // gone into: the key stays where it is, the rest around it goes
|
||||
}
|
||||
unknown = append(unknown, rel)
|
||||
return fs.SkipDir // moved whole
|
||||
case d.Type()&fs.ModeSymlink != 0:
|
||||
// A link to a key is a key's name: it stays. Any other link is moved as the link, never
|
||||
// what it points at.
|
||||
if st, err := os.Stat(p); err == nil && st.Mode().IsRegular() && keyKind(p) == "key" {
|
||||
return nil
|
||||
}
|
||||
unknown = append(unknown, rel)
|
||||
return nil
|
||||
case !d.Type().IsRegular():
|
||||
unknown = append(unknown, rel)
|
||||
return nil
|
||||
case copyOf.MatchString(rel):
|
||||
unknown = append(unknown, rel)
|
||||
return nil
|
||||
}
|
||||
switch k := keyKind(p); k {
|
||||
case "key":
|
||||
return nil
|
||||
case "unreadable":
|
||||
s.Left = append(s.Left, p)
|
||||
return nil
|
||||
}
|
||||
unknown = append(unknown, rel)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
if len(unknown) == 0 {
|
||||
return s, nil
|
||||
}
|
||||
root, err := c.claim(s.At)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.At = filepath.Base(root)
|
||||
defer func() {
|
||||
if len(s.Moved) > 0 {
|
||||
raw, _ := json.MarshalIndent(s.Moved, "", " ")
|
||||
os.WriteFile(root+".json", raw, 0o600)
|
||||
}
|
||||
}()
|
||||
for _, rel := range unknown {
|
||||
to := filepath.Join(root, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil {
|
||||
return s, err
|
||||
}
|
||||
if _, err := os.Lstat(to); err == nil {
|
||||
return s, fmt.Errorf("could not move %s out of ~/.ssh: %s is already there", rel, to) // never over a kept copy
|
||||
}
|
||||
if err := os.Rename(filepath.Join(dir, rel), to); err != nil {
|
||||
return s, fmt.Errorf("could not move %s out of ~/.ssh: %v", rel, err)
|
||||
}
|
||||
s.Moved = append(s.Moved, Moved{From: filepath.Join(dir, rel), To: to})
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// claim makes this sweep's own retired folder, named by its time; a folder of that name already there
|
||||
// (a sweep in the same second) is never shared: the name takes -2, -3, … instead.
|
||||
func (c *Client) claim(at string) (string, error) {
|
||||
base := filepath.Join(c.Home, RetiredDir)
|
||||
if err := os.MkdirAll(base, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for n := 1; n <= 1000; n++ {
|
||||
root := filepath.Join(base, at)
|
||||
if n > 1 {
|
||||
root = fmt.Sprintf("%s-%d", root, n)
|
||||
}
|
||||
err := os.Mkdir(root, 0o700)
|
||||
if err == nil {
|
||||
return root, nil
|
||||
}
|
||||
if !os.IsExist(err) {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no free name for the sweep's folder under %s", base)
|
||||
}
|
||||
|
||||
// keyKind says whether a regular file is a key ("key": a private key, a public key or a certificate),
|
||||
// cannot be read to tell ("unreadable"), or is neither ("").
|
||||
func keyKind(path string) string {
|
||||
if st, err := os.Stat(path); err != nil {
|
||||
return "unreadable"
|
||||
} else if !st.Mode().IsRegular() {
|
||||
return "" // a pipe or a device is no key, and opening one can block
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "unreadable"
|
||||
}
|
||||
f.Close()
|
||||
if header(path) || publicKey(path) {
|
||||
return "key"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// holdsKey is whether a folder holds, at any depth, a key, a socket or a file that cannot be read to
|
||||
// tell.
|
||||
func holdsKey(dir string) bool {
|
||||
found := false
|
||||
filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
found = true // a folder that cannot be read may hold one
|
||||
return fs.SkipAll
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, p)
|
||||
if d.Type()&fs.ModeSocket != 0 {
|
||||
found = true
|
||||
return fs.SkipAll
|
||||
}
|
||||
if (d.Type().IsRegular() || d.Type()&fs.ModeSymlink != 0) && !copyOf.MatchString(d.Name()) && keyKind(p) != "" && rel != "." {
|
||||
found = true
|
||||
return fs.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
// publicKey reads a small file's first line and says whether it is a public key or a certificate.
|
||||
func publicKey(path string) bool {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil || st.Size() > 16<<10 {
|
||||
return false
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
line, _, _ := strings.Cut(string(raw), "\n")
|
||||
if _, err := ParseKeyLine(line); err == nil {
|
||||
return true
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
return len(f) > 1 && strings.HasSuffix(f[0], "-cert-v01@openssh.com")
|
||||
}
|
||||
|
||||
// Retired lists the sweeps that moved something, newest first, from the lists each sweep wrote.
|
||||
func (c *Client) Retired() ([]Sweep, error) {
|
||||
root := filepath.Join(c.Home, RetiredDir)
|
||||
entries, err := os.ReadDir(root)
|
||||
if os.IsNotExist(err) {
|
||||
return []Sweep{}, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Sweep{}
|
||||
for _, e := range entries {
|
||||
at, ok := strings.CutSuffix(e.Name(), ".json")
|
||||
if !ok || e.IsDir() {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, e.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := Sweep{At: at}
|
||||
if err := json.Unmarshal(raw, &s.Moved); err != nil {
|
||||
return nil, fmt.Errorf("%s: %v", filepath.Join(root, e.Name()), err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Slice(out, func(a, b int) bool { return out[a].At > out[b].At })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SweepOnce sweeps and says each move, each path left, and a failure through say.
|
||||
func (c *Client) SweepOnce(now time.Time, say func(string, ...any)) {
|
||||
s, err := c.Sweep(now)
|
||||
for _, m := range s.Moved {
|
||||
say("moved %s, which this module does not know, to %s", m.From, m.To)
|
||||
}
|
||||
for _, l := range s.Left {
|
||||
say("left %s: it cannot be read to tell whether it is a key", l)
|
||||
}
|
||||
if err != nil {
|
||||
say("the sweep of ~/.ssh: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ADR 0415, issue 1017: the sweep keeps ~/.ssh to what this module knows and moves the rest out.
|
||||
|
||||
func sweepHome(t *testing.T) (*Client, string) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
dir := filepath.Join(home, ".ssh")
|
||||
put := func(rel, body string, mode os.FileMode) {
|
||||
p := filepath.Join(dir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
key := "-----BEGIN OPENSSH PRIVATE KEY-----\nPRIVATE\n"
|
||||
for rel := range Declared {
|
||||
if rel != "config.d" {
|
||||
put(rel, "declared\n", 0o600)
|
||||
}
|
||||
}
|
||||
put("id_ed25519", key, 0o600)
|
||||
put("id_ed25519.pub", ed25519Line(1, "me")+"\n", 0o644)
|
||||
put("work_key", key, 0o600) // a key with an unusual name
|
||||
put("work_key.pub", ed25519Line(2, "work")+"\n", 0o644)
|
||||
put("work_key-cert.pub", "ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29t work\n", 0o644)
|
||||
put("config.bak-20260807-015327", "old\n", 0o600)
|
||||
put("known_hosts.old", "old\n", 0o644)
|
||||
put("config.d/mesh", "Host ace\n", 0o600)
|
||||
put("removed-hal-20261002/config", "old\n", 0o600)
|
||||
put("removed-drone-20261004/id_drone", key, 0o600) // a key inside a leftover folder
|
||||
put("removed-drone-20261004/config", "old\n", 0o600)
|
||||
put("agent_only.pub", ed25519Line(3, "private half in an agent")+"\n", 0o644)
|
||||
put("notes.pub", "not a key\n", 0o644)
|
||||
put("authorized_keys.revoked-20261001T000000Z", ed25519Line(4, "revoked")+"\n", 0o600)
|
||||
put("authorized_keys.bak", ed25519Line(5, "copy")+"\n", 0o600)
|
||||
put("authorized_keys2", ed25519Line(6, "second file")+"\n", 0o600)
|
||||
put("config", "# BEGIN mesh ssh-client.config\nInclude ~/.ssh/config.d/*\n# END mesh ssh-client.config\n\nHost mine\n User me\nInclude ~/.ssh/config.d/mesh\n", 0o600)
|
||||
elsewhere := filepath.Join(home, "token")
|
||||
os.WriteFile(elsewhere, []byte(key), 0o600)
|
||||
if err := os.Symlink(elsewhere, filepath.Join(dir, "id_token")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(home, "gone"), filepath.Join(dir, "dangling")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Client{Home: home}, dir
|
||||
}
|
||||
|
||||
func exists(p string) bool { _, err := os.Lstat(p); return err == nil }
|
||||
|
||||
func TestSweepKeepsWhatItKnows(t *testing.T) {
|
||||
c, dir := sweepHome(t)
|
||||
now := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
|
||||
s, err := c.Sweep(now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rel := range []string{"config", "known_hosts", "authorized_keys", "config.d/00-mesh", "config.d/10-forge",
|
||||
KeyFile, KeyFile + ".pub", "id_ed25519", "id_ed25519.pub", "work_key", "work_key.pub", "work_key-cert.pub", "agent_only.pub", "removed-drone-20261004/id_drone", "authorized_keys2", "id_token"} {
|
||||
if !exists(filepath.Join(dir, rel)) {
|
||||
t.Errorf("%s was moved; it is known", rel)
|
||||
}
|
||||
}
|
||||
root := filepath.Join(c.Home, RetiredDir, "20261017T120000Z")
|
||||
for _, rel := range []string{"config.bak-20260807-015327", "known_hosts.old", "config.d/mesh", "removed-hal-20261002", "notes.pub", "removed-drone-20261004/config",
|
||||
"authorized_keys.revoked-20261001T000000Z", "authorized_keys.bak", "dangling"} {
|
||||
if exists(filepath.Join(dir, rel)) {
|
||||
t.Errorf("%s is still in ~/.ssh", rel)
|
||||
}
|
||||
if !exists(filepath.Join(root, rel)) {
|
||||
t.Errorf("%s is not kept at %s", rel, filepath.Join(root, rel))
|
||||
}
|
||||
}
|
||||
if !exists(filepath.Join(root, "removed-hal-20261002", "config")) {
|
||||
t.Error("a folder was not moved whole")
|
||||
}
|
||||
if len(s.Moved) != 9 {
|
||||
t.Errorf("moved %d paths, want 9: %+v", len(s.Moved), s.Moved)
|
||||
}
|
||||
if raw, _ := os.ReadFile(filepath.Join(dir, "config")); !strings.Contains(string(raw), "Host mine\n User me\nInclude ~/.ssh/config.d/mesh\n") {
|
||||
t.Errorf("the operator's lines in config changed: %q", raw)
|
||||
}
|
||||
if st, _ := os.Stat(filepath.Join(c.Home, RetiredDir)); st == nil || st.Mode().Perm() != 0o700 {
|
||||
t.Errorf("the retired folder is not 0700: %v", st)
|
||||
}
|
||||
|
||||
again, err := c.Sweep(now.Add(time.Minute))
|
||||
if err != nil || len(again.Moved) != 0 {
|
||||
t.Fatalf("a second sweep moved %+v (err %v)", again.Moved, err)
|
||||
}
|
||||
if exists(filepath.Join(c.Home, RetiredDir, "20261017T120100Z")) {
|
||||
t.Error("a sweep that moved nothing made a folder")
|
||||
}
|
||||
|
||||
retired, err := c.Retired()
|
||||
if err != nil || len(retired) != 1 || retired[0].At != "20261017T120000Z" || len(retired[0].Moved) != 9 {
|
||||
raw, _ := json.Marshal(retired)
|
||||
t.Fatalf("Retired = %s (err %v), want one sweep of 9 paths", raw, err)
|
||||
}
|
||||
for _, m := range retired[0].Moved {
|
||||
if !strings.HasPrefix(m.From, dir+"/") || !strings.HasPrefix(m.To, root+"/") || !exists(m.To) {
|
||||
t.Errorf("listed %+v, not a path moved from ~/.ssh into %s", m, root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepOnceSaysEachMove(t *testing.T) {
|
||||
c, dir := sweepHome(t)
|
||||
var said []string
|
||||
c.SweepOnce(time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC), func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) })
|
||||
want := "moved " + filepath.Join(dir, "known_hosts.old") + ", which this module does not know, to " +
|
||||
filepath.Join(c.Home, RetiredDir, "20261017T120000Z", "known_hosts.old")
|
||||
if !slices.Contains(said, want) || len(said) != 9 {
|
||||
t.Fatalf("said %q, want 9 lines with %q", said, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepLeavesSocketsAndUnreadable(t *testing.T) {
|
||||
c, dir := sweepHome(t)
|
||||
sock := filepath.Join(dir, "cm-ace")
|
||||
if l, err := net.Listen("unix", sock); err == nil {
|
||||
defer l.Close()
|
||||
} else {
|
||||
t.Skipf("no unix socket here: %v", err)
|
||||
}
|
||||
locked := filepath.Join(dir, "unreadable")
|
||||
os.WriteFile(locked, []byte("?"), 0o000)
|
||||
if f, err := os.Open(locked); err == nil {
|
||||
f.Close()
|
||||
t.Skip("running as a user who reads a mode-0 file")
|
||||
}
|
||||
s, err := c.Sweep(time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !exists(sock) {
|
||||
t.Error("a socket was moved")
|
||||
}
|
||||
if !exists(locked) || len(s.Left) != 1 || s.Left[0] != locked {
|
||||
t.Errorf("an unreadable file was not left and named: left %v", s.Left)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepWithoutSSHDir(t *testing.T) {
|
||||
c := &Client{Home: t.TempDir()}
|
||||
if s, err := c.Sweep(time.Now()); err != nil || len(s.Moved) != 0 {
|
||||
t.Fatalf("Sweep = %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every path the manifest declares under ~/.ssh is one the sweep knows.
|
||||
func TestDeclaredMatchesManifest(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []struct{ Path string } `json:"resources"`
|
||||
Facts map[string]struct{ Path string }
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
under := regexp.MustCompile(`^(\$\{machine:account-home\}/)?\.ssh/(.+)$`)
|
||||
paths := []string{}
|
||||
for _, r := range m.Resources {
|
||||
paths = append(paths, r.Path)
|
||||
}
|
||||
for _, f := range m.Facts {
|
||||
paths = append(paths, f.Path)
|
||||
}
|
||||
seen := 0
|
||||
for _, p := range paths {
|
||||
g := under.FindStringSubmatch(strings.TrimSuffix(p, "/"))
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if !Declared[g[2]] {
|
||||
t.Errorf("module.json declares ~/.ssh/%s, which the sweep does not know: it would be moved out", g[2])
|
||||
}
|
||||
}
|
||||
if seen < 5 {
|
||||
t.Errorf("found %d paths under ~/.ssh in module.json; the manifest's shape changed", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1: a folder holding only a socket is gone into, not moved with the socket.
|
||||
func TestSweepKeepsAFolderHoldingASocket(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dir := filepath.Join(home, ".ssh", "cm")
|
||||
os.MkdirAll(dir, 0o700)
|
||||
l, err := net.Listen("unix", filepath.Join(dir, "s"))
|
||||
if err != nil {
|
||||
t.Skipf("no unix socket here: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
c := &Client{Home: home}
|
||||
if s, err := c.Sweep(time.Now()); err != nil || len(s.Moved) != 0 || !exists(filepath.Join(dir, "s")) {
|
||||
t.Fatalf("moved %+v (err %v); the socket's folder must stay", s.Moved, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1: two sweeps in the same second never share a folder, so neither overwrites the other.
|
||||
func TestSweepsInOneSecondKeepBoth(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dir := filepath.Join(home, ".ssh")
|
||||
os.MkdirAll(dir, 0o700)
|
||||
c := &Client{Home: home}
|
||||
now := time.Date(2026, 10, 17, 12, 0, 0, 0, time.UTC)
|
||||
os.WriteFile(filepath.Join(dir, "a"), []byte("1"), 0o600)
|
||||
first, err := c.Sweep(now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, "a"), []byte("2"), 0o600)
|
||||
second, err := c.Sweep(now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.At == second.At {
|
||||
t.Fatalf("both sweeps used %s", first.At)
|
||||
}
|
||||
for at, want := range map[string]string{first.At: "1", second.At: "2"} {
|
||||
if raw, _ := os.ReadFile(filepath.Join(home, RetiredDir, at, "a")); string(raw) != want {
|
||||
t.Errorf("%s/a = %q, want %q", at, raw, want)
|
||||
}
|
||||
}
|
||||
if r, _ := c.Retired(); len(r) != 2 {
|
||||
t.Errorf("Retired lists %d sweeps, want 2", len(r))
|
||||
}
|
||||
}
|
||||
|
||||
// Review round 1: a link to a pipe below ~/.ssh never blocks the sweep.
|
||||
func TestSweepDoesNotOpenAPipe(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
sub := filepath.Join(home, ".ssh", "sub")
|
||||
os.MkdirAll(sub, 0o700)
|
||||
fifo := filepath.Join(home, "fifo")
|
||||
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
|
||||
t.Skipf("no fifo here: %v", err)
|
||||
}
|
||||
os.Symlink(fifo, filepath.Join(sub, "p"))
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := (&Client{Home: home}).Sweep(time.Now()); done <- err }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the sweep blocked on a pipe")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user