//go:build linux package host import ( "bytes" "context" "crypto/ed25519" "encoding/json" "fmt" "os" "path/filepath" "reflect" "runtime" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" "golang.org/x/sys/unix" ) func activationConsolePID(r hostactors.Report) (int, string, error) { p := strings.Split(r.ProcessID, ":") if len(p) != 4 || p[0] != r.BootID { return 0, "", errActivation } for _, field := range p[1:] { n, e := strconv.ParseUint(field, 10, 64) if e != nil || n == 0 { return 0, "", errActivation } } pid, err := strconv.Atoi(p[2]) if err != nil || pid <= 0 { return 0, "", errActivation } return pid, p[3], nil } // This observation is deliberately on demand. It never refreshes an old box // report or asserts that every possible console launch path is guarded. func activationConsoleProcess(ctx context.Context, pub ed25519.PublicKey, bucket string, o hostactors.Observed, id string, r hostactors.Report) (status.ConsoleProcess, error) { var zero status.ConsoleProcess c, ok := o.Roster.Consoles[id] if !ok || c.Compute == nil || r.Actor != id || r.Component != "console" || r.Compute == nil || *r.Compute != *c.Compute { return zero, errActivation } localRaw, err := activationRead("/etc/carlos/host-actor.json", false) if err != nil { return zero, err } var local hostfloor.Enrollment if json.Unmarshal(localRaw, &local) != nil || local.Box != c.Compute.Box || local.Enrollment != c.Compute.Enrollment { return zero, errActivation } hostEnv, err := activationRead("/etc/carlos/host.env", false) if err != nil { return zero, err } expected, err := activationConsoleEnvironment(o.Roster, local, hostEnv, id, r.HostClaimEnabled) if err != nil { return zero, err } actual, err := activationRead("/etc/carlos/claim-console.env", true) if err != nil || !bytes.Equal(actual, expected) { return zero, errActivation } d := hostfloor.Disk{Dir: "/opt/carlos/system/guard", Bucket: bucket, PublicKey: pub} policyRaw, err := activationRead(filepath.Join(d.Dir, "policy.json"), false) if err != nil { return zero, err } var policy hostfloor.Policy if json.Unmarshal(policyRaw, &policy) != nil || policy.Mode == "unpublished" { return zero, errActivation } floor, err := policy.Verify(pub, bucket) if err != nil || floor.Digest != r.FloorSHA256 { return zero, errActivation } path, err := activationRead(filepath.Join(d.Dir, "console-ready.json"), false) if err != nil { return zero, err } guard, err := activationRead("/etc/systemd/system/console.service.d/90-host-floor.conf", false) if err != nil || string(guard) != hostfloor.ConsoleGuardDropIn(string(path)) { return zero, errActivation } if !filepath.IsAbs(string(path)) || filepath.Clean(string(path)) != string(path) || strings.ContainsAny(string(path), " \t\r\n;") { return zero, errActivation } snapshot := func() (map[string]string, error) { raw, e := claimSystemctl(ctx, "show", "console.service", "--property=MainPID,ActiveState,SubState,ControlGroup,ExecStart,FragmentPath,DropInPaths") if e != nil { return nil, e } m := map[string]string{} for _, line := range strings.Split(string(raw), "\n") { k, v, ok := strings.Cut(line, "=") if ok { m[k] = v } } return m, nil } unit, err := snapshot() if err != nil { return zero, err } pid, start, err := activationConsolePID(r) if err != nil { return zero, err } wantExec := "{ path=/opt/carlos/system/guard/carlos ; argv[]=/opt/carlos/system/guard/carlos system guard-exec console " + string(path) + " serve ;" if unit["ActiveState"] != "active" || unit["SubState"] != "running" || unit["MainPID"] != strconv.Itoa(pid) || !strings.HasPrefix(unit["ExecStart"], wantExec) || strings.Count(unit["ExecStart"], "argv[]=") != 1 || unit["ControlGroup"] != "/system.slice/console.service" { return zero, errActivation } for _, p := range append([]string{unit["FragmentPath"]}, strings.Fields(unit["DropInPaths"])...) { if _, err := activationRead(p, false); err != nil { return zero, err } } if !strings.Contains(unit["DropInPaths"], "/etc/systemd/system/console.service.d/90-host-floor.conf") || !strings.Contains(unit["DropInPaths"], "/etc/systemd/system/console.service.d/91-claim-runtime.conf") { return zero, errActivation } proc := fmt.Sprintf("/proc/%d", pid) check := func() error { boot, e := os.ReadFile("/proc/sys/kernel/random/boot_id") if e != nil || strings.TrimSpace(string(boot)) != r.BootID { return errActivation } st, e := os.ReadFile(proc + "/stat") if e != nil { return e } end := strings.LastIndexByte(string(st), ')') if end < 0 { return errActivation } fields := strings.Fields(string(st[end+1:])) if len(fields) < 20 || fields[19] != start { return errActivation } var ns unix.Stat_t if unix.Stat(proc+"/ns/pid", &ns) != nil || strconv.FormatUint(ns.Ino, 10) != strings.Split(r.ProcessID, ":")[1] { return errActivation } group, e := os.ReadFile(proc + "/cgroup") if e != nil || string(group) != "0::"+unit["ControlGroup"]+"\n" { return errActivation } return nil } if err = check(); err != nil { return zero, err } command, err := os.ReadFile(proc + "/cmdline") if err != nil || string(command) != string(path)+"\x00serve\x00" { return zero, errActivation } processEnv, err := os.ReadFile(proc + "/environ") if err != nil { return zero, err } wantedEnv, err := activationEnvironment(expected) if err != nil { return zero, err } for _, name := range []string{"CARLOS_CONSOLE_ID", "CONSOLE_HOME_REGION", "CARLOS_HOST_CLAIM"} { count := 0 for _, entry := range strings.Split(string(processEnv), "\x00") { key, value, ok := strings.Cut(entry, "=") if ok && key == name { count++ if value != wantedEnv[name] { return zero, errActivation } } } if count != 1 { return zero, errActivation } } checker, err := hostfloor.OpenProtected(filepath.Join(d.Dir, "carlos")) if err != nil { return zero, err } _, err = d.VerifyFile(checker, runtime.GOOS+"-"+runtime.GOARCH) checker.Close() if err != nil { return zero, err } candidate, err := hostfloor.OpenProtected(string(path)) if err != nil { return zero, err } defer candidate.Close() f, err := os.Open(proc + "/exe") if err != nil { return zero, err } defer f.Close() currentInfo, err := f.Stat() if err != nil { return zero, err } candidateInfo, err := candidate.Stat() if err != nil || !os.SameFile(currentInfo, candidateInfo) { return zero, errActivation } var st unix.Stat_t if unix.Fstat(int(f.Fd()), &st) != nil || st.Uid != 0 || st.Mode&0022 != 0 { return zero, errActivation } hash, err := d.VerifyFile(f, runtime.GOOS+"-"+runtime.GOARCH, "console") if err != nil || hash != r.BinarySHA256 { return zero, errActivation } if err = check(); err != nil { return zero, err } again, err := snapshot() if err != nil || !reflect.DeepEqual(again, unit) { return zero, errActivation } return status.ConsoleProcess{ConsoleID: id, Enrollment: c.Enrollment, ProcessID: r.ProcessID, BinarySHA256: hash, GuardFloorSHA256: floor.Digest}, nil } func publishActivationConsoleReport(ctx context.Context, store release.Store, pub ed25519.PublicKey, bucket, id string, revision uint64, dry bool) (status.ConsoleProcess, error) { var zero status.ConsoleProcess d := hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket} o, err := d.Read(ctx) if err != nil { return zero, err } if revision == 0 || o.Roster.Revision != revision { return zero, errActivation } c, ok := o.Roster.Consoles[id] if !ok || c.Compute == nil { return zero, errActivation } e := hostactors.ExactEvidence{Store: store, PublicKey: pub, Boxes: d.PrivateBoxReport} r, err := e.ConsoleReport(ctx, id, c) if err != nil { return zero, err } p, err := activationConsoleProcess(ctx, pub, bucket, o, id, r) if err != nil { return zero, err } raw, err := activationRead("/opt/carlos/system/guard/policy.json", false) if err != nil { return zero, err } var policy hostfloor.Policy if json.Unmarshal(raw, &policy) != nil { return zero, errActivation } floor, err := policy.Verify(pub, bucket) if err != nil { return zero, err } if err = hostactors.VerifyReport(ctx, e, pub, floor, r, c.Enrollment, id, time.Now()); err != nil { return zero, err } b := o.Roster.Boxes[c.Compute.Box] key := "status/boxes/" + c.Compute.Box + ".json" body, tag, err := store.Get(ctx, key) if err != nil { return zero, err } br, err := d.PrivateBoxReport(ctx, c.Compute.Box, b) if err != nil { return zero, err } if err = hostactors.VerifyReport(ctx, e, pub, floor, br, b.Enrollment, b.Cloud.InstanceID, time.Now()); err != nil { return zero, err } unchanged, unchangedTag, err := store.Get(ctx, key) if err != nil || unchangedTag != tag || !bytes.Equal(unchanged, body) { return zero, errActivation } if br.BootID != r.BootID { return zero, errActivation } // Keep fields unknown to this binary, including future box metrics. var box map[string]json.RawMessage if json.Unmarshal(body, &box) != nil { return zero, errActivation } var metrics map[string]json.RawMessage if json.Unmarshal(box["metrics"], &metrics) != nil { return zero, errActivation } var processes []status.ConsoleProcess if v := metrics["console_processes"]; len(v) > 0 && json.Unmarshal(v, &processes) != nil { return zero, errActivation } next := []status.ConsoleProcess{} for _, old := range processes { if old.ConsoleID != id { next = append(next, old) } } next = append(next, p) metrics["console_processes"], _ = json.Marshal(next) box["metrics"], _ = json.Marshal(metrics) body, _ = json.Marshal(box) fresh, err := d.Read(ctx) if err != nil || fresh.ETag != o.ETag { return zero, errActivation } if _, err = activationConsoleProcess(ctx, pub, bucket, o, id, r); err != nil { return zero, err } if dry { return p, nil } written, err := store.Put(ctx, key, body, tag) if err != nil { return zero, err } got, readTag, err := store.Get(ctx, key) if err != nil || readTag != written || !bytes.Equal(got, body) { return zero, errActivation } if _, err = activationConsoleProcess(ctx, pub, bucket, o, id, r); err != nil { return zero, err } return p, nil }