carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package host
4
5import (
6 "context"
7 "crypto/sha256"
8 "encoding/hex"
9 "errors"
10 "fmt"
11 "io"
12 "os"
13 "os/exec"
14 "path/filepath"
15 "slices"
16 "strconv"
17 "strings"
18
19 "github.com/carlosframework/platform/internal/rootstate"
20 "golang.org/x/sys/unix"
21)
22
23type generationService struct {
24 group, executable, selectedPath string
25 main int
26 selected rootstate.FileIdentity
27}
28
29// generationServiceEnv is what the service lookup reads besides the task's
30// own /proc entries: systemd's view of the unit (show), the unit files'
31// ownership (trust), the main process's cgroup (under proc), and, for a
32// task still running a renamed-over binary, the box's signed router images
33// (binary, under opt) and the owner every trusted file must have. The
34// production values are generationServiceReader's; tests replace the parts
35// a non-root test cannot provide.
36type generationServiceEnv struct {
37 proc, opt string
38 owner uint32
39 show func(ctx context.Context, unit string) (map[string]string, error)
40 trust func(path string) error
41 binary func(ctx context.Context, opt, path string) (rootFloorBinary, error)
42}
43
44// generationServiceRefusal is a refusal of the service lookup. It names the
45// check, the unit, the task's cgroup and its exe link, so that refusals
46// that used to read as a bare "permission denied" can be told apart, and
47// it wraps the cause, so errors.Is still sees os.ErrPermission or a task
48// that exited.
49type generationServiceRefusal struct {
50 check, unit, group, exe string
51 err error
52}
53
54func (r *generationServiceRefusal) Error() string {
55 return fmt.Sprintf("%s check refused: unit %s, cgroup %s, exe %s: %v", r.check, r.unit, r.group, r.exe, r.err)
56}
57
58func (r *generationServiceRefusal) Unwrap() error { return r.err }
59
60// generationServiceReader is the production service lookup: classify a
61// task, and recheck the executable binding of a task it trusted
62// (generationKernel.recheck).
63func generationServiceReader(ctx context.Context) (func(string, []byte) (bool, error), func(string, bool) error) {
64 return generationServiceEnv{proc: "/proc", opt: "/opt/carlos", show: generationServiceShow, trust: generationServiceTrust, binary: readRootFloorBinaryContext}.reader(ctx)
65}
66
67// generationExeID is what binds a task to the image it was proven to run:
68// the device and inode of the descriptor opened through /proc/<pid>/exe,
69// the kernel's file handle for it where the filesystem gives one, and its
70// mount ID. rootstate.ObjectIdentity carries the same handle but also
71// resolves the mount in root's own mount table, which a service in a
72// private mount namespace does not share, so only its kernel facts are
73// used here.
74type generationExeID struct {
75 device, inode, mount uint64
76 handle string
77 handleType int32
78}
79
80func generationExeIdentity(f *os.File) (generationExeID, error) {
81 var st unix.Stat_t
82 if err := unix.Fstat(int(f.Fd()), &st); err != nil {
83 return generationExeID{}, err
84 }
85 var sx unix.Statx_t
86 if err := unix.Statx(int(f.Fd()), "", unix.AT_EMPTY_PATH, unix.STATX_MNT_ID, &sx); err != nil {
87 return generationExeID{}, err
88 }
89 id := generationExeID{device: uint64(st.Dev), inode: st.Ino, mount: sx.Mnt_id}
90 handle, _, err := unix.NameToHandleAt(int(f.Fd()), "", unix.AT_EMPTY_PATH)
91 if err == nil {
92 id.handle, id.handleType = hex.EncodeToString(handle.Bytes()), handle.Type()
93 } else if !errors.Is(err, unix.EOPNOTSUPP) {
94 return generationExeID{}, err
95 }
96 return id, nil
97}
98
99// generationExeBinding is a task the lookup trusted because the image it
100// ran was proven: that image's descriptor, kept open for the inspection so
101// its inode cannot be reused, and its identity.
102type generationExeBinding struct {
103 file *os.File
104 id generationExeID
105 unit, group, exe string
106}
107
108func generationServiceShow(ctx context.Context, unit string) (map[string]string, error) {
109 out, err := exec.CommandContext(ctx, "systemctl", "show", unit, "-p", "ControlGroup", "-p", "MainPID", "-p", "ExecStart", "-p", "FragmentPath", "-p", "DropInPaths").Output()
110 if err != nil {
111 return nil, err
112 }
113 props := map[string]string{}
114 for _, line := range strings.Split(string(out), "\n") {
115 if k, v, ok := strings.Cut(line, "="); ok {
116 props[k] = v
117 }
118 }
119 return props, nil
120}
121
122func generationServiceTrust(file string) error {
123 file, err := filepath.EvalSymlinks(file)
124 if err != nil {
125 return err
126 }
127 f, err := rootstate.OpenTrustedFile(file)
128 if err != nil {
129 return err
130 }
131 f.Close()
132 return nil
133}
134
135func (env generationServiceEnv) reader(ctx context.Context) (func(string, []byte) (bool, error), func(string, bool) error) {
136 units := map[string]generationService{}
137 bound := map[string]*generationExeBinding{}
138 var proven []string
139 var provenErr error
140 proved := false
141 // The digests of the current and the previous router binary, each
142 // proven against a signed identity the box holds, read once per census
143 // pass and only if a task's exe has been deleted.
144 prove := func() ([]string, error) {
145 if !proved {
146 proved = true
147 var errs []error
148 for _, name := range []string{"carlos", "carlos.prev"} {
149 b, err := env.binary(ctx, env.opt, filepath.Join(env.opt, name))
150 if err != nil {
151 if name == "carlos" || !errors.Is(err, os.ErrNotExist) {
152 errs = append(errs, fmt.Errorf("%s: %w", name, err))
153 }
154 continue
155 }
156 proven = append(proven, b.Digest)
157 }
158 if len(proven) == 0 {
159 provenErr = errors.Join(append(errs, os.ErrPermission)...)
160 }
161 }
162 return proven, provenErr
163 }
164 classify := func(path string, status []byte) (bool, error) {
165 var held *os.File
166 defer func() {
167 if held != nil {
168 held.Close()
169 }
170 }()
171 if old := bound[path]; old != nil {
172 old.file.Close()
173 delete(bound, path)
174 }
175 name, nameErr := os.Readlink(path + "/exe")
176 exe := name
177 if nameErr != nil {
178 exe = "unreadable"
179 }
180 unit, group := "unknown", "unknown"
181 refuse := func(check string, err error) error {
182 return &generationServiceRefusal{check: check, unit: unit, group: group, exe: exe, err: err}
183 }
184 raw, err := generationRead(path + "/cgroup")
185 if err != nil {
186 return false, refuse("cgroup", err)
187 }
188 group = strings.TrimPrefix(strings.TrimSpace(string(raw)), "0::")
189 if !strings.HasPrefix(group, "/") || strings.Contains(group, "\n") || strings.Contains(group, "/carlos-instances.slice/") {
190 return false, refuse("cgroup", os.ErrPermission)
191 }
192 unit = filepath.Base(group)
193 for _, part := range strings.Split(group, "/") {
194 if strings.HasSuffix(part, ".service") {
195 unit = part
196 break
197 }
198 }
199 if !strings.HasSuffix(unit, ".service") && group != "/init.scope" {
200 return false, refuse("unit", os.ErrPermission)
201 }
202 s, ok := units[unit]
203 if !ok {
204 if group == "/init.scope" {
205 s = generationService{group: group, main: 1}
206 } else {
207 props, err := env.show(ctx, unit)
208 if err != nil {
209 return false, refuse("systemctl show", err)
210 }
211 s.group, s.main = props["ControlGroup"], 0
212 s.main, err = strconv.Atoi(props["MainPID"])
213 if err != nil || s.main <= 0 || props["FragmentPath"] == "" {
214 return false, refuse("main pid", os.ErrPermission)
215 }
216 for _, file := range append([]string{props["FragmentPath"]}, strings.Fields(props["DropInPaths"])...) {
217 if err := env.trust(file); err != nil {
218 return false, refuse("unit file "+file, err)
219 }
220 }
221 _, tail, found := strings.Cut(props["ExecStart"], "path=")
222 if !found {
223 return false, refuse("exec start", os.ErrPermission)
224 }
225 s.executable, _, _ = strings.Cut(tail, " ;")
226 if unit == "carlos-edge.service" && s.executable == "/opt/carlos/system/guard/carlos" {
227 if !generationGuardEdgeStart(unit, props["ExecStart"]) {
228 return false, refuse("guard start", os.ErrPermission)
229 }
230 if _, err := env.binary(ctx, env.opt, s.executable); err != nil {
231 return false, refuse("guard binary", err)
232 }
233 selected, err := env.binary(ctx, env.opt, filepath.Join(env.opt, "carlos"))
234 if err != nil {
235 return false, refuse("selected binary", err)
236 }
237 s.selected, s.selectedPath = selected.File, filepath.Join(env.opt, "carlos")
238 }
239 }
240 units[unit] = s
241 }
242 supervisor := generationServiceSupervisor(unit, group, s.group, status, s.main)
243 if !generationServiceGroup(group, s.group) && !supervisor {
244 return false, refuse("group", os.ErrPermission)
245 }
246 if nameErr != nil {
247 return false, refuse("exe link", nameErr)
248 }
249
250 // Non-root services may execute only the program selected by their
251 // root-owned unit. UID and a service-looking cgroup are insufficient.
252 _, rootErr := generationRootTask(status)
253 checked := rootErr != nil || s.selected != (rootstate.FileIdentity{})
254 if program, deleted := strings.CutSuffix(name, " (deleted)"); deleted && checked {
255 want := s.executable
256 if s.selected != (rootstate.FileIdentity{}) {
257 want = s.selectedPath
258 }
259 image, err := env.deletedProgram(ctx, path, program, want, prove)
260 if err != nil {
261 return false, refuse("deleted exe", err)
262 }
263 held = image
264 } else {
265 actual, err := generationServiceExecutable(path, name)
266 if err != nil {
267 return false, refuse("exe", fmt.Errorf("%s: %w", name, err))
268 }
269 if checked {
270 want, e := generationIdentity(s.executable)
271 if s.selected != (rootstate.FileIdentity{}) {
272 want, e = s.selected, nil
273 }
274 if e != nil || want != actual {
275 return false, refuse("selected program", os.ErrPermission)
276 }
277 }
278 }
279 mainGroup, err := generationRead(env.proc + "/" + strconv.Itoa(s.main) + "/cgroup")
280 mainPath := strings.TrimPrefix(strings.TrimSpace(string(mainGroup)), "0::")
281 if err != nil || (!generationServiceGroup(mainPath, s.group) && !(supervisor && mainPath == group)) {
282 if err == nil {
283 err = os.ErrPermission
284 }
285 return false, refuse("main cgroup", err)
286 }
287 if held != nil {
288 id, err := generationExeIdentity(held)
289 if err != nil {
290 return false, refuse("exe binding", err)
291 }
292 bound[path] = &generationExeBinding{file: held, id: id, unit: unit, group: group, exe: exe}
293 held = nil
294 }
295 return true, nil
296 }
297 return classify, func(path string, final bool) error { return env.recheck(bound, path, final) }
298}
299
300// recheck holds a trusted task to the image it was proven to run, at each
301// later use of that trust and at the end of its inspection (final).
302// /proc/<pid>/exe, opened again, must be the very inode that was hashed (device, inode, mount and file handle),
303// still a root-owned regular file with no links left and no group or other
304// write. A task that exec'd anything else since, the same PID with the same
305// start time, refuses; a later census pass may classify it afresh. The
306// bytes are not hashed again: the identity is the hashed descriptor's, which
307// stays open, and the kernel refuses writes to an image being executed
308// (ETXTBSY). final releases the binding whatever the result. A task the
309// lookup trusted without a proven image (its live exe was the unit's
310// selected program, as on main) has no binding and passes.
311func (env generationServiceEnv) recheck(bound map[string]*generationExeBinding, path string, final bool) error {
312 b := bound[path]
313 if b == nil {
314 return nil
315 }
316 if final {
317 defer func() {
318 b.file.Close()
319 delete(bound, path)
320 }()
321 }
322 refuse := func(err error) error {
323 return &generationServiceRefusal{check: "exe binding", unit: b.unit, group: b.group, exe: b.exe, err: err}
324 }
325 f, err := os.Open(path + "/exe")
326 if err != nil {
327 return refuse(err)
328 }
329 defer f.Close()
330 id, err := generationExeIdentity(f)
331 if err != nil {
332 return refuse(err)
333 }
334 var st unix.Stat_t
335 if err := unix.Fstat(int(f.Fd()), &st); err != nil {
336 return refuse(err)
337 }
338 if id != b.id || st.Uid != env.owner || st.Mode&unix.S_IFMT != unix.S_IFREG || st.Mode&0022 != 0 || st.Nlink != 0 {
339 return refuse(fmt.Errorf("the task's image is no longer the one proven: %w", os.ErrPermission))
340 }
341 return nil
342}
343
344// deletedProgram accepts a non-root service task still running a carlos
345// binary that an apply has since renamed over, whose exe link reads
346// "<program> (deleted)". The program must be the one the unit selects and
347// the box's router binary, <opt>/carlos. The task's own image, opened
348// through /proc/<pid>/exe, must be a root-owned regular file with no links
349// left that no group or other can write. Its bytes must hash to the router
350// artifact of the signed identity of the current or the previous binary
351// (<opt>/carlos, <opt>/carlos.prev), proven by readRootFloorBinary, the
352// proof the guard makes before it execs one. Anything else refuses, and
353// the census's reference checks still run on an accepted task.
354//
355// On success it returns the image's descriptor, open: the lookup keeps it
356// to bind the task to that image for the rest of its inspection (recheck).
357func (env generationServiceEnv) deletedProgram(ctx context.Context, task, program, want string, prove func() ([]string, error)) (*os.File, error) {
358 if program != want || want != filepath.Join(env.opt, "carlos") {
359 return nil, fmt.Errorf("%s is not this unit's router binary: %w", program, os.ErrPermission)
360 }
361 f, err := os.Open(task + "/exe")
362 if err != nil {
363 return nil, err
364 }
365 keep := false
366 defer func() {
367 if !keep {
368 f.Close()
369 }
370 }()
371 var st unix.Stat_t
372 if err := unix.Fstat(int(f.Fd()), &st); err != nil {
373 return nil, err
374 }
375 if st.Uid != env.owner || st.Mode&unix.S_IFMT != unix.S_IFREG || st.Mode&0022 != 0 || st.Nlink != 0 || st.Size > 512<<20 {
376 return nil, fmt.Errorf("image is not an unlinked root-owned file: %w", os.ErrPermission)
377 }
378 sum := sha256.New()
379 if n, err := io.Copy(sum, generationContextReader{ctx: ctx, r: io.LimitReader(f, 512<<20+1)}); err != nil {
380 return nil, err
381 } else if n > 512<<20 {
382 return nil, os.ErrPermission
383 }
384 digests, err := prove()
385 if err != nil {
386 return nil, err
387 }
388 if !slices.Contains(digests, hex.EncodeToString(sum.Sum(nil))) {
389 return nil, fmt.Errorf("image is neither the current nor the previous signed router build: %w", os.ErrPermission)
390 }
391 keep = true
392 return f, nil
393}
394
395func generationGuardEdgeStart(unit, start string) bool {
396 if unit != "carlos-edge.service" || strings.Count(start, "{") != 1 || strings.Count(start, "}") != 1 {
397 return false
398 }
399 fields := strings.Split(start, " ; ")
400 return len(fields) >= 4 && fields[0] == "{ path=/opt/carlos/system/guard/carlos" &&
401 fields[1] == "argv[]=/opt/carlos/system/guard/carlos system guard-exec edge" && fields[2] == "ignore_errors=no"
402}
403
404func generationServiceGroup(group, base string) bool {
405 if group == base && base != "" {
406 return true
407 }
408 if filepath.Clean(group) != group || base == "" || !strings.HasPrefix(group, base+"/") {
409 return false
410 }
411 for p := group; ; p = filepath.Dir(p) {
412 var st unix.Stat_t
413 if unix.Lstat(filepath.Join("/sys/fs/cgroup", p), &st) != nil || st.Uid != 0 || st.Mode&0022 != 0 || st.Mode&unix.S_IFMT != unix.S_IFDIR {
414 return false
415 }
416 if p == base {
417 return true
418 }
419 }
420}
421
422func generationServiceExecutable(task, name string) (rootstate.FileIdentity, error) {
423 var zero rootstate.FileIdentity
424 if !filepath.IsAbs(name) || filepath.Clean(name) != name || name == "/" {
425 return zero, os.ErrPermission
426 }
427 root, err := os.Open(task + "/root")
428 if err != nil {
429 return zero, err
430 }
431 defer root.Close()
432 fd, err := unix.FcntlInt(root.Fd(), unix.F_DUPFD_CLOEXEC, 0)
433 if err != nil {
434 return zero, err
435 }
436 defer func() { unix.Close(fd) }()
437 parts := strings.Split(strings.TrimPrefix(name, "/"), "/")
438 for i := 0; ; i++ {
439 var st unix.Stat_t
440 if err := unix.Fstat(fd, &st); err != nil {
441 return zero, err
442 }
443 kind := uint32(unix.S_IFDIR)
444 if i == len(parts) {
445 kind = unix.S_IFREG
446 }
447 if st.Uid != 0 || st.Mode&unix.S_IFMT != kind || st.Mode&0022 != 0 || (kind == unix.S_IFREG && st.Nlink != 1) {
448 return zero, os.ErrPermission
449 }
450 if i == len(parts) {
451 id := rootstate.FileIdentity{Device: uint64(st.Dev), Inode: st.Ino}
452 actual, err := generationIdentity(task + "/exe")
453 if err != nil || actual != id {
454 return zero, os.ErrPermission
455 }
456 return id, nil
457 }
458 next, err := unix.Openat(fd, parts[i], unix.O_RDONLY|unix.O_NONBLOCK|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
459 if err != nil {
460 return zero, err
461 }
462 unix.Close(fd)
463 fd = next
464 }
465}
466
467func generationServiceSupervisor(unit, group, base string, status []byte, main int) bool {
468 if unit != "carlos-edge.service" || base == "" || filepath.Clean(base) != base || group != base+"/supervise" || main <= 0 {
469 return false
470 }
471 tgid := ""
472 for _, line := range strings.Split(string(status), "\n") {
473 if value, ok := strings.CutPrefix(line, "Tgid:"); ok {
474 if tgid != "" {
475 return false
476 }
477 tgid = strings.TrimSpace(value)
478 }
479 }
480 return tgid == strconv.Itoa(main)
481}
482