The region at the end let earlier Host lines win over the mesh's (research 027/03). A roster fact cannot be placed at the start, so the region holds one Include of config.d, and the hosts are config.d/00-mesh, read first. Eight tools; authorized_keys and known_hosts stay found until the controller holds those facts.
410 lines
16 KiB
Go
410 lines
16 KiB
Go
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)
|
|
}
|
|
}
|