package hostbox import ( "bytes" "context" "encoding/json" "fmt" "io" "path" "path/filepath" "strings" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/registry" ) // The reservation-bearing extension of main's isolated root launch protocol // (claim Plan 11, R10). The agent asks root to run one reserved role // through main's isolated instance unit. Root validates the reservation // against its own claim, identity and journal providers, journals its // intention before any effect, and records the unit invocation it started. // The request names which reservation to run; nothing in it is authority. const ( RoleLaunchV = 1 RoleLaunchProtocol = "isolated-unit-v3" RoleLaunchLimit = 32 << 10 RoleLaunchCapability = "capability" RoleLaunchStart = "start" RoleLaunchStop = "stop" RoleLaunchInputs = "unit-inputs" // Unit reservation states. Only root writes them, in its local launch // journal. None of the agent's journal operations moves a unit // reservation, and the stop journal reports a record unsettled while one // is intended or started. A settled unit shares the stop journal's // terminal state; its unit cgroup path tells it from an agent role. UnitIntended = "unit-intended" UnitStarted = "unit-started" UnitSettled = "settled" ) // ErrRoleLaunchShape refuses every role launch the isolated protocol does // not carry: any role but the main role, any request field it does not // define, and any agent-supplied containment or process identity. var ErrRoleLaunchShape = fmt.Errorf("%w: role launch shape is not carried by the isolated root protocol", ErrUnsupported) // RoleLaunchRequest is one protocol call. Start carries the reservation the // agent asks root to run, the build it pinned and the slice tier; stop names // the same reservation; capability carries nothing. type RoleLaunchRequest struct { Route *registry.Route `json:"route,omitempty"` Kind string `json:"kind,omitempty"` Spec *LaunchSpec `json:"spec,omitempty"` V int `json:"v"` Op string `json:"op"` Reservation *LaunchReservation `json:"reservation,omitempty"` Executable string `json:"executable,omitempty"` MemoryMax int64 `json:"memory_max,omitempty"` } // RoleLaunchResponse answers every call with the protocol root speaks. A // start answers with the grant root journaled. type UnitLaunchInputs struct { Unit, Socket, Addr, DBPath, Program string Args, Env []string } type RoleLaunchResponse struct { Inputs *UnitLaunchInputs `json:"inputs,omitempty"` V int `json:"v"` Protocol string `json:"protocol"` Grant *RoleLaunchGrant `json:"grant,omitempty"` } // RoleLaunchGrant is root's record of the unit it started for a reservation: // the unit, its systemd invocation, its main process and the identity of its // cgroup. Group.InvocationID is the unit's invocation, not the attempt's. type RoleLaunchGrant struct { ReservationID string `json:"reservation_id"` Unit string `json:"unit"` UnitInvocationID string `json:"unit_invocation_id"` PID int `json:"pid"` ProcessStart string `json:"process_start"` Group LaunchGroup `json:"group"` } // UnitGroupPath reports whether p can name an isolated unit's cgroup: a // clean relative path to a .service directory. func UnitGroupPath(p string) bool { return p != "" && filepath.IsLocal(p) && path.Clean(p) == p && strings.HasSuffix(p, ".service") && !strings.Contains(p, "\x00") } // unitReservationShape is a start's reservation: the main role, an attempt, // a pinned build and a complete owner tuple, with the unit's cgroup named but // not identified, and nothing root alone records. func unitReservationShape(r LaunchReservation) bool { return !r.NeverIntended && r.ID != "" && r.Role == "main" && r.InvocationID != "" && r.Inputs != "" && r.State == "" && r.PID == 0 && r.ProcessStart == "" && r.Parent == (LaunchGroup{}) && r.Executable.Device != 0 && r.Executable.Inode != 0 && validLaunchOwner(r.Target) && component(r.Target.Host) && UnitGroupPath(r.Group.RelativePath) && r.Group.Device == 0 && r.Group.Inode == 0 && r.Group.InvocationID == "" } // Validate checks the shape of a request; it grants nothing. func (q RoleLaunchRequest) Validate() error { if q.V != RoleLaunchV { return ErrRoleLaunchShape } if q.Op != RoleLaunchInputs && q.Route != nil { return ErrRoleLaunchShape } switch q.Op { case RoleLaunchInputs: if q.Route == nil || !component(q.Route.Host) || q.Kind != "" || q.Spec != nil || q.Reservation != nil || q.Executable != "" || q.MemoryMax != 0 { return ErrRoleLaunchShape } case RoleLaunchCapability: if q.Spec != nil || q.Reservation != nil || q.Executable != "" || q.MemoryMax != 0 { return ErrRoleLaunchShape } case RoleLaunchStart: if q.Kind != "" || q.Reservation == nil || !unitReservationShape(*q.Reservation) || !filepath.IsAbs(q.Executable) || filepath.Clean(q.Executable) != q.Executable || q.MemoryMax <= 0 { return ErrRoleLaunchShape } case RoleLaunchStop: if q.Kind != "" || q.Spec != nil || q.Reservation == nil || !unitReservationShape(*q.Reservation) || q.Executable != "" || q.MemoryMax != 0 { return ErrRoleLaunchShape } default: return ErrRoleLaunchShape } return nil } // EncodeRoleLaunchRequest is the request's canonical body. func EncodeRoleLaunchRequest(q RoleLaunchRequest) ([]byte, error) { return json.Marshal(q) } // DecodeRoleLaunchRequest strictly decodes one bounded request: no unknown // field, no trailing value, no body above RoleLaunchLimit. func DecodeRoleLaunchRequest(r io.Reader) (RoleLaunchRequest, error) { var q RoleLaunchRequest if err := strictRoleLaunch(r, &q); err != nil { return RoleLaunchRequest{}, err } if err := q.Validate(); err != nil { return RoleLaunchRequest{}, err } return q, nil } // DecodeRoleLaunchResponse strictly decodes root's answer. func DecodeRoleLaunchResponse(r io.Reader) (RoleLaunchResponse, error) { var out RoleLaunchResponse if err := strictRoleLaunch(r, &out); err != nil { return RoleLaunchResponse{}, err } if out.V != RoleLaunchV || out.Protocol == "" { return RoleLaunchResponse{}, ErrRoleLaunchShape } return out, nil } func strictRoleLaunch(r io.Reader, v any) error { raw, err := io.ReadAll(io.LimitReader(r, RoleLaunchLimit+1)) if err != nil { return err } if len(raw) > RoleLaunchLimit { return ErrRoleLaunchShape } d := json.NewDecoder(bytes.NewReader(raw)) d.DisallowUnknownFields() if err := d.Decode(v); err != nil { return ErrRoleLaunchShape } if d.Decode(new(any)) != io.EOF { return ErrRoleLaunchShape } return nil } // Check accepts a grant only for exactly r: the same reservation, the unit // cgroup r named with a real identity, one unit invocation, and a process. func (g RoleLaunchGrant) Check(r LaunchReservation) error { if g.ReservationID != r.ID || g.Unit == "" || g.UnitInvocationID == "" || g.PID <= 0 || g.ProcessStart == "" || g.Group.RelativePath != r.Group.RelativePath || !UnitGroupPath(g.Group.RelativePath) || g.Group.Device == 0 || g.Group.Inode == 0 || g.Group.InvocationID != g.UnitInvocationID { return ErrChanged } return nil } // IntendUnit durably records root's intention to start an isolated unit for // r before any effect. Only a root-local journal accepts it. The prelaunch // record must exist and its producers must not have been drained. A // reservation ID or attempt invocation the record already holds, in any // state, is a replay and refuses; so does a second live unit for the record. func (j *LaunchJournal) IntendUnit(ctx context.Context, r LaunchReservation) error { if j == nil || j.remote != nil || j.db == nil { return ErrUnsupported } if !unitReservationShape(r) { return ErrUnsupported } return j.mutate(ctx, r.Target, func(in *LaunchInventory) error { if in.WritersClosed { return ErrChanged } for _, old := range in.Reservations { if old.ID == r.ID || old.InvocationID == r.InvocationID || old.State == UnitIntended || old.State == UnitStarted { return ErrChanged } } r.State = UnitIntended in.Reservations = append(in.Reservations, r) return nil }) } // TombstoneUnit spends a reservation root never saw, so a start that arrives // after its stop can never run. A reservation already present is left to // SettleUnit. func (j *LaunchJournal) TombstoneUnit(ctx context.Context, r LaunchReservation) error { if j == nil || j.remote != nil || j.db == nil { return ErrUnsupported } if !unitReservationShape(r) { return ErrUnsupported } return j.mutate(ctx, r.Target, func(in *LaunchInventory) error { for _, old := range in.Reservations { if old.ID == r.ID { return ErrChanged } } r.State = UnitSettled r.NeverIntended = true in.Reservations = append(in.Reservations, r) return nil }) } // StartedUnit records the unit root started for exactly the intended r: its // cgroup identity and invocation, its main process and that process's start. func (j *LaunchJournal) StartedUnit(ctx context.Context, r LaunchReservation, g LaunchGroup, pid int, processStart string) error { if j == nil || j.remote != nil || j.db == nil { return ErrUnsupported } if pid <= 0 || processStart == "" || g.RelativePath != r.Group.RelativePath || g.Device == 0 || g.Inode == 0 || g.InvocationID == "" { return ErrUnsupported } return j.mutate(ctx, r.Target, func(in *LaunchInventory) error { for i := range in.Reservations { old := &in.Reservations[i] if old.ID != r.ID { continue } expected := r expected.State = UnitIntended if *old != expected { return ErrChanged } old.State, old.Group, old.PID, old.ProcessStart = UnitStarted, g, pid, processStart return nil } return ErrChanged }) } // SettleUnit marks one unit reservation settled once root has proved its // invocation gone. Settling a settled reservation is a no-op. func (j *LaunchJournal) SettleUnit(ctx context.Context, t hostguard.FenceTarget, id string) error { if j == nil || j.remote != nil || j.db == nil || id == "" { return ErrUnsupported } return j.mutate(ctx, t, func(in *LaunchInventory) error { for i := range in.Reservations { old := &in.Reservations[i] if old.ID != id { continue } if !IsUnitReservation(*old) { return ErrChanged } old.State = UnitSettled return nil } return ErrChanged }) } // UnitReservation reads one unit reservation from a root-local journal. func (j *LaunchJournal) UnitReservation(ctx context.Context, t hostguard.FenceTarget, id string) (LaunchReservation, error) { if j == nil || j.remote != nil || j.db == nil { return LaunchReservation{}, ErrUnsupported } in, err := j.Read(ctx, t) if err != nil { return LaunchReservation{}, err } for _, r := range in.Reservations { if r.ID == id && IsUnitReservation(r) { return r, nil } } return LaunchReservation{}, ErrChanged } // IsUnitReservation reports whether r is a root unit reservation: intended, // started, or settled with a unit cgroup path. func IsUnitReservation(r LaunchReservation) bool { switch r.State { case UnitIntended, UnitStarted: return true case UnitSettled: return UnitGroupPath(r.Group.RelativePath) } return false } // CloseWriters only removes launch authority. The root transport serializes it // with isolated starts under the host guard, and there is no reopen operation. func (j *LaunchJournal) CloseWriters(ctx context.Context, target hostguard.FenceTarget) error { if j == nil { return ErrUnsupported } if j.remote != nil { _, err := journalCall(ctx, j.remote, JournalRequest{Op: "close-writers", Target: target}) return err } return j.mutate(ctx, target, func(in *LaunchInventory) error { in.WritersClosed = true; return nil }) } // UnitIntentions returns unstarted isolated reservations for this host across // all sessions and controllers. Root calls it under the host guard after // proving the unit empty, then settles the returned intentions before starting // a replacement. It is deliberately unavailable through the agent protocol. func (j *LaunchJournal) UnitIntentions(ctx context.Context, host string) ([]LaunchReservation, error) { if j == nil || j.remote != nil || j.db == nil || !component(host) { return nil, ErrUnsupported } rows, err := journalExecutorFor(ctx, j.db).QueryContext(ctx, "SELECT identity, body FROM launches") if err != nil { return nil, err } defer rows.Close() var out []LaunchReservation for rows.Next() { var key string var body []byte if err := rows.Scan(&key, &body); err != nil { return nil, err } var in LaunchInventory if err := json.Unmarshal(body, &in); err != nil { return nil, err } if !validLaunchOwner(in.Target) || launchKey(in.Target) != key { return nil, ErrTypedInventory } if in.Target.Host != host { continue } for _, r := range in.Reservations { if r.State != UnitIntended { continue } if !SameLaunchOwner(r.Target, in.Target) || !IsUnitReservation(r) { return nil, ErrTypedInventory } out = append(out, r) } } return out, rows.Err() }