Files
mesh-catalog/modules/ssh-client/cmd/ssh-client-tools/client.go
T
jochen add923c74a ssh-client: the mesh's region first in ~/.ssh/config, its hosts in config.d, tools in Go
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.
2026-10-04 12:37:44 +02:00

845 lines
28 KiB
Go

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
}