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.
464 lines
18 KiB
Go
464 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"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
|
|
mu sync.Mutex
|
|
calls []call
|
|
}
|
|
|
|
// recorded returns a copy of the calls so far; scans call run from parallel goroutines.
|
|
func (f *fake) recorded() []call {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]call(nil), f.calls...)
|
|
}
|
|
|
|
func (f *fake) run(_ context.Context, env []string, name string, args ...string) Ran {
|
|
c := call{env, name, args}
|
|
f.mu.Lock()
|
|
f.calls = append(f.calls, c)
|
|
f.mu.Unlock()
|
|
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.recorded() {
|
|
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.recorded() {
|
|
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.recorded()[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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// Issue 1020, ADR 0417: the check shows a failed last sweep and a listed move with nothing at its destination
|
|
// as problems, and says when no sweep has run.
|
|
func TestCheckShowsAFailedSweep(t *testing.T) {
|
|
h := home(t, map[string]string{"config": layout["config"], "known_hosts": ""})
|
|
f := &fake{answer: func(call) Ran { return Ran{Status: 1} }}
|
|
c := client(h, f)
|
|
got, _ := c.Check(context.Background(), false)
|
|
if !slices.ContainsFunc(got["not_checked"].([]string), func(s string) bool { return strings.Contains(s, "the sweep") }) {
|
|
t.Errorf("not_checked = %v, want the sweep named as not run yet", got["not_checked"])
|
|
}
|
|
if err := c.writeLastSweep(LastSweep{At: "20261017T120000Z", Error: "it broke"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
list := filepath.Join(h, RetiredDir, "20261001T000000Z.json")
|
|
os.MkdirAll(filepath.Dir(list), 0o700)
|
|
os.WriteFile(list, []byte(`[{"from":"`+filepath.Join(h, ".ssh", "x")+`","to":"`+filepath.Join(h, RetiredDir, "20261001T000000Z", "x")+`"}]`), 0o600)
|
|
got, _ = c.Check(context.Background(), false)
|
|
var whats []string
|
|
for _, x := range got["findings"].([]Finding) {
|
|
if x.Severity == "problem" {
|
|
whats = append(whats, x.What)
|
|
}
|
|
}
|
|
all := strings.Join(whats, "\n")
|
|
for _, want := range []string{"the last sweep (20261017T120000Z) failed: it broke", "nothing is here"} {
|
|
if !strings.Contains(all, want) {
|
|
t.Errorf("missing problem %q in:\n%s", want, all)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = templateText(t, r)
|
|
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)
|
|
}
|
|
}
|