A machine with millions of files never finished the walk within fifteen minutes, so its agent account was never judged and the router refused to link. Only root can make a setuid-root file, so a complete search stays sound until root acts: it now runs at idle priority with no bound, is kept beside the engine's state, judges for rootsearch.FreshFor from its start, runs again daily and after an apply that moves accounts, packages, actions or sudo rules, and an incomplete or stale search is never free.
339 lines
12 KiB
Go
339 lines
12 KiB
Go
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-host/rootsearch"
|
|
)
|
|
|
|
// SetuidCache keeps the search for setuid programs, which walks every local filesystem, and runs it in the
|
|
// background (novox/hq ADR 0266, issue 361): a look never waits for it, and the search itself is never cut
|
|
// short.
|
|
//
|
|
// - **A verdict comes only from a complete search, while it is fresh**: one that walked everything and had the
|
|
// package manager answer for every path found, started less than FreshFor ago. Before the first completes,
|
|
// or once the last is older, the answer is "not judged yet" (ReasonPending) while a search runs, and "not
|
|
// judged" (ReasonIncomplete) after one failed. Never "none found" from a search that did not finish.
|
|
// - **It runs to completion**, once at a time, with no bound: a fresh one when the last complete one is Every
|
|
// old, when SearchAgain asks (an apply changed what root controls), or when a filesystem appeared that the
|
|
// last one did not walk. One that fails is tried again after a back-off that doubles from FailedBackoff up
|
|
// to MaxBackoff.
|
|
// - **Kept** in Path, beside the engine's other state, with when it started and finished: an engine restart
|
|
// serves the last complete search rather than judging nothing until a new walk ends.
|
|
//
|
|
// Why a complete search stays sound for a day and more is said in the rootsearch package: only root makes a
|
|
// setuid-root file, never the agent account.
|
|
type SetuidCache struct {
|
|
// Every and FreshFor are rootsearch's when zero.
|
|
Every, FreshFor time.Duration
|
|
// Path is the file the last complete search is kept in; empty keeps it in memory only. The engine's own
|
|
// state directory, which only root writes: a file there that is not a regular file root owns and alone
|
|
// may write is not read (keptSearch).
|
|
Path string
|
|
// Say is where a search's end and a kept search refused are said; nil says nothing.
|
|
Say func(string)
|
|
|
|
mu sync.Mutex
|
|
wg sync.WaitGroup
|
|
loaded bool
|
|
last *setuidResult // the last complete search
|
|
running bool
|
|
startedAt time.Time // when the search running, or the last one, was started
|
|
again bool // asked to search again since the running or last one started
|
|
failedAt time.Time
|
|
failed error
|
|
backoff time.Duration
|
|
starts int // how many searches were started, for a test
|
|
}
|
|
|
|
// setuidResult is one complete search, as kept.
|
|
type setuidResult struct {
|
|
// Started and Finished are on the caller's clock: Finished is Started plus how long the search took.
|
|
Started time.Time `json:"started"`
|
|
Finished time.Time `json:"finished"`
|
|
// Mounts are the filesystems it walked; Found the setuid-root programs no package owns.
|
|
Mounts []string `json:"mounts"`
|
|
Found []string `json:"found"`
|
|
}
|
|
|
|
const (
|
|
// FailedBackoff is the first wait after a search failed, doubling to MaxBackoff.
|
|
FailedBackoff = time.Hour
|
|
MaxBackoff = 6 * time.Hour
|
|
// AgainAfter is the least time between the starts of two searches that SearchAgain or a new filesystem
|
|
// asked for: a run of applies does not walk the disks back to back.
|
|
AgainAfter = time.Hour
|
|
// clockSkew is how far in the future a kept search may say it started and still judge: a clock set back
|
|
// further makes it no verdict, never a fresh one.
|
|
clockSkew = 5 * time.Minute
|
|
// SetuidSearchFile is the file's name beside the engine's state.
|
|
SetuidSearchFile = "setuid-search.json"
|
|
)
|
|
|
|
// SetuidNotJudged is the search's answer when no complete search judges: none has finished yet, the last one is
|
|
// older than FreshFor, or every one since failed. The judge says the way is not judged, never that there is none.
|
|
type SetuidNotJudged struct {
|
|
// Pending is true while a search runs; false after one failed.
|
|
Pending bool
|
|
Since time.Time
|
|
Err error
|
|
// Last is when the last complete search started, when there is one too old to judge.
|
|
Last time.Time
|
|
}
|
|
|
|
func (e *SetuidNotJudged) Error() string {
|
|
stale := ""
|
|
if !e.Last.IsZero() {
|
|
stale = fmt.Sprintf("; the last complete one, started %s, is too old to judge", e.Last.Local().Format("2006-01-02 15:04"))
|
|
}
|
|
if e.Pending {
|
|
return fmt.Sprintf("%s: the search for setuid programs, started %s, has not finished yet%s", ReasonPending,
|
|
e.Since.Local().Format("2006-01-02 15:04"), stale)
|
|
}
|
|
return fmt.Sprintf("%s: the search for setuid programs did not complete (last tried %s: %v); it is tried "+
|
|
"again later%s", ReasonIncomplete, e.Since.Local().Format("2006-01-02 15:04"), e.Err, stale)
|
|
}
|
|
|
|
// The reasons of a root verdict the setuid search could not answer.
|
|
const (
|
|
ReasonPending = rootsearch.ReasonPending
|
|
ReasonIncomplete = rootsearch.ReasonIncomplete
|
|
)
|
|
|
|
func (c *SetuidCache) every() time.Duration {
|
|
if c.Every > 0 {
|
|
return c.Every
|
|
}
|
|
return rootsearch.Every
|
|
}
|
|
|
|
func (c *SetuidCache) freshFor() time.Duration {
|
|
if c.FreshFor > 0 {
|
|
return c.FreshFor
|
|
}
|
|
return rootsearch.FreshFor
|
|
}
|
|
|
|
// SearchAgain asks for a new search at the next look: an apply changed what root controls (an account, a group,
|
|
// a sudo rule, a package, an action). The last complete search keeps judging until it is too old: only root acts
|
|
// through an apply, and the agent account cannot make a setuid-root file. Nil c is nothing to ask.
|
|
func (c *SetuidCache) SearchAgain() {
|
|
if c == nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.again = true
|
|
}
|
|
|
|
// Look answers the setuid-root programs no package owns as the last complete search found them, and when it
|
|
// started — while it is fresh — and starts a search in the background when one is due; it never waits for one.
|
|
// mounts are the filesystems a search walks now. Nil c searches in place, for a caller that wants to.
|
|
//
|
|
// **One clock: the caller's.** A search's time is the `now` it was started at, never the wall clock read when it
|
|
// finished: due-ness compares a later `now` with it, and mixing the two clocks made a search never due again
|
|
// whenever the caller's clock ran behind the wall clock (found while checking issue 356). Measured from the
|
|
// start, a result reads at most one search older than it is, which errs towards not judging.
|
|
func (c *SetuidCache) Look(now time.Time, mounts []string,
|
|
search func(ctx context.Context, mounts []string) ([]string, error)) ([]string, time.Time, error) {
|
|
if c == nil {
|
|
found, err := search(context.Background(), mounts)
|
|
return found, now, err
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if !c.loaded {
|
|
c.loaded = true
|
|
c.last = c.load()
|
|
}
|
|
var age time.Duration
|
|
if c.last != nil {
|
|
age = now.Sub(c.last.Started)
|
|
}
|
|
fresh := c.last != nil && age > -clockSkew && age < c.freshFor()
|
|
asked := c.again || (c.last != nil && !covers(c.last.Mounts, mounts))
|
|
due := !c.running && (c.failed == nil || now.Sub(c.failedAt) >= c.backoff) &&
|
|
(!fresh || age >= c.every() || (asked && (c.startedAt.IsZero() || now.Sub(c.startedAt) >= AgainAfter)))
|
|
if due {
|
|
c.start(now, mounts, search)
|
|
}
|
|
switch {
|
|
case fresh:
|
|
return slices.Clone(c.last.Found), c.last.Started, nil
|
|
case c.running:
|
|
return nil, time.Time{}, &SetuidNotJudged{Pending: true, Since: c.startedAt, Last: c.lastStarted()}
|
|
case c.failed != nil:
|
|
return nil, time.Time{}, &SetuidNotJudged{Since: c.failedAt, Err: c.failed, Last: c.lastStarted()}
|
|
default:
|
|
// Not fresh, not running, never failed: cannot be, as not fresh is due. Never a verdict.
|
|
return nil, time.Time{}, &SetuidNotJudged{Pending: true, Since: now, Last: c.lastStarted()}
|
|
}
|
|
}
|
|
|
|
func (c *SetuidCache) lastStarted() time.Time {
|
|
if c.last == nil {
|
|
return time.Time{}
|
|
}
|
|
return c.last.Started
|
|
}
|
|
|
|
// start runs one search in the background, with no bound: c.mu held.
|
|
func (c *SetuidCache) start(now time.Time, mounts []string, search func(context.Context, []string) ([]string, error)) {
|
|
c.running, c.startedAt, c.again = true, now, false
|
|
c.starts++
|
|
mounts = slices.Clone(mounts)
|
|
began := time.Now()
|
|
c.wg.Add(1)
|
|
go func() {
|
|
defer c.wg.Done()
|
|
found, err := search(context.Background(), mounts)
|
|
took := time.Since(began)
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.running = false
|
|
if err != nil {
|
|
c.failed, c.failedAt = err, now
|
|
c.backoff = min(max(2*c.backoff, FailedBackoff), MaxBackoff)
|
|
c.say(fmt.Sprintf("the search for setuid programs started %s did not complete after %s: %v",
|
|
now.Local().Format("15:04"), took.Round(time.Second), err))
|
|
return
|
|
}
|
|
r := &setuidResult{Started: now, Finished: now.Add(took), Mounts: mounts, Found: found}
|
|
c.last, c.failed, c.backoff = r, nil, 0
|
|
if err := c.keep(r); err != nil {
|
|
c.say("the search for setuid programs could not be kept, and judges from memory until the engine " +
|
|
"restarts: " + err.Error())
|
|
}
|
|
c.say(fmt.Sprintf("the search for setuid programs started %s completed in %s: %d no package owns",
|
|
now.Local().Format("15:04"), took.Round(time.Second), len(found)))
|
|
}()
|
|
}
|
|
|
|
func (c *SetuidCache) say(s string) {
|
|
if c.Say != nil {
|
|
c.Say(s)
|
|
}
|
|
}
|
|
|
|
// covers says every filesystem a search would walk now was walked.
|
|
func covers(walked, now []string) bool {
|
|
for _, m := range now {
|
|
if !slices.Contains(walked, m) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Wait is for a test: until the search running, if any, has finished.
|
|
func (c *SetuidCache) Wait() { c.wg.Wait() }
|
|
|
|
// keep writes a complete search to Path: a new file beside it, readable by root alone, renamed over it.
|
|
func (c *SetuidCache) keep(r *setuidResult) error {
|
|
if c.Path == "" {
|
|
return nil
|
|
}
|
|
raw, err := json.Marshal(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := os.CreateTemp(filepath.Dir(c.Path), "."+filepath.Base(c.Path)+".*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(f.Name())
|
|
if _, err := f.Write(raw); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
f.Close()
|
|
return err
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(f.Name(), c.Path)
|
|
}
|
|
|
|
// load reads the kept search, or nil: none kept, or one that cannot be believed, said why.
|
|
func (c *SetuidCache) load() *setuidResult {
|
|
if c.Path == "" {
|
|
return nil
|
|
}
|
|
r, err := keptSearch(c.Path)
|
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
c.say("the kept search for setuid programs is not read, and a new one runs: " + err.Error())
|
|
}
|
|
return r
|
|
}
|
|
|
|
// keptSearch reads a kept search, believing it only as a regular file, not followed through a link, owned by
|
|
// the engine's own account and writable by it alone, whose directory is the same: anything else could have been
|
|
// written by somebody other than root, and a search it says is no search.
|
|
func keptSearch(path string) (*setuidResult, error) {
|
|
if err := ownedAlone(filepath.Dir(path), true); err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ownedAloneInfo(path, info, false); err != nil {
|
|
return nil, err
|
|
}
|
|
raw, err := io.ReadAll(io.LimitReader(f, 1<<20))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var r setuidResult
|
|
if err := json.Unmarshal(raw, &r); err != nil {
|
|
return nil, fmt.Errorf("%s is not a kept search: %w", path, err)
|
|
}
|
|
if r.Started.IsZero() || r.Finished.Before(r.Started) || len(r.Mounts) == 0 {
|
|
return nil, fmt.Errorf("%s says no complete search", path)
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
func ownedAlone(path string, dir bool) error {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return ownedAloneInfo(path, info, dir)
|
|
}
|
|
|
|
func ownedAloneInfo(path string, info fs.FileInfo, dir bool) error {
|
|
if dir && !info.IsDir() || !dir && !info.Mode().IsRegular() {
|
|
return fmt.Errorf("%s is not a plain %s", path, map[bool]string{true: "directory", false: "file"}[dir])
|
|
}
|
|
st, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return fmt.Errorf("%s's owner could not be read", path)
|
|
}
|
|
if int(st.Uid) != os.Geteuid() {
|
|
return fmt.Errorf("%s is owned by uid %d, not the engine's %d", path, st.Uid, os.Geteuid())
|
|
}
|
|
if info.Mode().Perm()&0o022 != 0 {
|
|
return fmt.Errorf("%s may be written by others than its owner (mode %o)", path, info.Mode().Perm())
|
|
}
|
|
return nil
|
|
}
|