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 }