//go:build linux package host import ( "context" "errors" "fmt" "io/fs" "os" "path/filepath" "time" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/rootstate" "golang.org/x/sys/unix" ) // census checks the whole staged namespace, including unreserved builds. This // verifier accepts running tenants only with root-retained launch containment. // Other service tasks and unexplained namespaces still refuse completeness. func (k generationKernel) census(ctx context.Context, tree string) (string, string, error) { before, err := k.boot() if err != nil { return "", "", err } f, err := os.Open(tree) if err != nil { return "", "", err } id, err := rootstate.Identity(f) f.Close() if err != nil { return "", "", err } r := release.GenerationReservation{Binding: release.GenerationBinding{Tree: tree, Path: tree, TreeIdentity: id, Executable: id}} digest, err := k.absent(ctx, r) if err != nil { return "", "", err } after, err := k.boot() if err != nil || after != before { return "", "", os.ErrPermission } return before, digest, nil } func checkedGenerationTree(ctx context.Context, tree string) error { state, err := rootstate.Open(tree, false, true) if err != nil { return err } defer state.Close() count := 0 return filepath.WalkDir(tree, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if err := ctx.Err(); err != nil { return err } count++ if count > 8192 { return os.ErrPermission } var st unix.Stat_t if err := unix.Lstat(path, &st); err != nil { return err } if st.Uid != 0 || st.Mode&0022 != 0 || (st.Mode&unix.S_IFMT == unix.S_IFREG && st.Nlink != 1) || (st.Mode&unix.S_IFMT != unix.S_IFREG && st.Mode&unix.S_IFMT != unix.S_IFDIR) { return os.ErrPermission } return nil }) } // productionGenerationKernelCensus is root's kernel generation census. Its // every refusal is a generationCensusRefusal, so a no-journal recovery can // tell it from the other ways a settlement fails. func productionGenerationKernelCensus(ctx context.Context) (boot, digest string, err error) { defer func() { if err != nil { boot, digest, err = "", "", &generationCensusRefusal{err: err} } }() if os.Geteuid() != 0 { return "", "", os.ErrPermission } if err := verifyStopKernelMounts("/sys/fs/cgroup", "/proc"); err != nil { return "", "", err } hardlinks, err := generationRead("/proc/sys/fs/protected_hardlinks") if err != nil || string(hardlinks) != "1\n" { return "", "", os.ErrPermission } tree := filepath.Join(release.OptDir(), "versions") if err := checkedGenerationTree(ctx, tree); err != nil { return "", "", err } records, _ := ctx.Value(generationCensusReservationsKey{}).([]release.GenerationReservation) return generationCensusRetry(ctx, func(ctx context.Context) (string, string, error) { service, recheck := generationServiceReader(ctx) return (generationKernel{proc: "/proc", cgroups: "/sys/fs/cgroup", contained: records, service: service, recheck: recheck, parent: generationCensusParent(ctx)}).census(ctx, tree) }) } // generationCensusWindow bounds a whole census, its passes and the waits // between them: a task that exited or was replaced mid-pass, or one the // service lookup refused (a unit still activating, the guard's prelude, a // login's or sshd's short-lived child), is waited out inside it. A task // still refused, or a pass still running, when it ends refuses the census. const generationCensusWindow = 20 * time.Second // errGenerationCensusLate is a census that did not finish inside its // window: the last pass ran past it, successful or not, or no pass could // start before it ended. var errGenerationCensusLate = fmt.Errorf("generation census ran past its window: %w", os.ErrPermission) // generationTaskRefusal is a census refusal of one task, named by its /proc // path. Its text is the path and the cause, as before it had a type. type generationTaskRefusal struct { task string err error } func (r *generationTaskRefusal) Error() string { return r.task + ": " + r.err.Error() } func (r *generationTaskRefusal) Unwrap() error { return r.err } // errGenerationTaskReplaced is a task whose start time moved during its // inspection: the PID now names another task. It is still a refusal // (os.ErrPermission), but one a fresh pass can clear. var errGenerationTaskReplaced = fmt.Errorf("task replaced during the census: %w", os.ErrPermission) // transient is true for the task-level refusals a later pass can clear: // the task exited or was replaced, or the service lookup refused it. A // task that references a retired generation, or a namespace or mount // refusal, is not: those refuse at once. func (r *generationTaskRefusal) transient() bool { var service *generationServiceRefusal return errors.Is(r.err, os.ErrNotExist) || errors.Is(r.err, unix.ESRCH) || errors.Is(r.err, errGenerationTaskReplaced) || errors.As(r.err, &service) } func generationCensusRetry(ctx context.Context, inspect func(context.Context) (string, string, error)) (string, string, error) { return generationCensusRetryFor(ctx, inspect, generationCensusWindow, time.Now, generationSleep) } func generationSleep(ctx context.Context, d time.Duration) error { t := time.NewTimer(d) defer t.Stop() select { case <-ctx.Done(): return ctx.Err() case <-t.C: return nil } } // generationCensusRetryFor runs whole census passes under one deadline, // window from its start. Each pass gets a context that ends at the // deadline, which its blocking reads (systemctl show, the image hash) obey, // and a pass that ends past the deadline refuses even if it succeeded, so // nothing it saw is accepted late. A transient task refusal is retried with // backoff (250 ms, doubling to 4 s) while the wait ends before the // deadline; then the last refusal is returned as it is. Any other // missing-file or vanished-process error is retried at once, up to three // passes, as before. Everything else returns at once. now is the clock the // deadline is read on. func generationCensusRetryFor(ctx context.Context, inspect func(context.Context) (string, string, error), window time.Duration, now func() time.Time, sleep func(context.Context, time.Duration) error) (string, string, error) { deadline := now().Add(window) bounded, cancel := context.WithTimeout(ctx, window) defer cancel() late := func(last error) error { if last == nil { return errGenerationCensusLate } return fmt.Errorf("%w (last pass: %v)", errGenerationCensusLate, last) } delay, quick := 250*time.Millisecond, 0 var last error for { if err := ctx.Err(); err != nil { return "", "", err } if !now().Before(deadline) || bounded.Err() != nil { return "", "", late(last) } boot, digest, err := inspect(bounded) if ctx.Err() == nil && (!now().Before(deadline) || bounded.Err() != nil) { return "", "", late(err) } if err == nil { return boot, digest, nil } last = err var task *generationTaskRefusal switch { case errors.As(err, &task) && task.transient(): if !now().Add(delay).Before(deadline) || sleep(bounded, delay) != nil { return boot, digest, err } delay = min(2*delay, 4*time.Second) case quick < 2 && (errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ESRCH)): quick++ default: return boot, digest, err } } }