| 1 | package hostbox |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "path" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/hostguard" |
| 14 | "github.com/carlosframework/platform/internal/registry" |
| 15 | ) |
| 16 | |
| 17 | // The reservation-bearing extension of main's isolated root launch protocol |
| 18 | // (claim Plan 11, R10). The agent asks root to run one reserved role |
| 19 | // through main's isolated instance unit. Root validates the reservation |
| 20 | // against its own claim, identity and journal providers, journals its |
| 21 | // intention before any effect, and records the unit invocation it started. |
| 22 | // The request names which reservation to run; nothing in it is authority. |
| 23 | const ( |
| 24 | RoleLaunchV = 1 |
| 25 | RoleLaunchProtocol = "isolated-unit-v3" |
| 26 | RoleLaunchLimit = 32 << 10 |
| 27 | |
| 28 | RoleLaunchCapability = "capability" |
| 29 | RoleLaunchStart = "start" |
| 30 | RoleLaunchStop = "stop" |
| 31 | RoleLaunchInputs = "unit-inputs" |
| 32 | |
| 33 | // Unit reservation states. Only root writes them, in its local launch |
| 34 | // journal. None of the agent's journal operations moves a unit |
| 35 | // reservation, and the stop journal reports a record unsettled while one |
| 36 | // is intended or started. A settled unit shares the stop journal's |
| 37 | // terminal state; its unit cgroup path tells it from an agent role. |
| 38 | UnitIntended = "unit-intended" |
| 39 | UnitStarted = "unit-started" |
| 40 | UnitSettled = "settled" |
| 41 | ) |
| 42 | |
| 43 | // ErrRoleLaunchShape refuses every role launch the isolated protocol does |
| 44 | // not carry: any role but the main role, any request field it does not |
| 45 | // define, and any agent-supplied containment or process identity. |
| 46 | var ErrRoleLaunchShape = fmt.Errorf("%w: role launch shape is not carried by the isolated root protocol", ErrUnsupported) |
| 47 | |
| 48 | // RoleLaunchRequest is one protocol call. Start carries the reservation the |
| 49 | // agent asks root to run, the build it pinned and the slice tier; stop names |
| 50 | // the same reservation; capability carries nothing. |
| 51 | type RoleLaunchRequest struct { |
| 52 | Route *registry.Route `json:"route,omitempty"` |
| 53 | Kind string `json:"kind,omitempty"` |
| 54 | Spec *LaunchSpec `json:"spec,omitempty"` |
| 55 | V int `json:"v"` |
| 56 | Op string `json:"op"` |
| 57 | Reservation *LaunchReservation `json:"reservation,omitempty"` |
| 58 | Executable string `json:"executable,omitempty"` |
| 59 | MemoryMax int64 `json:"memory_max,omitempty"` |
| 60 | } |
| 61 | |
| 62 | // RoleLaunchResponse answers every call with the protocol root speaks. A |
| 63 | // start answers with the grant root journaled. |
| 64 | type UnitLaunchInputs struct { |
| 65 | Unit, Socket, Addr, DBPath, Program string |
| 66 | Args, Env []string |
| 67 | } |
| 68 | |
| 69 | type RoleLaunchResponse struct { |
| 70 | Inputs *UnitLaunchInputs `json:"inputs,omitempty"` |
| 71 | V int `json:"v"` |
| 72 | Protocol string `json:"protocol"` |
| 73 | Grant *RoleLaunchGrant `json:"grant,omitempty"` |
| 74 | } |
| 75 | |
| 76 | // RoleLaunchGrant is root's record of the unit it started for a reservation: |
| 77 | // the unit, its systemd invocation, its main process and the identity of its |
| 78 | // cgroup. Group.InvocationID is the unit's invocation, not the attempt's. |
| 79 | type RoleLaunchGrant struct { |
| 80 | ReservationID string `json:"reservation_id"` |
| 81 | Unit string `json:"unit"` |
| 82 | UnitInvocationID string `json:"unit_invocation_id"` |
| 83 | PID int `json:"pid"` |
| 84 | ProcessStart string `json:"process_start"` |
| 85 | Group LaunchGroup `json:"group"` |
| 86 | } |
| 87 | |
| 88 | // UnitGroupPath reports whether p can name an isolated unit's cgroup: a |
| 89 | // clean relative path to a .service directory. |
| 90 | func UnitGroupPath(p string) bool { |
| 91 | return p != "" && filepath.IsLocal(p) && path.Clean(p) == p && strings.HasSuffix(p, ".service") && !strings.Contains(p, "\x00") |
| 92 | } |
| 93 | |
| 94 | // unitReservationShape is a start's reservation: the main role, an attempt, |
| 95 | // a pinned build and a complete owner tuple, with the unit's cgroup named but |
| 96 | // not identified, and nothing root alone records. |
| 97 | func unitReservationShape(r LaunchReservation) bool { |
| 98 | return !r.NeverIntended && r.ID != "" && r.Role == "main" && r.InvocationID != "" && r.Inputs != "" && r.State == "" && r.PID == 0 && r.ProcessStart == "" && |
| 99 | r.Parent == (LaunchGroup{}) && r.Executable.Device != 0 && r.Executable.Inode != 0 && validLaunchOwner(r.Target) && component(r.Target.Host) && |
| 100 | UnitGroupPath(r.Group.RelativePath) && r.Group.Device == 0 && r.Group.Inode == 0 && r.Group.InvocationID == "" |
| 101 | } |
| 102 | |
| 103 | // Validate checks the shape of a request; it grants nothing. |
| 104 | func (q RoleLaunchRequest) Validate() error { |
| 105 | if q.V != RoleLaunchV { |
| 106 | return ErrRoleLaunchShape |
| 107 | } |
| 108 | if q.Op != RoleLaunchInputs && q.Route != nil { |
| 109 | return ErrRoleLaunchShape |
| 110 | } |
| 111 | switch q.Op { |
| 112 | case RoleLaunchInputs: |
| 113 | if q.Route == nil || !component(q.Route.Host) || q.Kind != "" || q.Spec != nil || q.Reservation != nil || q.Executable != "" || q.MemoryMax != 0 { |
| 114 | return ErrRoleLaunchShape |
| 115 | } |
| 116 | case RoleLaunchCapability: |
| 117 | if q.Spec != nil || q.Reservation != nil || q.Executable != "" || q.MemoryMax != 0 { |
| 118 | return ErrRoleLaunchShape |
| 119 | } |
| 120 | case RoleLaunchStart: |
| 121 | if q.Kind != "" || q.Reservation == nil || !unitReservationShape(*q.Reservation) || !filepath.IsAbs(q.Executable) || filepath.Clean(q.Executable) != q.Executable || q.MemoryMax <= 0 { |
| 122 | return ErrRoleLaunchShape |
| 123 | } |
| 124 | case RoleLaunchStop: |
| 125 | if q.Kind != "" || q.Spec != nil || q.Reservation == nil || !unitReservationShape(*q.Reservation) || q.Executable != "" || q.MemoryMax != 0 { |
| 126 | return ErrRoleLaunchShape |
| 127 | } |
| 128 | default: |
| 129 | return ErrRoleLaunchShape |
| 130 | } |
| 131 | return nil |
| 132 | } |
| 133 | |
| 134 | // EncodeRoleLaunchRequest is the request's canonical body. |
| 135 | func EncodeRoleLaunchRequest(q RoleLaunchRequest) ([]byte, error) { return json.Marshal(q) } |
| 136 | |
| 137 | // DecodeRoleLaunchRequest strictly decodes one bounded request: no unknown |
| 138 | // field, no trailing value, no body above RoleLaunchLimit. |
| 139 | func DecodeRoleLaunchRequest(r io.Reader) (RoleLaunchRequest, error) { |
| 140 | var q RoleLaunchRequest |
| 141 | if err := strictRoleLaunch(r, &q); err != nil { |
| 142 | return RoleLaunchRequest{}, err |
| 143 | } |
| 144 | if err := q.Validate(); err != nil { |
| 145 | return RoleLaunchRequest{}, err |
| 146 | } |
| 147 | return q, nil |
| 148 | } |
| 149 | |
| 150 | // DecodeRoleLaunchResponse strictly decodes root's answer. |
| 151 | func DecodeRoleLaunchResponse(r io.Reader) (RoleLaunchResponse, error) { |
| 152 | var out RoleLaunchResponse |
| 153 | if err := strictRoleLaunch(r, &out); err != nil { |
| 154 | return RoleLaunchResponse{}, err |
| 155 | } |
| 156 | if out.V != RoleLaunchV || out.Protocol == "" { |
| 157 | return RoleLaunchResponse{}, ErrRoleLaunchShape |
| 158 | } |
| 159 | return out, nil |
| 160 | } |
| 161 | |
| 162 | func strictRoleLaunch(r io.Reader, v any) error { |
| 163 | raw, err := io.ReadAll(io.LimitReader(r, RoleLaunchLimit+1)) |
| 164 | if err != nil { |
| 165 | return err |
| 166 | } |
| 167 | if len(raw) > RoleLaunchLimit { |
| 168 | return ErrRoleLaunchShape |
| 169 | } |
| 170 | d := json.NewDecoder(bytes.NewReader(raw)) |
| 171 | d.DisallowUnknownFields() |
| 172 | if err := d.Decode(v); err != nil { |
| 173 | return ErrRoleLaunchShape |
| 174 | } |
| 175 | if d.Decode(new(any)) != io.EOF { |
| 176 | return ErrRoleLaunchShape |
| 177 | } |
| 178 | return nil |
| 179 | } |
| 180 | |
| 181 | // Check accepts a grant only for exactly r: the same reservation, the unit |
| 182 | // cgroup r named with a real identity, one unit invocation, and a process. |
| 183 | func (g RoleLaunchGrant) Check(r LaunchReservation) error { |
| 184 | if g.ReservationID != r.ID || g.Unit == "" || g.UnitInvocationID == "" || g.PID <= 0 || g.ProcessStart == "" || |
| 185 | g.Group.RelativePath != r.Group.RelativePath || !UnitGroupPath(g.Group.RelativePath) || g.Group.Device == 0 || g.Group.Inode == 0 || g.Group.InvocationID != g.UnitInvocationID { |
| 186 | return ErrChanged |
| 187 | } |
| 188 | return nil |
| 189 | } |
| 190 | |
| 191 | // IntendUnit durably records root's intention to start an isolated unit for |
| 192 | // r before any effect. Only a root-local journal accepts it. The prelaunch |
| 193 | // record must exist and its producers must not have been drained. A |
| 194 | // reservation ID or attempt invocation the record already holds, in any |
| 195 | // state, is a replay and refuses; so does a second live unit for the record. |
| 196 | func (j *LaunchJournal) IntendUnit(ctx context.Context, r LaunchReservation) error { |
| 197 | if j == nil || j.remote != nil || j.db == nil { |
| 198 | return ErrUnsupported |
| 199 | } |
| 200 | if !unitReservationShape(r) { |
| 201 | return ErrUnsupported |
| 202 | } |
| 203 | return j.mutate(ctx, r.Target, func(in *LaunchInventory) error { |
| 204 | if in.WritersClosed { |
| 205 | return ErrChanged |
| 206 | } |
| 207 | for _, old := range in.Reservations { |
| 208 | if old.ID == r.ID || old.InvocationID == r.InvocationID || old.State == UnitIntended || old.State == UnitStarted { |
| 209 | return ErrChanged |
| 210 | } |
| 211 | } |
| 212 | r.State = UnitIntended |
| 213 | in.Reservations = append(in.Reservations, r) |
| 214 | return nil |
| 215 | }) |
| 216 | } |
| 217 | |
| 218 | // TombstoneUnit spends a reservation root never saw, so a start that arrives |
| 219 | // after its stop can never run. A reservation already present is left to |
| 220 | // SettleUnit. |
| 221 | func (j *LaunchJournal) TombstoneUnit(ctx context.Context, r LaunchReservation) error { |
| 222 | if j == nil || j.remote != nil || j.db == nil { |
| 223 | return ErrUnsupported |
| 224 | } |
| 225 | if !unitReservationShape(r) { |
| 226 | return ErrUnsupported |
| 227 | } |
| 228 | return j.mutate(ctx, r.Target, func(in *LaunchInventory) error { |
| 229 | for _, old := range in.Reservations { |
| 230 | if old.ID == r.ID { |
| 231 | return ErrChanged |
| 232 | } |
| 233 | } |
| 234 | r.State = UnitSettled |
| 235 | r.NeverIntended = true |
| 236 | in.Reservations = append(in.Reservations, r) |
| 237 | return nil |
| 238 | }) |
| 239 | } |
| 240 | |
| 241 | // StartedUnit records the unit root started for exactly the intended r: its |
| 242 | // cgroup identity and invocation, its main process and that process's start. |
| 243 | func (j *LaunchJournal) StartedUnit(ctx context.Context, r LaunchReservation, g LaunchGroup, pid int, processStart string) error { |
| 244 | if j == nil || j.remote != nil || j.db == nil { |
| 245 | return ErrUnsupported |
| 246 | } |
| 247 | if pid <= 0 || processStart == "" || g.RelativePath != r.Group.RelativePath || g.Device == 0 || g.Inode == 0 || g.InvocationID == "" { |
| 248 | return ErrUnsupported |
| 249 | } |
| 250 | return j.mutate(ctx, r.Target, func(in *LaunchInventory) error { |
| 251 | for i := range in.Reservations { |
| 252 | old := &in.Reservations[i] |
| 253 | if old.ID != r.ID { |
| 254 | continue |
| 255 | } |
| 256 | expected := r |
| 257 | expected.State = UnitIntended |
| 258 | if *old != expected { |
| 259 | return ErrChanged |
| 260 | } |
| 261 | old.State, old.Group, old.PID, old.ProcessStart = UnitStarted, g, pid, processStart |
| 262 | return nil |
| 263 | } |
| 264 | return ErrChanged |
| 265 | }) |
| 266 | } |
| 267 | |
| 268 | // SettleUnit marks one unit reservation settled once root has proved its |
| 269 | // invocation gone. Settling a settled reservation is a no-op. |
| 270 | func (j *LaunchJournal) SettleUnit(ctx context.Context, t hostguard.FenceTarget, id string) error { |
| 271 | if j == nil || j.remote != nil || j.db == nil || id == "" { |
| 272 | return ErrUnsupported |
| 273 | } |
| 274 | return j.mutate(ctx, t, func(in *LaunchInventory) error { |
| 275 | for i := range in.Reservations { |
| 276 | old := &in.Reservations[i] |
| 277 | if old.ID != id { |
| 278 | continue |
| 279 | } |
| 280 | if !IsUnitReservation(*old) { |
| 281 | return ErrChanged |
| 282 | } |
| 283 | old.State = UnitSettled |
| 284 | return nil |
| 285 | } |
| 286 | return ErrChanged |
| 287 | }) |
| 288 | } |
| 289 | |
| 290 | // UnitReservation reads one unit reservation from a root-local journal. |
| 291 | func (j *LaunchJournal) UnitReservation(ctx context.Context, t hostguard.FenceTarget, id string) (LaunchReservation, error) { |
| 292 | if j == nil || j.remote != nil || j.db == nil { |
| 293 | return LaunchReservation{}, ErrUnsupported |
| 294 | } |
| 295 | in, err := j.Read(ctx, t) |
| 296 | if err != nil { |
| 297 | return LaunchReservation{}, err |
| 298 | } |
| 299 | for _, r := range in.Reservations { |
| 300 | if r.ID == id && IsUnitReservation(r) { |
| 301 | return r, nil |
| 302 | } |
| 303 | } |
| 304 | return LaunchReservation{}, ErrChanged |
| 305 | } |
| 306 | |
| 307 | // IsUnitReservation reports whether r is a root unit reservation: intended, |
| 308 | // started, or settled with a unit cgroup path. |
| 309 | func IsUnitReservation(r LaunchReservation) bool { |
| 310 | switch r.State { |
| 311 | case UnitIntended, UnitStarted: |
| 312 | return true |
| 313 | case UnitSettled: |
| 314 | return UnitGroupPath(r.Group.RelativePath) |
| 315 | } |
| 316 | return false |
| 317 | } |
| 318 | |
| 319 | // CloseWriters only removes launch authority. The root transport serializes it |
| 320 | // with isolated starts under the host guard, and there is no reopen operation. |
| 321 | func (j *LaunchJournal) CloseWriters(ctx context.Context, target hostguard.FenceTarget) error { |
| 322 | if j == nil { |
| 323 | return ErrUnsupported |
| 324 | } |
| 325 | if j.remote != nil { |
| 326 | _, err := journalCall(ctx, j.remote, JournalRequest{Op: "close-writers", Target: target}) |
| 327 | return err |
| 328 | } |
| 329 | return j.mutate(ctx, target, func(in *LaunchInventory) error { in.WritersClosed = true; return nil }) |
| 330 | } |
| 331 | |
| 332 | // UnitIntentions returns unstarted isolated reservations for this host across |
| 333 | // all sessions and controllers. Root calls it under the host guard after |
| 334 | // proving the unit empty, then settles the returned intentions before starting |
| 335 | // a replacement. It is deliberately unavailable through the agent protocol. |
| 336 | func (j *LaunchJournal) UnitIntentions(ctx context.Context, host string) ([]LaunchReservation, error) { |
| 337 | if j == nil || j.remote != nil || j.db == nil || !component(host) { |
| 338 | return nil, ErrUnsupported |
| 339 | } |
| 340 | rows, err := journalExecutorFor(ctx, j.db).QueryContext(ctx, "SELECT identity, body FROM launches") |
| 341 | if err != nil { |
| 342 | return nil, err |
| 343 | } |
| 344 | defer rows.Close() |
| 345 | var out []LaunchReservation |
| 346 | for rows.Next() { |
| 347 | var key string |
| 348 | var body []byte |
| 349 | if err := rows.Scan(&key, &body); err != nil { |
| 350 | return nil, err |
| 351 | } |
| 352 | var in LaunchInventory |
| 353 | if err := json.Unmarshal(body, &in); err != nil { |
| 354 | return nil, err |
| 355 | } |
| 356 | if !validLaunchOwner(in.Target) || launchKey(in.Target) != key { |
| 357 | return nil, ErrTypedInventory |
| 358 | } |
| 359 | if in.Target.Host != host { |
| 360 | continue |
| 361 | } |
| 362 | for _, r := range in.Reservations { |
| 363 | if r.State != UnitIntended { |
| 364 | continue |
| 365 | } |
| 366 | if !SameLaunchOwner(r.Target, in.Target) || !IsUnitReservation(r) { |
| 367 | return nil, ErrTypedInventory |
| 368 | } |
| 369 | out = append(out, r) |
| 370 | } |
| 371 | } |
| 372 | return out, rows.Err() |
| 373 | } |
| 374 | |