carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package hostbox
2
3import (
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.
23const (
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.
46var 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.
51type 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.
64type UnitLaunchInputs struct {
65 Unit, Socket, Addr, DBPath, Program string
66 Args, Env []string
67}
68
69type 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.
79type 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.
90func 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.
97func 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.
104func (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.
135func 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.
139func 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.
151func 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
162func 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.
183func (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.
196func (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.
221func (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.
243func (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.
270func (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.
291func (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.
309func 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.
321func (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.
336func (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