463 lines
15 KiB
Go
463 lines
15 KiB
Go
package main
|
|
|
|
// The sweep (novox/hq ADR 0415, issue 1017): ssh-client owns ~/.ssh, so it keeps it to what it knows.
|
|
// Every path it does not know is moved, whole, into ~/.local/state/ssh-client/retired/<UTC time>/,
|
|
// keeping its path relative to ~/.ssh. Nothing is deleted. A key (private, public or a certificate),
|
|
// at any depth and whatever its name, and authorized_keys are never moved (design 29 §3: moving one
|
|
// can lock the operator out); a folder holding a key is gone into rather than moved whole. A socket
|
|
// is ssh's own runtime and stays, and so do the files ssh and sshd read by themselves (SSHReads). A
|
|
// path changed in the last Settle is being written (a temporary file of atomicWrite or ssh-keygen) and
|
|
// waits for the next sweep. The process sweeps when it starts and every KeyRound of go/forgekey (main.go); what the
|
|
// last sweep did, or why it failed, is kept beside the retired folder for ssh_client_check (issue 1020).
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
|
)
|
|
|
|
// Declared are the paths under ~/.ssh this module declares (module.json) or its own process makes
|
|
// (keykeeper.go). A test checks the manifest against this list.
|
|
var Declared = map[string]bool{
|
|
"config": true,
|
|
"known_hosts": true,
|
|
"config.d": true,
|
|
"config.d/00-mesh": true,
|
|
"config.d/10-forge": true,
|
|
forgekey.KeyFile: true,
|
|
forgekey.KeyFile + ".pub": true,
|
|
"authorized_keys": true,
|
|
}
|
|
|
|
// SSHReads are the files ssh and sshd read in ~/.ssh by themselves, though this module writes none of
|
|
// them (ssh(1), sshd(8) FILES): moving one loses a trust or a login's setup, or locks the operator out.
|
|
var SSHReads = map[string]bool{
|
|
"authorized_keys2": true, // sshd's second AuthorizedKeysFile by default
|
|
"known_hosts2": true, // ssh's second UserKnownHostsFile by default
|
|
"rc": true, // run by sshd at every login
|
|
"environment": true, // read by sshd at login when PermitUserEnvironment allows
|
|
}
|
|
|
|
// known is whether rel, relative to ~/.ssh, is a path this module declares or one ssh reads itself.
|
|
func known(rel string) bool { return Declared[rel] || SSHReads[rel] }
|
|
|
|
// copyOf is a copy of authorized_keys or known_hosts (authorized_keys.revoked-*, known_hosts.old): it
|
|
// carries key lines but is no key, and sshd and ssh never read it. It is matched on a name, at any depth.
|
|
var copyOf = regexp.MustCompile(`^(authorized_keys|known_hosts)[^/]+$`)
|
|
|
|
// RetiredDir is where the sweep moves what it does not know, under the account's home.
|
|
const RetiredDir = ".local/state/ssh-client/retired"
|
|
|
|
// LastSweepFile is where each sweep writes what it did, or why it failed, under the account's home:
|
|
// the sweep runs in the background, so a failure that is only logged is quiet (issue 1020).
|
|
const LastSweepFile = ".local/state/ssh-client/last-sweep.json"
|
|
|
|
// Moved is one path a sweep moved out of ~/.ssh.
|
|
type Moved struct {
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
// Missing is set by Retired when nothing lies at To: the sweep listed the move before making it and
|
|
// stopped between the two.
|
|
Missing bool `json:"missing,omitempty"`
|
|
}
|
|
|
|
// Sweep is what one sweep did: what it moved, and what it left because it could not tell what it is.
|
|
type Sweep struct {
|
|
At string `json:"at"`
|
|
Moved []Moved `json:"moved"`
|
|
Left []string `json:"left,omitempty"`
|
|
}
|
|
|
|
// LastSweep is what the last sweep did, as LastSweepFile holds it.
|
|
type LastSweep struct {
|
|
At string `json:"at"`
|
|
Moved int `json:"moved"`
|
|
Left []string `json:"left,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// Sweep moves every path under ~/.ssh this module does not know into a retired folder named by now,
|
|
// and writes beside that folder the list of what it moved (<time>.json). It moves nothing when
|
|
// everything is known, and makes nothing then.
|
|
func (c *Client) Sweep(now time.Time) (Sweep, error) {
|
|
dir := c.ssh("")
|
|
s := Sweep{At: now.UTC().Format("20060102T150405Z"), Moved: []Moved{}}
|
|
if _, err := os.Lstat(dir); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return s, nil
|
|
}
|
|
return s, err
|
|
}
|
|
var unknown []string
|
|
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
if p == dir {
|
|
return err
|
|
}
|
|
s.Left = append(s.Left, p) // a folder that cannot be read may hold a key
|
|
return fs.SkipDir
|
|
}
|
|
rel, _ := filepath.Rel(dir, p)
|
|
if rel == "." || known(rel) || d.Type()&fs.ModeSocket != 0 {
|
|
return nil
|
|
}
|
|
if c.young(p, now) {
|
|
if d.IsDir() {
|
|
return fs.SkipDir
|
|
}
|
|
return nil // being written: the next sweep looks again
|
|
}
|
|
switch {
|
|
case d.IsDir():
|
|
if c.holdsKey(p, now) != "" {
|
|
return nil // gone into: the key, or what cannot be told or is being written, stays; the rest around it goes
|
|
}
|
|
unknown = append(unknown, rel)
|
|
return fs.SkipDir // moved whole
|
|
case d.Type()&fs.ModeSymlink != 0:
|
|
// A link to a key, or to a folder holding one, is a key's name: it stays. A link whose target
|
|
// cannot be checked is left (ADR 0415 §5). A link to nothing, or to anything else, is moved as
|
|
// the link, never what it points at.
|
|
switch c.linked(p, now) {
|
|
case "key":
|
|
return nil
|
|
case "unreadable":
|
|
s.Left = append(s.Left, p)
|
|
return nil
|
|
}
|
|
unknown = append(unknown, rel)
|
|
return nil
|
|
case !d.Type().IsRegular():
|
|
unknown = append(unknown, rel)
|
|
return nil
|
|
case copyOf.MatchString(d.Name()):
|
|
unknown = append(unknown, rel)
|
|
return nil
|
|
}
|
|
switch k := keyKind(p); k {
|
|
case "key":
|
|
return nil
|
|
case "unreadable":
|
|
s.Left = append(s.Left, p)
|
|
return nil
|
|
}
|
|
unknown = append(unknown, rel)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
if len(unknown) == 0 {
|
|
return s, nil
|
|
}
|
|
base := filepath.Join(c.Home, RetiredDir)
|
|
if err := sameFileSystem(dir, base); err != nil {
|
|
return s, err // before any folder is made: a sweep that cannot move leaves nothing behind
|
|
}
|
|
if err := os.MkdirAll(base, 0o700); err != nil {
|
|
return s, err
|
|
}
|
|
root, err := c.claim(s.At)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
s.At = filepath.Base(root)
|
|
list := root + ".json"
|
|
// The list names a move before it is made, so a sweep stopped between the two leaves a list that says
|
|
// more, never less, than it did (Retired marks what is not there). A list that cannot be written stops
|
|
// the sweep before the move it would name.
|
|
write := func(moved []Moved) error {
|
|
raw, err := json.MarshalIndent(moved, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := atomicWrite(list, raw, 0o600); err != nil {
|
|
return fmt.Errorf("could not write the list of what the sweep moved, %s: %v", list, err)
|
|
}
|
|
return nil
|
|
}
|
|
defer func() {
|
|
if len(s.Moved) == 0 {
|
|
os.Remove(list)
|
|
removeEmpty(root) // nothing moved, so the sweep leaves no folder
|
|
}
|
|
}()
|
|
for _, rel := range unknown {
|
|
to := filepath.Join(root, rel)
|
|
if err := os.MkdirAll(filepath.Dir(to), 0o700); err != nil {
|
|
return s, err
|
|
}
|
|
if _, err := os.Lstat(to); err == nil {
|
|
return s, fmt.Errorf("could not move %s out of ~/.ssh: %s is already there", rel, to) // never over a kept copy
|
|
}
|
|
m := Moved{From: filepath.Join(dir, rel), To: to}
|
|
if err := write(append(s.Moved, m)); err != nil {
|
|
return s, err
|
|
}
|
|
if err := os.Rename(m.From, to); err != nil {
|
|
if werr := write(s.Moved); werr != nil {
|
|
return s, fmt.Errorf("could not move %s out of ~/.ssh: %v; and %v", rel, err, werr)
|
|
}
|
|
return s, fmt.Errorf("could not move %s out of ~/.ssh: %v", rel, err)
|
|
}
|
|
s.Moved = append(s.Moved, m)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// removeEmpty removes dir and the folders under it, deepest first, as long as each is empty: never a file.
|
|
func removeEmpty(dir string) {
|
|
entries, _ := os.ReadDir(dir)
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
removeEmpty(filepath.Join(dir, e.Name()))
|
|
}
|
|
}
|
|
os.Remove(dir) // fails, and keeps it, when anything is left in it
|
|
}
|
|
|
|
// sameFileSystem refuses a retired folder on another file system than ~/.ssh: a rename cannot cross
|
|
// one, and a copy would leave two of each file for a moment (issue 1020). Before the folder exists, its
|
|
// nearest existing parent is where it would be made.
|
|
func sameFileSystem(dir, base string) error {
|
|
for {
|
|
if _, err := os.Stat(base); !os.IsNotExist(err) || filepath.Dir(base) == base {
|
|
break
|
|
}
|
|
base = filepath.Dir(base)
|
|
}
|
|
a, errA := os.Stat(dir)
|
|
b, errB := os.Stat(base)
|
|
if errA != nil || errB != nil {
|
|
return nil // the moves say what is wrong
|
|
}
|
|
sa, okA := a.Sys().(*syscall.Stat_t)
|
|
sb, okB := b.Sys().(*syscall.Stat_t)
|
|
if okA && okB && sa.Dev != sb.Dev {
|
|
return fmt.Errorf("%s is on another file system than %s, so nothing can be moved there; nothing was moved", base, dir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// young is whether p changed less than c.Settle before now: it may be a file being written, such as
|
|
// atomicWrite's temporary file during ssh_client_revoke or ssh-keygen's during a refresh.
|
|
func (c *Client) young(p string, now time.Time) bool {
|
|
if c.Settle <= 0 {
|
|
return false
|
|
}
|
|
st, err := os.Lstat(p)
|
|
return err == nil && now.Sub(st.ModTime()) < c.Settle
|
|
}
|
|
|
|
// linked says what a symbolic link names: "key" for a key or a folder holding one (or a path being written),
|
|
// "unreadable" when its target, or something in the folder it names, cannot be checked, "" for nothing (a link
|
|
// to nothing) or anything else.
|
|
func (c *Client) linked(p string, now time.Time) string {
|
|
st, err := os.Stat(p)
|
|
if os.IsNotExist(err) {
|
|
return ""
|
|
} else if err != nil {
|
|
return "unreadable"
|
|
}
|
|
if st.IsDir() {
|
|
target, err := filepath.EvalSymlinks(p)
|
|
if err != nil {
|
|
return "unreadable"
|
|
}
|
|
switch c.holdsKey(target, now) {
|
|
case "key", "young":
|
|
return "key"
|
|
case "unreadable":
|
|
return "unreadable"
|
|
}
|
|
return ""
|
|
}
|
|
if !st.Mode().IsRegular() {
|
|
return ""
|
|
}
|
|
return keyKind(p)
|
|
}
|
|
|
|
// claim makes this sweep's own retired folder, named by its time; a folder of that name already there
|
|
// (a sweep in the same second) is never shared: the name takes -2, -3, … instead.
|
|
func (c *Client) claim(at string) (string, error) {
|
|
base := filepath.Join(c.Home, RetiredDir)
|
|
for n := 1; n <= 1000; n++ {
|
|
root := filepath.Join(base, at)
|
|
if n > 1 {
|
|
root = fmt.Sprintf("%s-%d", root, n)
|
|
}
|
|
err := os.Mkdir(root, 0o700)
|
|
if err == nil {
|
|
return root, nil
|
|
}
|
|
if !os.IsExist(err) {
|
|
return "", err
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no free name for the sweep's folder under %s", base)
|
|
}
|
|
|
|
// keyKind says whether a regular file is a key ("key": a private key, a public key or a certificate),
|
|
// cannot be read to tell ("unreadable"), or is neither ("").
|
|
func keyKind(path string) string {
|
|
if st, err := os.Stat(path); err != nil {
|
|
return "unreadable"
|
|
} else if !st.Mode().IsRegular() {
|
|
return "" // a pipe or a device is no key, and opening one can block
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "unreadable"
|
|
}
|
|
f.Close()
|
|
if header(path) || publicKey(path) {
|
|
return "key"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// holdsKey says what a folder holds, at any depth: "key" for a key or a socket, "unreadable" for a path that
|
|
// cannot be read to tell (a folder it cannot read, or a file whose kind it cannot tell), "young" for a path
|
|
// changed less than c.Settle before now (it may be a key being written: ADR 0417 §4), "" for none of these.
|
|
// A key wins over the rest.
|
|
func (c *Client) holdsKey(dir string, now time.Time) string {
|
|
found := ""
|
|
filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
found = "unreadable" // a folder that cannot be read may hold one
|
|
return fs.SkipDir
|
|
}
|
|
rel, _ := filepath.Rel(dir, p)
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
if d.Type()&fs.ModeSocket != 0 {
|
|
found = "key"
|
|
return fs.SkipAll
|
|
}
|
|
if (d.Type().IsRegular() || d.Type()&fs.ModeSymlink != 0) && !copyOf.MatchString(d.Name()) {
|
|
switch keyKind(p) {
|
|
case "key":
|
|
found = "key"
|
|
return fs.SkipAll
|
|
case "unreadable":
|
|
found = "unreadable"
|
|
}
|
|
}
|
|
if found == "" && c.young(p, now) {
|
|
found = "young"
|
|
}
|
|
return nil
|
|
})
|
|
return found
|
|
}
|
|
|
|
// publicKey reads a small file's first line and says whether it is a public key or a certificate.
|
|
func publicKey(path string) bool {
|
|
st, err := os.Stat(path)
|
|
if err != nil || st.Size() > 16<<10 {
|
|
return false
|
|
}
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
line, _, _ := strings.Cut(string(raw), "\n")
|
|
if _, err := forgekey.ParseKeyLine(line); err == nil {
|
|
return true
|
|
}
|
|
f := strings.Fields(line)
|
|
return len(f) > 1 && strings.HasSuffix(f[0], "-cert-v01@openssh.com")
|
|
}
|
|
|
|
// Retired lists the sweeps that moved something, newest first, from the lists each sweep wrote.
|
|
func (c *Client) Retired() ([]Sweep, error) {
|
|
root := filepath.Join(c.Home, RetiredDir)
|
|
entries, err := os.ReadDir(root)
|
|
if os.IsNotExist(err) {
|
|
return []Sweep{}, nil
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
out := []Sweep{}
|
|
for _, e := range entries {
|
|
at, ok := strings.CutSuffix(e.Name(), ".json")
|
|
if !ok || e.IsDir() {
|
|
continue
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(root, e.Name()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := Sweep{At: at}
|
|
if err := json.Unmarshal(raw, &s.Moved); err != nil {
|
|
return nil, fmt.Errorf("%s: %v", filepath.Join(root, e.Name()), err)
|
|
}
|
|
for i := range s.Moved {
|
|
if _, err := os.Lstat(s.Moved[i].To); err != nil {
|
|
s.Moved[i].Missing = true
|
|
}
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
sort.Slice(out, func(a, b int) bool { return out[a].At > out[b].At })
|
|
return out, nil
|
|
}
|
|
|
|
// SweepOnce sweeps, says each move, each path left, and a failure through say, and writes what it did to
|
|
// LastSweepFile, so ssh_client_check shows a failure the log alone would keep quiet.
|
|
func (c *Client) SweepOnce(now time.Time, say func(string, ...any)) {
|
|
s, err := c.Sweep(now)
|
|
for _, m := range s.Moved {
|
|
say("moved %s, which this module does not know, to %s", m.From, m.To)
|
|
}
|
|
for _, l := range s.Left {
|
|
say("left %s: it cannot be read to tell whether it is a key", l)
|
|
}
|
|
last := LastSweep{At: now.UTC().Format("20060102T150405Z"), Moved: len(s.Moved), Left: s.Left}
|
|
if err != nil {
|
|
say("the sweep of ~/.ssh: %v", err)
|
|
last.Error = err.Error()
|
|
}
|
|
if werr := c.writeLastSweep(last); werr != nil {
|
|
say("could not keep what the sweep did: %v", werr)
|
|
}
|
|
}
|
|
|
|
func (c *Client) writeLastSweep(last LastSweep) error {
|
|
p := filepath.Join(c.Home, LastSweepFile)
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
return err
|
|
}
|
|
raw, err := json.MarshalIndent(last, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return atomicWrite(p, raw, 0o600)
|
|
}
|
|
|
|
// LastSwept is what the last sweep did, or nil when no sweep has run on this account yet.
|
|
func (c *Client) LastSwept() (*LastSweep, error) {
|
|
raw, err := os.ReadFile(filepath.Join(c.Home, LastSweepFile))
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
var last LastSweep
|
|
if err := json.Unmarshal(raw, &last); err != nil {
|
|
return nil, fmt.Errorf("%s: %v", filepath.Join(c.Home, LastSweepFile), err)
|
|
}
|
|
return &last, nil
|
|
}
|