package console import ( "context" "encoding/json" "errors" "fmt" "log" "log/slog" "os" "path/filepath" "runtime" "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/version" ) // hostRunningImage is the census's seam for what this process is running. It // is nil in production, where the console reads its guard files off the box // (/opt/carlos/system/guard and the systemd drop-in); a test that has no box // installs one, so the worker's real write path can be exercised. var hostRunningImage func() (hostfloor.Running, error) func (s *Server) localHostReport(ctx context.Context) (hostactors.Report, error) { if hostRunningImage != nil { return s.hostReport(ctx, hostRunningImage) } disk := hostfloor.Disk{Dir: filepath.Join(release.OptDir(), "system", "guard"), Bucket: s.cfg.Bucket, PublicKey: s.cfg.ReleasePub} return s.hostReport(ctx, func() (hostfloor.Running, error) { return disk.Running(version.Build, runtime.GOOS+"-"+runtime.GOARCH, "console") }) } func (s *Server) hostReport(ctx context.Context, runningImage func() (hostfloor.Running, error)) (hostactors.Report, error) { id := s.cfg.HostConsoleID if id == "" { return hostactors.Report{}, hostactors.ErrRoster } d := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket} roster, err := d.Read(ctx) if err != nil { return hostactors.Report{}, err } c, ok := roster.Roster.Consoles[id] if !ok { c, err = d.InvitedConsole(ctx, roster, id) if err != nil { return hostactors.Report{}, err } } if c.Compute != nil { local, err := hostfloor.ReadEnrollment("/etc/carlos/host-actor.json") box, exists := roster.Roster.Boxes[c.Compute.Box] if err != nil || !exists || local.Box != c.Compute.Box || local.Enrollment != c.Compute.Enrollment || box.Enrollment != local.Enrollment || local.InstanceID != box.Cloud.InstanceID { return hostactors.Report{}, hostactors.ErrRoster } } running, err := runningImage() if err != nil { return hostactors.Report{}, err } return hostactors.Report{Compute: c.Compute, Component: "console", GeneratedAt: time.Now().UTC(), Actor: id, Enrollment: c.Enrollment, InstanceID: id, ProcessID: hostReportProcessID(running.BootID), Release: version.Build, ReleaseTag: running.ReleaseTag, Platform: runtime.GOOS + "-" + runtime.GOARCH, BinarySHA256: running.Hash, MovesProtocol: running.Protocol, BootID: running.BootID, FloorSHA256: running.FloorSHA256, FloorGuardReady: running.GuardReady, HostClaimEnabled: s.cfg.HostClaim}, nil } func (s *Server) StartHostReports(ctx context.Context) { // Reporting before the flag is enabled is harmless and needed for arming. if s.cfg.HostConsoleID == "" || strings.ContainsAny(s.cfg.HostConsoleID, "/\\") || s.cfg.Releases == nil { return } ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() s.runHostReports(ctx, ticker.C) } func (s *Server) runHostReports(ctx context.Context, ticks <-chan time.Time) { collisionLogged := false for { report, err := s.localHostReport(ctx) if err == nil { key := "status/consoles/" + s.cfg.HostConsoleID + ".json" raw, _ := json.Marshal(report) err = putHostReport(ctx, s.cfg.Releases, key, raw) } if len(s.cfg.Key) != 0 { d := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket} if o, e := d.Read(ctx); e == nil { for id := range o.Roster.Activations { _ = d.ReleaseActivation(ctx, release.RootSigner(s.cfg.Key), id, time.Now()) } } } if errors.Is(err, errHostReportCollision) { if !collisionLogged { slog.Info("console: host census report", "collision", err) collisionLogged = true } } else if err != nil { log.Printf("console: host census report: %v", err) } else { collisionLogged = false } select { case <-ctx.Done(): return case <-ticks: } } } // putHostReport replaces the console's report, creating it when absent. // // A fresh report refuses replacement unless its recorded process is provably // gone on this boot. The replacement uses the checked ETag. func putHostReport(ctx context.Context, store release.Store, key string, raw []byte) error { old, etag, err := store.Get(ctx, key) if err != nil && !errors.Is(err, os.ErrNotExist) { // S3Store wraps it return err } if err == nil { if err := hostReportCollision(old, raw, time.Now()); err != nil { return err } } _, err = store.Put(ctx, key, raw, etag) return err } var errHostReportCollision = errors.New("another console process is using the same CARLOS_CONSOLE_ID") func hostReportCollision(old, raw []byte, now time.Time) error { var prev, next hostactors.Report if json.Unmarshal(old, &prev) != nil || json.Unmarshal(raw, &next) != nil { return nil } if prev.ProcessID == "" || prev.ProcessID == next.ProcessID || prev.GeneratedAt.IsZero() { return nil } age := now.Sub(prev.GeneratedAt) if age > 15*time.Minute || age < -5*time.Second { return nil } if hostReportProcessGone(prev.ProcessID, next.ProcessID) { return nil } return fmt.Errorf("console report: process %s published this console's report at %s; %w", prev.ProcessID, prev.GeneratedAt.Format(time.RFC3339), errHostReportCollision) }