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 }