mesh/merge-gate pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/361-the-setuid-search-runs-to-completion delivered: every member is delivered
The review found a cursor of a name that is not UTF-8 came back changed from JSON, so a resumed walk skipped what it stood before; an ask to search again was lost to a restart, which carried the older walk on; a fresh search judged a filesystem it never walked; a mount point root cannot read failed or hung the walk; a found file gone before the package manager was asked failed every resume; and skipped or kept outcomes started a search on every reconcile, back to back with a long walk.
464 lines
17 KiB
Go
464 lines
17 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, on a thread of its own at idle priority (Lower):
|
|
// 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.
|
|
// - **Resumable**: the walk's progress is kept in ProgressPath every CheckpointEvery, and a search started
|
|
// again — after an engine restart, an update, a reboot or a failure — carries on from it while it is
|
|
// younger than FreshFor. A search SearchAgain asked for never resumes an older walk: it starts from nothing.
|
|
// - **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, and a resumed walk is sound, is said in the rootsearch
|
|
// package and setuid_walk.go: 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, and ProgressPath the walk under way; empty keeps
|
|
// either in memory only. The engine's own state directory, which only root writes: a file there that is
|
|
// not a regular file the engine's account owns and alone may write is not read (keptFile).
|
|
Path, ProgressPath string
|
|
// Lower is called on the search's own goroutine before it walks (LowerThisThread on a serving engine); a
|
|
// priority it cannot lower fails the search rather than walk the disks at full priority. Nil lowers nothing.
|
|
Lower func() error
|
|
// Say is where a search's end and a kept file refused are said; nil says nothing.
|
|
Say func(string)
|
|
|
|
mu sync.Mutex
|
|
wg sync.WaitGroup
|
|
loaded bool
|
|
last *setuidResult // the last complete search
|
|
unfinished *SetuidProgress // a walk that did not finish, to resume
|
|
running bool
|
|
startedAt time.Time // when the search running, or the last one, was started (a resumed one: its start)
|
|
attemptAt time.Time // when the running or last search was started or resumed
|
|
endedAt time.Time // when the last search ended, on the same clock
|
|
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 or resumed, 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 Paths `json:"mounts"`
|
|
Found Paths `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 and SetuidProgressFile are the files' names beside the engine's state: the last
|
|
// complete search, and the walk under way.
|
|
SetuidSearchFile = "setuid-search.json"
|
|
SetuidProgressFile = "setuid-search-progress.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
|
|
// The walk under way, or kept, started before root acted: never carried on, even by an engine that
|
|
// restarts before the next search starts (the review of 2026-10-10). It keeps no progress from now on.
|
|
c.unfinished = nil
|
|
c.forgetProgress()
|
|
}
|
|
|
|
// SetuidSearch is one search: it carries p on to its end — walking what p has not walked, calling checkpoint
|
|
// as it goes so its progress can be kept — and answers the setuid-root programs no package owns.
|
|
type SetuidSearch func(ctx context.Context, p *SetuidProgress, checkpoint func()) ([]string, error)
|
|
|
|
// 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 new search walks. 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 SetuidSearch) ([]string, time.Time, error) {
|
|
if c == nil {
|
|
p := &SetuidProgress{Started: now, Mounts: slices.Clone(mounts)}
|
|
found, err := search(context.Background(), p, func() {})
|
|
return found, now, err
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if !c.loaded {
|
|
c.loaded = true
|
|
c.last = c.loadResult()
|
|
c.unfinished = c.loadProgress()
|
|
}
|
|
var age time.Duration
|
|
if c.last != nil {
|
|
age = now.Sub(c.last.Started)
|
|
}
|
|
// Fresh only while it walked every filesystem a search would walk now: one mounted since is not judged
|
|
// by a search that never saw it.
|
|
fresh := c.last != nil && age > -clockSkew && age < c.freshFor() && covers(c.last.Mounts, mounts)
|
|
// Asked for (an apply, a new filesystem): at most once an AgainAfter, counted from when the last search
|
|
// ended, so a walk of hours is not followed by another at once, apply after apply.
|
|
gap := now.Sub(c.attemptAt)
|
|
if !c.endedAt.IsZero() {
|
|
gap = now.Sub(c.endedAt)
|
|
}
|
|
due := !c.running && (c.failed == nil || now.Sub(c.failedAt) >= c.backoff) &&
|
|
(!fresh || age >= c.every() || (c.again && (c.attemptAt.IsZero() || gap >= 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
|
|
}
|
|
|
|
// resumable is the walk that did not finish, when a search may carry it on: younger than FreshFor, and no
|
|
// SearchAgain since — an apply that moved root's things wants a walk that starts after it. Anything else is
|
|
// thrown away. c.mu held.
|
|
func (c *SetuidCache) resumable(now time.Time) *SetuidProgress {
|
|
p := c.unfinished
|
|
c.unfinished = nil
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
age := now.Sub(p.Started)
|
|
if c.again || age <= -clockSkew || age >= c.freshFor() {
|
|
c.forgetProgress()
|
|
return nil
|
|
}
|
|
return p
|
|
}
|
|
|
|
// start runs one search in the background, with no bound: a walk that did not finish carried on, or a new one.
|
|
// c.mu held.
|
|
func (c *SetuidCache) start(now time.Time, mounts []string, search SetuidSearch) {
|
|
p := c.resumable(now)
|
|
resumed := p != nil
|
|
if p == nil {
|
|
p = &SetuidProgress{ID: fmt.Sprintf("%x", now.UnixNano()), Started: now, Mounts: slices.Clone(mounts),
|
|
Cursor: map[string]string{}}
|
|
}
|
|
c.running, c.startedAt, c.attemptAt, c.again = true, p.Started, now, false
|
|
c.starts++
|
|
if resumed {
|
|
c.say(fmt.Sprintf("the search for setuid programs started %s carries on where it was (%d of %d "+
|
|
"filesystems walked)", p.Started.Local().Format("2006-01-02 15:04"), len(p.Done), len(p.Mounts)))
|
|
}
|
|
began := time.Now()
|
|
c.wg.Add(1)
|
|
go func() {
|
|
defer c.wg.Done()
|
|
var found []string
|
|
var err error
|
|
if c.Lower != nil {
|
|
// The goroutine stays on its lowered thread until it ends, and the thread ends with it.
|
|
if lerr := c.Lower(); lerr != nil {
|
|
err = fmt.Errorf("its priority could not be lowered, so it did not walk: %w", lerr)
|
|
}
|
|
}
|
|
if err == nil {
|
|
unkept := false
|
|
found, err = search(context.Background(), p, func() {
|
|
c.mu.Lock()
|
|
superseded := c.again
|
|
c.mu.Unlock()
|
|
if superseded {
|
|
return // root acted since this walk started: its progress is not to be carried on
|
|
}
|
|
if kerr := c.keepProgress(p); kerr != nil && !unkept {
|
|
unkept = true
|
|
c.say("the search for setuid programs could not keep how far it got, and starts over if " +
|
|
"the engine stops: " + kerr.Error())
|
|
}
|
|
})
|
|
}
|
|
took := time.Since(began)
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.running = false
|
|
c.endedAt = now.Add(took)
|
|
if err != nil {
|
|
// The back-off counts from when it failed.
|
|
c.failed, c.failedAt = err, c.endedAt
|
|
c.backoff = min(max(2*c.backoff, FailedBackoff), MaxBackoff)
|
|
if !c.again { // superseded by an ask to search again: never carried on
|
|
c.unfinished = p
|
|
}
|
|
c.say(fmt.Sprintf("the search for setuid programs started %s did not complete (%s this time): %v",
|
|
p.Started.Local().Format("2006-01-02 15:04"), took.Round(time.Second), err))
|
|
return
|
|
}
|
|
r := &setuidResult{Started: p.Started, Finished: now.Add(took), Mounts: p.Mounts, Found: found}
|
|
c.last, c.failed, c.backoff = r, nil, 0
|
|
if err := c.keepResult(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.forgetProgress()
|
|
c.say(fmt.Sprintf("the search for setuid programs started %s completed (%s this time): %d no package "+
|
|
"owns", p.Started.Local().Format("2006-01-02 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() }
|
|
|
|
func (c *SetuidCache) keepResult(r *setuidResult) error { return keepFile(c.Path, r) }
|
|
|
|
func (c *SetuidCache) keepProgress(p *SetuidProgress) error { return keepFile(c.ProgressPath, p) }
|
|
|
|
// forgetProgress removes the kept walk: finished, or thrown away.
|
|
func (c *SetuidCache) forgetProgress() {
|
|
if c.ProgressPath == "" {
|
|
return
|
|
}
|
|
if err := os.Remove(c.ProgressPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
c.say("the search for setuid programs could not forget the walk it kept: " + err.Error())
|
|
}
|
|
}
|
|
|
|
// keepFile writes v to path: a new file beside it, readable by its owner alone, renamed over it — a link at
|
|
// path is replaced, never followed. Empty path keeps nothing.
|
|
func keepFile(path string, v any) error {
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
raw, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(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(), path)
|
|
}
|
|
|
|
// loadResult reads the kept complete search, or nil: none kept, or one that cannot be believed, said why.
|
|
func (c *SetuidCache) loadResult() *setuidResult {
|
|
var r setuidResult
|
|
if !c.loadFile(c.Path, "complete search", &r) {
|
|
return nil
|
|
}
|
|
if r.Started.IsZero() || r.Finished.Before(r.Started) || len(r.Mounts) == 0 {
|
|
c.say("the kept search for setuid programs says no complete search, and a new one runs")
|
|
return nil
|
|
}
|
|
return &r
|
|
}
|
|
|
|
// loadProgress reads the kept walk, or nil.
|
|
func (c *SetuidCache) loadProgress() *SetuidProgress {
|
|
var p SetuidProgress
|
|
if !c.loadFile(c.ProgressPath, "walk under way", &p) {
|
|
return nil
|
|
}
|
|
if p.Started.IsZero() || len(p.Mounts) == 0 {
|
|
c.say("the kept walk for setuid programs says no walk, and it starts over")
|
|
c.forgetProgress()
|
|
return nil
|
|
}
|
|
if p.Cursor == nil {
|
|
p.Cursor = map[string]string{}
|
|
}
|
|
return &p
|
|
}
|
|
|
|
func (c *SetuidCache) loadFile(path, what string, v any) bool {
|
|
if path == "" {
|
|
return false
|
|
}
|
|
raw, err := keptFile(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return false
|
|
}
|
|
if err == nil {
|
|
if err = json.Unmarshal(raw, v); err != nil {
|
|
err = fmt.Errorf("%s is not JSON: %w", path, err)
|
|
}
|
|
}
|
|
if err != nil {
|
|
c.say(fmt.Sprintf("the kept %s for setuid programs is not read, and a new one runs: %v", what, err))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// keptFile reads a kept file, believing it only as a regular file, not followed through a link, owned by the
|
|
// engine's own account and writable by it alone, in a directory that is the same: anything else could have been
|
|
// written by somebody other than root, and a search it says is no search.
|
|
func keptFile(path string) ([]byte, 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
|
|
}
|
|
return io.ReadAll(io.LimitReader(f, 64<<20))
|
|
}
|
|
|
|
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
|
|
}
|