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.
845 lines
28 KiB
Go
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
|
|
}
|