| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "io/fs" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/release" |
| 15 | "github.com/carlosframework/platform/internal/rootstate" |
| 16 | "golang.org/x/sys/unix" |
| 17 | ) |
| 18 | |
| 19 | // census checks the whole staged namespace, including unreserved builds. This |
| 20 | // verifier accepts running tenants only with root-retained launch containment. |
| 21 | // Other service tasks and unexplained namespaces still refuse completeness. |
| 22 | func (k generationKernel) census(ctx context.Context, tree string) (string, string, error) { |
| 23 | before, err := k.boot() |
| 24 | if err != nil { |
| 25 | return "", "", err |
| 26 | } |
| 27 | f, err := os.Open(tree) |
| 28 | if err != nil { |
| 29 | return "", "", err |
| 30 | } |
| 31 | id, err := rootstate.Identity(f) |
| 32 | f.Close() |
| 33 | if err != nil { |
| 34 | return "", "", err |
| 35 | } |
| 36 | r := release.GenerationReservation{Binding: release.GenerationBinding{Tree: tree, Path: tree, TreeIdentity: id, Executable: id}} |
| 37 | digest, err := k.absent(ctx, r) |
| 38 | if err != nil { |
| 39 | return "", "", err |
| 40 | } |
| 41 | after, err := k.boot() |
| 42 | if err != nil || after != before { |
| 43 | return "", "", os.ErrPermission |
| 44 | } |
| 45 | return before, digest, nil |
| 46 | } |
| 47 | func checkedGenerationTree(ctx context.Context, tree string) error { |
| 48 | state, err := rootstate.Open(tree, false, true) |
| 49 | if err != nil { |
| 50 | return err |
| 51 | } |
| 52 | defer state.Close() |
| 53 | count := 0 |
| 54 | return filepath.WalkDir(tree, func(path string, entry fs.DirEntry, walkErr error) error { |
| 55 | if walkErr != nil { |
| 56 | return walkErr |
| 57 | } |
| 58 | if err := ctx.Err(); err != nil { |
| 59 | return err |
| 60 | } |
| 61 | count++ |
| 62 | if count > 8192 { |
| 63 | return os.ErrPermission |
| 64 | } |
| 65 | var st unix.Stat_t |
| 66 | if err := unix.Lstat(path, &st); err != nil { |
| 67 | return err |
| 68 | } |
| 69 | 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) { |
| 70 | return os.ErrPermission |
| 71 | } |
| 72 | return nil |
| 73 | }) |
| 74 | } |
| 75 | |
| 76 | // productionGenerationKernelCensus is root's kernel generation census. Its |
| 77 | // every refusal is a generationCensusRefusal, so a no-journal recovery can |
| 78 | // tell it from the other ways a settlement fails. |
| 79 | func productionGenerationKernelCensus(ctx context.Context) (boot, digest string, err error) { |
| 80 | defer func() { |
| 81 | if err != nil { |
| 82 | boot, digest, err = "", "", &generationCensusRefusal{err: err} |
| 83 | } |
| 84 | }() |
| 85 | if os.Geteuid() != 0 { |
| 86 | return "", "", os.ErrPermission |
| 87 | } |
| 88 | if err := verifyStopKernelMounts("/sys/fs/cgroup", "/proc"); err != nil { |
| 89 | return "", "", err |
| 90 | } |
| 91 | hardlinks, err := generationRead("/proc/sys/fs/protected_hardlinks") |
| 92 | if err != nil || string(hardlinks) != "1\n" { |
| 93 | return "", "", os.ErrPermission |
| 94 | } |
| 95 | tree := filepath.Join(release.OptDir(), "versions") |
| 96 | if err := checkedGenerationTree(ctx, tree); err != nil { |
| 97 | return "", "", err |
| 98 | } |
| 99 | records, _ := ctx.Value(generationCensusReservationsKey{}).([]release.GenerationReservation) |
| 100 | return generationCensusRetry(ctx, func(ctx context.Context) (string, string, error) { |
| 101 | service, recheck := generationServiceReader(ctx) |
| 102 | return (generationKernel{proc: "/proc", cgroups: "/sys/fs/cgroup", contained: records, service: service, recheck: recheck, parent: generationCensusParent(ctx)}).census(ctx, tree) |
| 103 | }) |
| 104 | } |
| 105 | |
| 106 | // generationCensusWindow bounds a whole census, its passes and the waits |
| 107 | // between them: a task that exited or was replaced mid-pass, or one the |
| 108 | // service lookup refused (a unit still activating, the guard's prelude, a |
| 109 | // login's or sshd's short-lived child), is waited out inside it. A task |
| 110 | // still refused, or a pass still running, when it ends refuses the census. |
| 111 | const generationCensusWindow = 20 * time.Second |
| 112 | |
| 113 | // errGenerationCensusLate is a census that did not finish inside its |
| 114 | // window: the last pass ran past it, successful or not, or no pass could |
| 115 | // start before it ended. |
| 116 | var errGenerationCensusLate = fmt.Errorf("generation census ran past its window: %w", os.ErrPermission) |
| 117 | |
| 118 | // generationTaskRefusal is a census refusal of one task, named by its /proc |
| 119 | // path. Its text is the path and the cause, as before it had a type. |
| 120 | type generationTaskRefusal struct { |
| 121 | task string |
| 122 | err error |
| 123 | } |
| 124 | |
| 125 | func (r *generationTaskRefusal) Error() string { return r.task + ": " + r.err.Error() } |
| 126 | |
| 127 | func (r *generationTaskRefusal) Unwrap() error { return r.err } |
| 128 | |
| 129 | // errGenerationTaskReplaced is a task whose start time moved during its |
| 130 | // inspection: the PID now names another task. It is still a refusal |
| 131 | // (os.ErrPermission), but one a fresh pass can clear. |
| 132 | var errGenerationTaskReplaced = fmt.Errorf("task replaced during the census: %w", os.ErrPermission) |
| 133 | |
| 134 | // transient is true for the task-level refusals a later pass can clear: |
| 135 | // the task exited or was replaced, or the service lookup refused it. A |
| 136 | // task that references a retired generation, or a namespace or mount |
| 137 | // refusal, is not: those refuse at once. |
| 138 | func (r *generationTaskRefusal) transient() bool { |
| 139 | var service *generationServiceRefusal |
| 140 | return errors.Is(r.err, os.ErrNotExist) || errors.Is(r.err, unix.ESRCH) || errors.Is(r.err, errGenerationTaskReplaced) || errors.As(r.err, &service) |
| 141 | } |
| 142 | |
| 143 | func generationCensusRetry(ctx context.Context, inspect func(context.Context) (string, string, error)) (string, string, error) { |
| 144 | return generationCensusRetryFor(ctx, inspect, generationCensusWindow, time.Now, generationSleep) |
| 145 | } |
| 146 | |
| 147 | func generationSleep(ctx context.Context, d time.Duration) error { |
| 148 | t := time.NewTimer(d) |
| 149 | defer t.Stop() |
| 150 | select { |
| 151 | case <-ctx.Done(): |
| 152 | return ctx.Err() |
| 153 | case <-t.C: |
| 154 | return nil |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | // generationCensusRetryFor runs whole census passes under one deadline, |
| 159 | // window from its start. Each pass gets a context that ends at the |
| 160 | // deadline, which its blocking reads (systemctl show, the image hash) obey, |
| 161 | // and a pass that ends past the deadline refuses even if it succeeded, so |
| 162 | // nothing it saw is accepted late. A transient task refusal is retried with |
| 163 | // backoff (250 ms, doubling to 4 s) while the wait ends before the |
| 164 | // deadline; then the last refusal is returned as it is. Any other |
| 165 | // missing-file or vanished-process error is retried at once, up to three |
| 166 | // passes, as before. Everything else returns at once. now is the clock the |
| 167 | // deadline is read on. |
| 168 | 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) { |
| 169 | deadline := now().Add(window) |
| 170 | bounded, cancel := context.WithTimeout(ctx, window) |
| 171 | defer cancel() |
| 172 | late := func(last error) error { |
| 173 | if last == nil { |
| 174 | return errGenerationCensusLate |
| 175 | } |
| 176 | return fmt.Errorf("%w (last pass: %v)", errGenerationCensusLate, last) |
| 177 | } |
| 178 | delay, quick := 250*time.Millisecond, 0 |
| 179 | var last error |
| 180 | for { |
| 181 | if err := ctx.Err(); err != nil { |
| 182 | return "", "", err |
| 183 | } |
| 184 | if !now().Before(deadline) || bounded.Err() != nil { |
| 185 | return "", "", late(last) |
| 186 | } |
| 187 | boot, digest, err := inspect(bounded) |
| 188 | if ctx.Err() == nil && (!now().Before(deadline) || bounded.Err() != nil) { |
| 189 | return "", "", late(err) |
| 190 | } |
| 191 | if err == nil { |
| 192 | return boot, digest, nil |
| 193 | } |
| 194 | last = err |
| 195 | var task *generationTaskRefusal |
| 196 | switch { |
| 197 | case errors.As(err, &task) && task.transient(): |
| 198 | if !now().Add(delay).Before(deadline) || sleep(bounded, delay) != nil { |
| 199 | return boot, digest, err |
| 200 | } |
| 201 | delay = min(2*delay, 4*time.Second) |
| 202 | case quick < 2 && (errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ESRCH)): |
| 203 | quick++ |
| 204 | default: |
| 205 | return boot, digest, err |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | |