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-group group fix/361-the-setuid-search-runs-to-completion ready: every member ready, and composed together they pass
mesh/delivery superseded: a newer head of the same pull request
The control-node's walk takes far longer than the engine runs between updates, so a search that starts over at every restart never completes. The walk is the engine's own, in lexical order, so its progress is one cursor per filesystem, kept every minute beside the engine's state and carried on after a restart while younger than rootsearch.FreshFor. It runs on a thread of its own at nice 19 and the idle I/O class.
195 lines
6.5 KiB
Go
195 lines
6.5 KiB
Go
package accounts
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// The walk for setuid programs, resumable (novox/hq issue 361).
|
|
//
|
|
// The walk is the engine's own, in Go rather than find, so it can say how far it got: filepath.WalkDir visits
|
|
// every filesystem's tree depth first, each directory's entries in lexical order, so the walk's progress is one
|
|
// cursor per filesystem — the last path it visited — and everything before the cursor in that order is done.
|
|
// The progress is kept every CheckpointEvery beside the engine's state (SetuidCache), so an engine restarted,
|
|
// updated or rebooted midway carries on from its cursor rather than starting over: on the control-node the
|
|
// whole walk takes far longer than an engine runs between updates.
|
|
//
|
|
// **Why a resumed walk is sound.** Only root can make a setuid- or setgid-root file: the agent account can
|
|
// neither set the bit on root's file nor give its own file to root. A file that appears behind the cursor while
|
|
// the walk runs, or while it waits to be resumed, was made by root — and root's acts through the mesh start a
|
|
// new search (apply.MovesRoot), whose walk starts from nothing. A walk is dated by its start, and one resumed
|
|
// more than FreshFor after its start is thrown away and begun again.
|
|
|
|
// SetuidProgress is one walk, as far as it got.
|
|
type SetuidProgress struct {
|
|
// ID names the walk; Started is when it began, on the cache's clock: the search is dated by it.
|
|
ID string `json:"id"`
|
|
Started time.Time `json:"started"`
|
|
// Mounts are the filesystems it walks, root first; Done those walked to their end.
|
|
Mounts []string `json:"mounts"`
|
|
Done []string `json:"done"`
|
|
// Cursor is, for a filesystem being walked, the last path visited.
|
|
Cursor map[string]string `json:"cursor"`
|
|
// Found is every setuid- or setgid-root regular file found so far, before the package manager is asked.
|
|
Found []string `json:"found"`
|
|
}
|
|
|
|
// CheckpointEvery is how often a walk's progress is kept.
|
|
const CheckpointEvery = time.Minute
|
|
|
|
// prunedPaths are never walked: the kernel's own trees, and the container runtimes' layers.
|
|
var prunedPaths = []string{"/proc", "/sys", "/var/lib/docker", "/var/lib/containers"}
|
|
|
|
// walker walks for setuid programs. rootUID and rootGID are the owner and group a match has: root's (0), but
|
|
// for a test.
|
|
type walker struct {
|
|
rootUID, rootGID uint32
|
|
every time.Duration
|
|
}
|
|
|
|
// walk carries p on to its end, calling checkpoint every w.every and after each filesystem. It ends early only
|
|
// with an error — a directory it could not read, or ctx ended — which is an incomplete walk, never "none".
|
|
// A file or directory that vanished while it walked is not an error: what still exists was walked.
|
|
func (w walker) walk(ctx context.Context, p *SetuidProgress, checkpoint func()) error {
|
|
if p.Cursor == nil {
|
|
p.Cursor = map[string]string{}
|
|
}
|
|
last := time.Now()
|
|
for _, m := range p.Mounts {
|
|
if slices.Contains(p.Done, m) {
|
|
continue
|
|
}
|
|
if err := w.walkMount(ctx, p, m, checkpoint, &last); err != nil {
|
|
return err
|
|
}
|
|
p.Done = append(p.Done, m)
|
|
delete(p.Cursor, m)
|
|
checkpoint()
|
|
last = time.Now()
|
|
}
|
|
slices.Sort(p.Found)
|
|
p.Found = slices.Compact(p.Found)
|
|
return nil
|
|
}
|
|
|
|
func (w walker) walkMount(ctx context.Context, p *SetuidProgress, mount string, checkpoint func(), last *time.Time) error {
|
|
top, err := os.Lstat(mount)
|
|
if err != nil {
|
|
return fmt.Errorf("the filesystem at %s could not be read: %w", mount, err)
|
|
}
|
|
dev, ok := deviceOf(top)
|
|
if !ok || !top.IsDir() {
|
|
return fmt.Errorf("the filesystem at %s is not a directory whose device could be read", mount)
|
|
}
|
|
var cursor []string
|
|
if c := p.Cursor[mount]; c != "" {
|
|
cursor = components(c)
|
|
}
|
|
return filepath.WalkDir(mount, func(path string, d fs.DirEntry, err error) error {
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return ctxErr
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil // vanished while it walked
|
|
}
|
|
return fmt.Errorf("the walk for setuid programs could not read %s: %w", path, err)
|
|
}
|
|
at := components(path)
|
|
if cursor != nil && walkOrder(at, cursor) < 0 && !isPrefix(at, cursor) {
|
|
// Before the cursor and not on its way: visited by the walk this resumes.
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
switch {
|
|
case d.IsDir():
|
|
if path != mount && slices.Contains(prunedPaths, path) {
|
|
return filepath.SkipDir
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return filepath.SkipDir
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("the walk for setuid programs could not read %s: %w", path, err)
|
|
}
|
|
if d, ok := deviceOf(info); !ok || d != dev {
|
|
return filepath.SkipDir // another filesystem, walked as its own or not at all (find's -xdev)
|
|
}
|
|
case d.Type().IsRegular():
|
|
info, err := d.Info() // the entry's own, never through a link
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("the walk for setuid programs could not read %s: %w", path, err)
|
|
}
|
|
if w.setuidRoot(info) {
|
|
p.Found = append(p.Found, path)
|
|
}
|
|
}
|
|
// The cursor only moves on: the directories on the way back to it are visited again, and are before it.
|
|
if cursor == nil || walkOrder(at, cursor) > 0 {
|
|
p.Cursor[mount], cursor = path, at
|
|
}
|
|
if time.Since(*last) >= w.every {
|
|
checkpoint()
|
|
*last = time.Now()
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// setuidRoot is a regular file setuid and owned by root, or setgid to root's group.
|
|
func (w walker) setuidRoot(info fs.FileInfo) bool {
|
|
st, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok || !info.Mode().IsRegular() {
|
|
return false
|
|
}
|
|
return uint32(st.Uid) == w.rootUID && info.Mode()&fs.ModeSetuid != 0 ||
|
|
uint32(st.Gid) == w.rootGID && info.Mode()&fs.ModeSetgid != 0
|
|
}
|
|
|
|
func deviceOf(info fs.FileInfo) (uint64, bool) {
|
|
st, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return uint64(st.Dev), true
|
|
}
|
|
|
|
// components are a path's names, in order; "/" has none.
|
|
func components(path string) []string {
|
|
path = strings.Trim(filepath.Clean(path), "/")
|
|
if path == "" {
|
|
return nil
|
|
}
|
|
return strings.Split(path, "/")
|
|
}
|
|
|
|
// walkOrder compares two paths in the order WalkDir visits them: name by name, each in lexical order, a
|
|
// directory before what is in it.
|
|
func walkOrder(a, b []string) int {
|
|
for i := 0; i < len(a) && i < len(b); i++ {
|
|
if c := strings.Compare(a[i], b[i]); c != 0 {
|
|
return c
|
|
}
|
|
}
|
|
return len(a) - len(b)
|
|
}
|
|
|
|
// isPrefix says a is b or a directory b is in.
|
|
func isPrefix(a, b []string) bool {
|
|
return len(a) <= len(b) && slices.Equal(a, b[:len(a)])
|
|
}
|