| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/ed25519" |
| 9 | "encoding/json" |
| 10 | "fmt" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "reflect" |
| 14 | "runtime" |
| 15 | "strconv" |
| 16 | "strings" |
| 17 | "time" |
| 18 | |
| 19 | "github.com/carlosframework/platform/internal/hostactors" |
| 20 | "github.com/carlosframework/platform/internal/hostfloor" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | "github.com/carlosframework/platform/internal/status" |
| 23 | "golang.org/x/sys/unix" |
| 24 | ) |
| 25 | |
| 26 | func activationConsolePID(r hostactors.Report) (int, string, error) { |
| 27 | p := strings.Split(r.ProcessID, ":") |
| 28 | if len(p) != 4 || p[0] != r.BootID { |
| 29 | return 0, "", errActivation |
| 30 | } |
| 31 | for _, field := range p[1:] { |
| 32 | n, e := strconv.ParseUint(field, 10, 64) |
| 33 | if e != nil || n == 0 { |
| 34 | return 0, "", errActivation |
| 35 | } |
| 36 | } |
| 37 | pid, err := strconv.Atoi(p[2]) |
| 38 | if err != nil || pid <= 0 { |
| 39 | return 0, "", errActivation |
| 40 | } |
| 41 | return pid, p[3], nil |
| 42 | } |
| 43 | |
| 44 | // This observation is deliberately on demand. It never refreshes an old box |
| 45 | // report or asserts that every possible console launch path is guarded. |
| 46 | func activationConsoleProcess(ctx context.Context, pub ed25519.PublicKey, bucket string, o hostactors.Observed, id string, r hostactors.Report) (status.ConsoleProcess, error) { |
| 47 | var zero status.ConsoleProcess |
| 48 | c, ok := o.Roster.Consoles[id] |
| 49 | if !ok || c.Compute == nil || r.Actor != id || r.Component != "console" || r.Compute == nil || *r.Compute != *c.Compute { |
| 50 | return zero, errActivation |
| 51 | } |
| 52 | localRaw, err := activationRead("/etc/carlos/host-actor.json", false) |
| 53 | if err != nil { |
| 54 | return zero, err |
| 55 | } |
| 56 | var local hostfloor.Enrollment |
| 57 | if json.Unmarshal(localRaw, &local) != nil || local.Box != c.Compute.Box || local.Enrollment != c.Compute.Enrollment { |
| 58 | return zero, errActivation |
| 59 | } |
| 60 | hostEnv, err := activationRead("/etc/carlos/host.env", false) |
| 61 | if err != nil { |
| 62 | return zero, err |
| 63 | } |
| 64 | expected, err := activationConsoleEnvironment(o.Roster, local, hostEnv, id, r.HostClaimEnabled) |
| 65 | if err != nil { |
| 66 | return zero, err |
| 67 | } |
| 68 | actual, err := activationRead("/etc/carlos/claim-console.env", true) |
| 69 | if err != nil || !bytes.Equal(actual, expected) { |
| 70 | return zero, errActivation |
| 71 | } |
| 72 | d := hostfloor.Disk{Dir: "/opt/carlos/system/guard", Bucket: bucket, PublicKey: pub} |
| 73 | policyRaw, err := activationRead(filepath.Join(d.Dir, "policy.json"), false) |
| 74 | if err != nil { |
| 75 | return zero, err |
| 76 | } |
| 77 | var policy hostfloor.Policy |
| 78 | if json.Unmarshal(policyRaw, &policy) != nil || policy.Mode == "unpublished" { |
| 79 | return zero, errActivation |
| 80 | } |
| 81 | floor, err := policy.Verify(pub, bucket) |
| 82 | if err != nil || floor.Digest != r.FloorSHA256 { |
| 83 | return zero, errActivation |
| 84 | } |
| 85 | path, err := activationRead(filepath.Join(d.Dir, "console-ready.json"), false) |
| 86 | if err != nil { |
| 87 | return zero, err |
| 88 | } |
| 89 | guard, err := activationRead("/etc/systemd/system/console.service.d/90-host-floor.conf", false) |
| 90 | if err != nil || string(guard) != hostfloor.ConsoleGuardDropIn(string(path)) { |
| 91 | return zero, errActivation |
| 92 | } |
| 93 | if !filepath.IsAbs(string(path)) || filepath.Clean(string(path)) != string(path) || strings.ContainsAny(string(path), " \t\r\n;") { |
| 94 | return zero, errActivation |
| 95 | } |
| 96 | snapshot := func() (map[string]string, error) { |
| 97 | raw, e := claimSystemctl(ctx, "show", "console.service", "--property=MainPID,ActiveState,SubState,ControlGroup,ExecStart,FragmentPath,DropInPaths") |
| 98 | if e != nil { |
| 99 | return nil, e |
| 100 | } |
| 101 | m := map[string]string{} |
| 102 | for _, line := range strings.Split(string(raw), "\n") { |
| 103 | k, v, ok := strings.Cut(line, "=") |
| 104 | if ok { |
| 105 | m[k] = v |
| 106 | } |
| 107 | } |
| 108 | return m, nil |
| 109 | } |
| 110 | unit, err := snapshot() |
| 111 | if err != nil { |
| 112 | return zero, err |
| 113 | } |
| 114 | pid, start, err := activationConsolePID(r) |
| 115 | if err != nil { |
| 116 | return zero, err |
| 117 | } |
| 118 | wantExec := "{ path=/opt/carlos/system/guard/carlos ; argv[]=/opt/carlos/system/guard/carlos system guard-exec console " + string(path) + " serve ;" |
| 119 | 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" { |
| 120 | return zero, errActivation |
| 121 | } |
| 122 | for _, p := range append([]string{unit["FragmentPath"]}, strings.Fields(unit["DropInPaths"])...) { |
| 123 | if _, err := activationRead(p, false); err != nil { |
| 124 | return zero, err |
| 125 | } |
| 126 | } |
| 127 | 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") { |
| 128 | return zero, errActivation |
| 129 | } |
| 130 | proc := fmt.Sprintf("/proc/%d", pid) |
| 131 | check := func() error { |
| 132 | boot, e := os.ReadFile("/proc/sys/kernel/random/boot_id") |
| 133 | if e != nil || strings.TrimSpace(string(boot)) != r.BootID { |
| 134 | return errActivation |
| 135 | } |
| 136 | st, e := os.ReadFile(proc + "/stat") |
| 137 | if e != nil { |
| 138 | return e |
| 139 | } |
| 140 | end := strings.LastIndexByte(string(st), ')') |
| 141 | if end < 0 { |
| 142 | return errActivation |
| 143 | } |
| 144 | fields := strings.Fields(string(st[end+1:])) |
| 145 | if len(fields) < 20 || fields[19] != start { |
| 146 | return errActivation |
| 147 | } |
| 148 | var ns unix.Stat_t |
| 149 | if unix.Stat(proc+"/ns/pid", &ns) != nil || strconv.FormatUint(ns.Ino, 10) != strings.Split(r.ProcessID, ":")[1] { |
| 150 | return errActivation |
| 151 | } |
| 152 | group, e := os.ReadFile(proc + "/cgroup") |
| 153 | if e != nil || string(group) != "0::"+unit["ControlGroup"]+"\n" { |
| 154 | return errActivation |
| 155 | } |
| 156 | return nil |
| 157 | } |
| 158 | if err = check(); err != nil { |
| 159 | return zero, err |
| 160 | } |
| 161 | command, err := os.ReadFile(proc + "/cmdline") |
| 162 | if err != nil || string(command) != string(path)+"\x00serve\x00" { |
| 163 | return zero, errActivation |
| 164 | } |
| 165 | processEnv, err := os.ReadFile(proc + "/environ") |
| 166 | if err != nil { |
| 167 | return zero, err |
| 168 | } |
| 169 | wantedEnv, err := activationEnvironment(expected) |
| 170 | if err != nil { |
| 171 | return zero, err |
| 172 | } |
| 173 | for _, name := range []string{"CARLOS_CONSOLE_ID", "CONSOLE_HOME_REGION", "CARLOS_HOST_CLAIM"} { |
| 174 | count := 0 |
| 175 | for _, entry := range strings.Split(string(processEnv), "\x00") { |
| 176 | key, value, ok := strings.Cut(entry, "=") |
| 177 | if ok && key == name { |
| 178 | count++ |
| 179 | if value != wantedEnv[name] { |
| 180 | return zero, errActivation |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | if count != 1 { |
| 185 | return zero, errActivation |
| 186 | } |
| 187 | } |
| 188 | checker, err := hostfloor.OpenProtected(filepath.Join(d.Dir, "carlos")) |
| 189 | if err != nil { |
| 190 | return zero, err |
| 191 | } |
| 192 | _, err = d.VerifyFile(checker, runtime.GOOS+"-"+runtime.GOARCH) |
| 193 | checker.Close() |
| 194 | if err != nil { |
| 195 | return zero, err |
| 196 | } |
| 197 | candidate, err := hostfloor.OpenProtected(string(path)) |
| 198 | if err != nil { |
| 199 | return zero, err |
| 200 | } |
| 201 | defer candidate.Close() |
| 202 | f, err := os.Open(proc + "/exe") |
| 203 | if err != nil { |
| 204 | return zero, err |
| 205 | } |
| 206 | defer f.Close() |
| 207 | currentInfo, err := f.Stat() |
| 208 | if err != nil { |
| 209 | return zero, err |
| 210 | } |
| 211 | candidateInfo, err := candidate.Stat() |
| 212 | if err != nil || !os.SameFile(currentInfo, candidateInfo) { |
| 213 | return zero, errActivation |
| 214 | } |
| 215 | var st unix.Stat_t |
| 216 | if unix.Fstat(int(f.Fd()), &st) != nil || st.Uid != 0 || st.Mode&0022 != 0 { |
| 217 | return zero, errActivation |
| 218 | } |
| 219 | hash, err := d.VerifyFile(f, runtime.GOOS+"-"+runtime.GOARCH, "console") |
| 220 | if err != nil || hash != r.BinarySHA256 { |
| 221 | return zero, errActivation |
| 222 | } |
| 223 | if err = check(); err != nil { |
| 224 | return zero, err |
| 225 | } |
| 226 | again, err := snapshot() |
| 227 | if err != nil || !reflect.DeepEqual(again, unit) { |
| 228 | return zero, errActivation |
| 229 | } |
| 230 | return status.ConsoleProcess{ConsoleID: id, Enrollment: c.Enrollment, ProcessID: r.ProcessID, BinarySHA256: hash, GuardFloorSHA256: floor.Digest}, nil |
| 231 | } |
| 232 | |
| 233 | func publishActivationConsoleReport(ctx context.Context, store release.Store, pub ed25519.PublicKey, bucket, id string, revision uint64, dry bool) (status.ConsoleProcess, error) { |
| 234 | var zero status.ConsoleProcess |
| 235 | d := hostactors.Directory{Store: store, PublicKey: pub, Bucket: bucket} |
| 236 | o, err := d.Read(ctx) |
| 237 | if err != nil { |
| 238 | return zero, err |
| 239 | } |
| 240 | if revision == 0 || o.Roster.Revision != revision { |
| 241 | return zero, errActivation |
| 242 | } |
| 243 | c, ok := o.Roster.Consoles[id] |
| 244 | if !ok || c.Compute == nil { |
| 245 | return zero, errActivation |
| 246 | } |
| 247 | e := hostactors.ExactEvidence{Store: store, PublicKey: pub, Boxes: d.PrivateBoxReport} |
| 248 | r, err := e.ConsoleReport(ctx, id, c) |
| 249 | if err != nil { |
| 250 | return zero, err |
| 251 | } |
| 252 | p, err := activationConsoleProcess(ctx, pub, bucket, o, id, r) |
| 253 | if err != nil { |
| 254 | return zero, err |
| 255 | } |
| 256 | raw, err := activationRead("/opt/carlos/system/guard/policy.json", false) |
| 257 | if err != nil { |
| 258 | return zero, err |
| 259 | } |
| 260 | var policy hostfloor.Policy |
| 261 | if json.Unmarshal(raw, &policy) != nil { |
| 262 | return zero, errActivation |
| 263 | } |
| 264 | floor, err := policy.Verify(pub, bucket) |
| 265 | if err != nil { |
| 266 | return zero, err |
| 267 | } |
| 268 | if err = hostactors.VerifyReport(ctx, e, pub, floor, r, c.Enrollment, id, time.Now()); err != nil { |
| 269 | return zero, err |
| 270 | } |
| 271 | b := o.Roster.Boxes[c.Compute.Box] |
| 272 | key := "status/boxes/" + c.Compute.Box + ".json" |
| 273 | body, tag, err := store.Get(ctx, key) |
| 274 | if err != nil { |
| 275 | return zero, err |
| 276 | } |
| 277 | br, err := d.PrivateBoxReport(ctx, c.Compute.Box, b) |
| 278 | if err != nil { |
| 279 | return zero, err |
| 280 | } |
| 281 | if err = hostactors.VerifyReport(ctx, e, pub, floor, br, b.Enrollment, b.Cloud.InstanceID, time.Now()); err != nil { |
| 282 | return zero, err |
| 283 | } |
| 284 | unchanged, unchangedTag, err := store.Get(ctx, key) |
| 285 | if err != nil || unchangedTag != tag || !bytes.Equal(unchanged, body) { |
| 286 | return zero, errActivation |
| 287 | } |
| 288 | if br.BootID != r.BootID { |
| 289 | return zero, errActivation |
| 290 | } |
| 291 | // Keep fields unknown to this binary, including future box metrics. |
| 292 | var box map[string]json.RawMessage |
| 293 | if json.Unmarshal(body, &box) != nil { |
| 294 | return zero, errActivation |
| 295 | } |
| 296 | var metrics map[string]json.RawMessage |
| 297 | if json.Unmarshal(box["metrics"], &metrics) != nil { |
| 298 | return zero, errActivation |
| 299 | } |
| 300 | var processes []status.ConsoleProcess |
| 301 | if v := metrics["console_processes"]; len(v) > 0 && json.Unmarshal(v, &processes) != nil { |
| 302 | return zero, errActivation |
| 303 | } |
| 304 | next := []status.ConsoleProcess{} |
| 305 | for _, old := range processes { |
| 306 | if old.ConsoleID != id { |
| 307 | next = append(next, old) |
| 308 | } |
| 309 | } |
| 310 | next = append(next, p) |
| 311 | metrics["console_processes"], _ = json.Marshal(next) |
| 312 | box["metrics"], _ = json.Marshal(metrics) |
| 313 | body, _ = json.Marshal(box) |
| 314 | fresh, err := d.Read(ctx) |
| 315 | if err != nil || fresh.ETag != o.ETag { |
| 316 | return zero, errActivation |
| 317 | } |
| 318 | if _, err = activationConsoleProcess(ctx, pub, bucket, o, id, r); err != nil { |
| 319 | return zero, err |
| 320 | } |
| 321 | if dry { |
| 322 | return p, nil |
| 323 | } |
| 324 | written, err := store.Put(ctx, key, body, tag) |
| 325 | if err != nil { |
| 326 | return zero, err |
| 327 | } |
| 328 | got, readTag, err := store.Get(ctx, key) |
| 329 | if err != nil || readTag != written || !bytes.Equal(got, body) { |
| 330 | return zero, errActivation |
| 331 | } |
| 332 | if _, err = activationConsoleProcess(ctx, pub, bucket, o, id, r); err != nil { |
| 333 | return zero, err |
| 334 | } |
| 335 | return p, nil |
| 336 | } |
| 337 | |