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.
868 lines
29 KiB
Go
868 lines
29 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"
|
|
"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
|
|
// Settle is how long a path in ~/.ssh is left by the sweep after it changed (sweep.go): zero in tests.
|
|
Settle time.Duration
|
|
}
|
|
|
|
// 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, Settle: time.Minute}
|
|
}
|
|
|
|
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(io.LimitReader(f, 256)).ReadString('\n') // a key's first line is short; a large file's may not be
|
|
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, ", "))
|
|
}
|
|
retired, err := c.Retired()
|
|
if err != nil {
|
|
add("problem", filepath.Join(c.Home, RetiredDir), "cannot be read: %v", err)
|
|
}
|
|
for _, r := range retired {
|
|
for _, m := range r.Moved {
|
|
if m.Missing {
|
|
add("problem", m.To, "the sweep of %s lists %s as moved here, but nothing is here: it may still be in ~/.ssh", r.At, m.From)
|
|
}
|
|
}
|
|
}
|
|
last, err := c.LastSwept()
|
|
switch {
|
|
case err != nil:
|
|
add("problem", filepath.Join(c.Home, LastSweepFile), "cannot be read: %v", err)
|
|
case last == nil:
|
|
notChecked = append(notChecked, "the sweep: it has not run on this account yet")
|
|
case last.Error != "":
|
|
add("problem", dir, "the last sweep (%s) failed: %s", last.At, last.Error)
|
|
}
|
|
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, "retired": retired, "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
|
|
}
|