| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "crypto/sha256" |
| 8 | "database/sql" |
| 9 | "encoding/hex" |
| 10 | "encoding/json" |
| 11 | "errors" |
| 12 | "fmt" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/hostbox" |
| 18 | "github.com/carlosframework/platform/internal/hostclaim" |
| 19 | "github.com/carlosframework/platform/internal/hostevidence" |
| 20 | "github.com/carlosframework/platform/internal/hostguard" |
| 21 | "github.com/carlosframework/platform/internal/release" |
| 22 | ) |
| 23 | |
| 24 | var ( |
| 25 | ErrClaimStopUnavailable = errors.New("claim stop: required root provider unavailable") |
| 26 | ErrClaimStopIdentity = errors.New("claim stop: authenticated identity mismatch") |
| 27 | ErrClaimStopInventory = errors.New("claim stop: inventory does not match request") |
| 28 | ErrClaimStopReceipt = errors.New("claim stop: immutable receipt conflict") |
| 29 | ) |
| 30 | |
| 31 | type StopPeer struct { |
| 32 | UID uint32 |
| 33 | PID int |
| 34 | ProcessStart string |
| 35 | BootID string |
| 36 | ControllerID string |
| 37 | } |
| 38 | |
| 39 | type StopPeerProvider interface { |
| 40 | AuthenticatedPeer(context.Context) (StopPeer, error) |
| 41 | } |
| 42 | |
| 43 | type StopInventoryEntry = hostbox.StopInventoryEntry |
| 44 | type StopInventory = hostbox.StopInventory |
| 45 | |
| 46 | type StopInventoryReader interface { |
| 47 | ReadStopInventory(context.Context, string) (StopInventory, error) |
| 48 | } |
| 49 | |
| 50 | type StopRequest struct { |
| 51 | Region string |
| 52 | Cleanup bool |
| 53 | // Recover names a launch tuple journaled by an earlier controller process |
| 54 | // or boot. The live controller asks; root proves that controller dead. |
| 55 | Recover bool |
| 56 | Host, Incarnation string |
| 57 | InstallationStamp, WriterSession uint64 |
| 58 | OperationID, Phase, ObligationID string |
| 59 | Box, Enrollment string |
| 60 | BootID, ControllerID string |
| 61 | ExpectedClaimETag string |
| 62 | ExpectedDirectoryRevision string |
| 63 | // RosterRevision is the roster write counter of the roster the requester |
| 64 | // read. Root persists it in the fence target; it becomes the receipt's |
| 65 | // directory_revision numeral (roster binding N1). |
| 66 | RosterRevision uint64 |
| 67 | } |
| 68 | |
| 69 | type StopClaimReader interface { |
| 70 | Read(context.Context, string) (hostclaim.Observed, error) |
| 71 | } |
| 72 | |
| 73 | type DrainAcknowledgement struct { |
| 74 | Completed hostbox.ProducerCompletion `json:",omitzero"` |
| 75 | ProducerIDs []string |
| 76 | FenceID string |
| 77 | ControllerID string |
| 78 | ProcessStart string |
| 79 | Digest string |
| 80 | } |
| 81 | |
| 82 | type StopDrainer interface { |
| 83 | DrainClaim(context.Context, StopRequest, string) (DrainAcknowledgement, error) |
| 84 | } |
| 85 | |
| 86 | type StopKernelEvidence struct { |
| 87 | ObservedBootID string |
| 88 | InventorySHA256 string |
| 89 | LocalJournalSHA256 string |
| 90 | CgroupIdentities []string |
| 91 | UnitInvocationIDs []string |
| 92 | } |
| 93 | |
| 94 | type StopKernelVerifier interface { |
| 95 | VerifyStopped(context.Context, hostguard.FenceTarget, StopInventory, DrainAcknowledgement) (StopKernelEvidence, error) |
| 96 | } |
| 97 | |
| 98 | // StopReceipt is root's signed stop evidence. DirectoryRevision is the |
| 99 | // roster write counter, a numeral, taken from the fence target's persisted |
| 100 | // RosterRevision: the v0.28.4 console bounds it by 0 < n <= roster.Revision. |
| 101 | // Binding is the installation's binding; box-side receipt comparisons use it |
| 102 | // (roster binding N1, R1). |
| 103 | type StopReceipt struct { |
| 104 | RequestLimitNS int64 `json:"request_limit_ns"` |
| 105 | Region string `json:"region,omitempty"` |
| 106 | ClaimETag string `json:"claim_etag,omitempty"` |
| 107 | ObservedBootID string `json:"observed_boot_id,omitempty"` |
| 108 | V int `json:"v"` |
| 109 | ID string `json:"id"` |
| 110 | Host string `json:"host"` |
| 111 | Incarnation string `json:"incarnation"` |
| 112 | InstallationStamp uint64 `json:"installation_stamp"` |
| 113 | WriterSession uint64 `json:"writer_session"` |
| 114 | OperationID string `json:"operation_id"` |
| 115 | Phase string `json:"phase"` |
| 116 | ObligationID string `json:"obligation_id"` |
| 117 | Box string `json:"box"` |
| 118 | Enrollment string `json:"enrollment"` |
| 119 | BootID string `json:"boot_id"` |
| 120 | ControllerID string `json:"controller_id"` |
| 121 | ClaimGen uint64 `json:"claim_gen"` |
| 122 | DirectoryRevision string `json:"directory_revision"` |
| 123 | Binding string `json:"binding,omitempty"` |
| 124 | Kind string `json:"kind"` |
| 125 | EvidenceAt time.Time `json:"evidence_at"` |
| 126 | FenceID string `json:"fence_id"` |
| 127 | InventorySHA256 string `json:"inventory_sha256"` |
| 128 | LocalJournalSHA256 string `json:"local_journal_sha256"` |
| 129 | ProducerDrainDigest string `json:"producer_drain_digest"` |
| 130 | CgroupIdentities []string `json:"cgroup_identities"` |
| 131 | UnitInvocationIDs []string `json:"unit_invocation_ids"` |
| 132 | } |
| 133 | |
| 134 | type StopResult struct { |
| 135 | Target hostguard.FenceTarget |
| 136 | Key string |
| 137 | Digest string |
| 138 | Bytes []byte |
| 139 | EvidenceAt time.Time |
| 140 | } |
| 141 | |
| 142 | type ClaimStopCoordinator struct { |
| 143 | RetainNoProcess func(context.Context, hostclaim.Observed, StopResult) error |
| 144 | // VerifiedRosterRevision is the write counter of the roster root reads |
| 145 | // and verifies itself at the moment of the call, quarantined or not. |
| 146 | // It bounds an agent-sent RosterRevision before any fence or receipt |
| 147 | // takes it (boundRosterRevision); nil refuses every stop. |
| 148 | VerifiedRosterRevision func(context.Context) (uint64, error) |
| 149 | EvidenceSigner ed25519.PrivateKey |
| 150 | RequestLimit time.Duration |
| 151 | Guard hostguard.Guard |
| 152 | Claims StopClaimReader |
| 153 | Peers StopPeerProvider |
| 154 | Inventory StopInventoryReader |
| 155 | Drainer StopDrainer |
| 156 | Kernel StopKernelVerifier |
| 157 | Evidence release.Store |
| 158 | CarlosUID uint32 |
| 159 | NewID func() string |
| 160 | Now func() time.Time |
| 161 | } |
| 162 | |
| 163 | func stopComponent(s string) bool { |
| 164 | return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\\\x00") |
| 165 | } |
| 166 | |
| 167 | func stopDigest(s string) bool { |
| 168 | if len(s) != sha256.Size*2 { |
| 169 | return false |
| 170 | } |
| 171 | _, err := hex.DecodeString(s) |
| 172 | return err == nil |
| 173 | } |
| 174 | |
| 175 | func (c ClaimStopCoordinator) validateRequest(r StopRequest) error { |
| 176 | if r.WriterSession == 0 { |
| 177 | return ErrClaimStopUnavailable |
| 178 | } |
| 179 | return c.validateShape(r) |
| 180 | } |
| 181 | |
| 182 | // validateShape is validateRequest without the session: the no-process |
| 183 | // witness takes writer session 0. |
| 184 | func (c ClaimStopCoordinator) validateShape(r StopRequest) error { |
| 185 | if len(c.EvidenceSigner) != ed25519.PrivateKeySize || c.RequestLimit != hostbox.RequestLimit { |
| 186 | return ErrClaimStopUnavailable |
| 187 | } |
| 188 | if c.CarlosUID == 0 || c.Peers == nil || c.Claims == nil || c.Inventory == nil || c.Drainer == nil || c.Kernel == nil || c.Evidence == nil || c.NewID == nil || c.Now == nil || |
| 189 | !stopComponent(r.Host) || !stopOperationTuple(r.OperationID, r.Phase, r.ObligationID) || !stopComponent(r.Box) || |
| 190 | !stopComponent(r.Enrollment) || r.Incarnation == "" || r.InstallationStamp == 0 || |
| 191 | r.BootID == "" || r.ControllerID == "" || r.ExpectedClaimETag == "" || r.ExpectedDirectoryRevision == "" || r.RosterRevision == 0 { |
| 192 | return ErrClaimStopUnavailable |
| 193 | } |
| 194 | return nil |
| 195 | } |
| 196 | |
| 197 | // boundRosterRevision refuses an agent-sent receipt numeral outside |
| 198 | // 0 < n <= the revision of the roster root reads and verifies now (fix |
| 199 | // round 1, finding 3), the bound the v0.28.4 console verifiers apply |
| 200 | // against the roster they read. Roster revisions only grow, so a numeral |
| 201 | // inside it now stays inside it for every later verifier. It runs before a |
| 202 | // fence is begun or a no-process receipt is made; a retry that resumes a |
| 203 | // fence still keeps the fence's original numeral (B2). |
| 204 | func (c ClaimStopCoordinator) boundRosterRevision(ctx context.Context, n uint64) error { |
| 205 | if c.VerifiedRosterRevision == nil { |
| 206 | return ErrClaimStopUnavailable |
| 207 | } |
| 208 | current, err := c.VerifiedRosterRevision(ctx) |
| 209 | if err != nil { |
| 210 | return err |
| 211 | } |
| 212 | if n == 0 || n > current { |
| 213 | return ErrClaimStopIdentity |
| 214 | } |
| 215 | return nil |
| 216 | } |
| 217 | |
| 218 | // stopReceiptNumeral is a receipt's directory_revision, read from the one |
| 219 | // persisted source: the fence target's RosterRevision. A target without one |
| 220 | // (written before roster binding) cannot produce a receipt. |
| 221 | func stopReceiptNumeral(t hostguard.FenceTarget) (string, error) { |
| 222 | if t.RosterRevision == 0 { |
| 223 | return "", ErrClaimStopReceipt |
| 224 | } |
| 225 | return strconv.FormatUint(t.RosterRevision, 10), nil |
| 226 | } |
| 227 | |
| 228 | // receiptRosterRevision parses a receipt's directory_revision numeral: a |
| 229 | // canonical non-zero decimal. |
| 230 | func receiptRosterRevision(s string) (uint64, bool) { |
| 231 | n, err := strconv.ParseUint(s, 10, 64) |
| 232 | if err != nil || n == 0 || strconv.FormatUint(n, 10) != s { |
| 233 | return 0, false |
| 234 | } |
| 235 | return n, true |
| 236 | } |
| 237 | |
| 238 | // The receipt kinds: a stopped writer session, and the no-process witness |
| 239 | // for an installation whose holder never admitted a writer (R4). |
| 240 | const ( |
| 241 | StopReceiptStopped = "stopped" |
| 242 | StopReceiptNoProcess = "no-process" |
| 243 | ) |
| 244 | |
| 245 | // stopNoProcessKernel is root's kernel half of the session-zero witness: no |
| 246 | // launch journal entry for the host, its host containment empty or absent, |
| 247 | // and no process holding any of the installation's files. |
| 248 | type stopNoProcessKernel interface { |
| 249 | VerifyNoProcess(context.Context, hostguard.FenceTarget, StopInventory) (StopKernelEvidence, error) |
| 250 | } |
| 251 | |
| 252 | func (c ClaimStopCoordinator) FenceAndStop(ctx context.Context, req StopRequest) (StopResult, error) { |
| 253 | if req.WriterSession == 0 { |
| 254 | return c.witnessNoProcess(ctx, req) |
| 255 | } |
| 256 | if ctx == nil || c.validateRequest(req) != nil || (req.Cleanup && req.Recover) { |
| 257 | return StopResult{}, ErrClaimStopUnavailable |
| 258 | } |
| 259 | var recovery stopRecoveryKernel |
| 260 | if req.Recover { |
| 261 | var ok bool |
| 262 | if recovery, ok = c.Kernel.(stopRecoveryKernel); !ok { |
| 263 | return StopResult{}, ErrClaimStopUnavailable |
| 264 | } |
| 265 | } |
| 266 | peer, err := c.Peers.AuthenticatedPeer(ctx) |
| 267 | if err != nil { |
| 268 | return StopResult{}, err |
| 269 | } |
| 270 | if peer.UID != c.CarlosUID || peer.PID <= 0 || peer.ProcessStart == "" || peer.BootID == "" || peer.ControllerID == "" { |
| 271 | return StopResult{}, ErrClaimStopIdentity |
| 272 | } |
| 273 | // Only recovery may name another tuple, and only for this box's session. |
| 274 | if !req.Recover && (peer.BootID != req.BootID || peer.ControllerID != req.ControllerID) { |
| 275 | return StopResult{}, ErrClaimStopIdentity |
| 276 | } |
| 277 | var observed hostclaim.Observed |
| 278 | var cleanupTarget *hostguard.FenceTarget |
| 279 | if req.Cleanup { |
| 280 | want := hostguard.FenceTarget{Region: req.Region, Host: req.Host, Incarnation: req.Incarnation, Stamp: req.InstallationStamp, Session: req.WriterSession, Op: req.OperationID, Phase: req.Phase, ObligationID: req.ObligationID, Box: req.Box, Enrollment: req.Enrollment, BootID: req.BootID, ControllerID: req.ControllerID, ClaimETag: req.ExpectedClaimETag, DirectoryRevision: req.ExpectedDirectoryRevision, RosterRevision: req.RosterRevision} |
| 281 | durable, err := c.Guard.ExistingFence(ctx, want) |
| 282 | if err != nil { |
| 283 | return StopResult{}, err |
| 284 | } |
| 285 | cleanupTarget = &durable |
| 286 | } else { |
| 287 | observed, err = c.Claims.Read(ctx, req.Host) |
| 288 | if err != nil { |
| 289 | return StopResult{}, err |
| 290 | } |
| 291 | if observed.Absent || observed.Claim == nil || observed.Claim.Holder == nil || observed.ETag != req.ExpectedClaimETag || |
| 292 | observed.Claim.Host != req.Host || observed.Claim.Holder.Incarnation != req.Incarnation || |
| 293 | observed.Claim.Holder.Stamp != req.InstallationStamp || observed.Claim.Holder.Session != req.WriterSession || |
| 294 | !hostguard.StopAuthorityMatches(observed.Claim, hostguard.FenceTarget{Region: req.Region, Host: req.Host, Incarnation: req.Incarnation, Stamp: req.InstallationStamp, Session: req.WriterSession, Op: req.OperationID, Phase: req.Phase, ObligationID: req.ObligationID, Box: req.Box}) { |
| 295 | return StopResult{}, ErrClaimStopIdentity |
| 296 | } |
| 297 | |
| 298 | } |
| 299 | var inv StopInventory |
| 300 | if cleanupTarget != nil { |
| 301 | reader, ok := c.Inventory.(exactStopInventory) |
| 302 | if !ok { |
| 303 | return StopResult{}, ErrClaimStopInventory |
| 304 | } |
| 305 | inv, err = reader.ReadExactStopInventory(ctx, *cleanupTarget) |
| 306 | } else { |
| 307 | inv, err = c.Inventory.ReadStopInventory(ctx, req.Host) |
| 308 | } |
| 309 | if err != nil { |
| 310 | return StopResult{}, err |
| 311 | } |
| 312 | if inv.Host != req.Host || inv.Incarnation != req.Incarnation || inv.InstallationStamp != req.InstallationStamp || |
| 313 | inv.Box != req.Box || inv.Enrollment != req.Enrollment || inv.DirectoryRevision != req.ExpectedDirectoryRevision { |
| 314 | return StopResult{}, ErrClaimStopInventory |
| 315 | } |
| 316 | fenceID, receiptID := c.NewID(), c.NewID() |
| 317 | if !stopComponent(fenceID) || !stopComponent(receiptID) { |
| 318 | return StopResult{}, ErrClaimStopUnavailable |
| 319 | } |
| 320 | if err := c.boundRosterRevision(ctx, req.RosterRevision); err != nil { |
| 321 | return StopResult{}, err |
| 322 | } |
| 323 | gen := uint64(0) |
| 324 | if observed.Claim != nil { |
| 325 | gen = observed.Claim.Gen |
| 326 | } |
| 327 | target := hostguard.FenceTarget{Region: req.Region, ClaimGen: gen, ObligationID: req.ObligationID, FenceID: fenceID, Host: req.Host, Incarnation: req.Incarnation, Stamp: req.InstallationStamp, |
| 328 | Session: req.WriterSession, Op: req.OperationID, Phase: req.Phase, Box: req.Box, Enrollment: req.Enrollment, |
| 329 | BootID: req.BootID, ControllerID: req.ControllerID, ClaimETag: req.ExpectedClaimETag, DirectoryRevision: req.ExpectedDirectoryRevision, RosterRevision: req.RosterRevision} |
| 330 | if cleanupTarget != nil { |
| 331 | target = *cleanupTarget |
| 332 | err = c.Guard.ResumeFence(ctx, target) |
| 333 | } else { |
| 334 | target, err = c.Guard.BeginFence(ctx, target) |
| 335 | } |
| 336 | if err != nil { |
| 337 | return StopResult{}, err |
| 338 | } |
| 339 | fenceID = target.FenceID |
| 340 | // The receipt numeral comes from the persisted fence target, never from |
| 341 | // this request: a retry after roster writes resumes the original fence |
| 342 | // and its original numeral (B2). A fence persisted without one cannot |
| 343 | // produce a receipt and is refused before any drain or kill (N1). |
| 344 | numeral, err := stopReceiptNumeral(target) |
| 345 | if err != nil { |
| 346 | return StopResult{}, err |
| 347 | } |
| 348 | if durable, err := c.Guard.FenceReceiptFor(ctx, target); err == nil { |
| 349 | result, err := stopResultFromDurable(req, durable) |
| 350 | if err != nil { |
| 351 | return StopResult{}, err |
| 352 | } |
| 353 | if !req.Cleanup { |
| 354 | if err := c.Publish(ctx, result); err != nil { |
| 355 | return result, err |
| 356 | } |
| 357 | } |
| 358 | return result, nil |
| 359 | } else if !errors.Is(err, hostguard.ErrNoFenceReceipt) { |
| 360 | return StopResult{}, err |
| 361 | } |
| 362 | var ack DrainAcknowledgement |
| 363 | var kernel StopKernelEvidence |
| 364 | if req.Recover { |
| 365 | // Root reads the tuple's own launch record and proves its controller |
| 366 | // dead, or the boot gone, before it kills, verifies and settles. A |
| 367 | // missing record or a live controller refuses; the fence stays pending. |
| 368 | kernel, ack, err = c.recoverKernel(ctx, recovery, target, inv) |
| 369 | if err != nil { |
| 370 | return StopResult{}, err |
| 371 | } |
| 372 | if ack.FenceID != fenceID || ack.ControllerID != req.ControllerID || ack.ProcessStart == "" || !stopDigest(ack.Digest) || |
| 373 | (req.BootID == peer.BootID && req.ControllerID == peer.ControllerID && ack.ProcessStart == peer.ProcessStart) { |
| 374 | return StopResult{}, ErrClaimStopIdentity |
| 375 | } |
| 376 | } else if absent, absentErr := c.verifyUnjournaled(ctx, target, inv, sql.ErrNoRows); absentErr == nil { |
| 377 | kernel = absent |
| 378 | ack = DrainClaimAcknowledgement(fenceID, req.ControllerID, peer.ProcessStart, nil, hostbox.ProducerCompletion{}) |
| 379 | } else { |
| 380 | ack, err = c.Drainer.DrainClaim(ctx, req, fenceID) |
| 381 | if err != nil { |
| 382 | return StopResult{}, err |
| 383 | } |
| 384 | if ack.FenceID != fenceID || ack.ControllerID != req.ControllerID || ack.ProcessStart != peer.ProcessStart || !stopDigest(ack.Digest) { |
| 385 | return StopResult{}, ErrClaimStopIdentity |
| 386 | } |
| 387 | kernel, err = c.Kernel.VerifyStopped(ctx, target, inv, ack) |
| 388 | if err != nil { |
| 389 | kernel, err = c.verifyUnjournaled(ctx, target, inv, err) |
| 390 | } |
| 391 | if err != nil { |
| 392 | return StopResult{}, err |
| 393 | } |
| 394 | } |
| 395 | evidenceAt := c.Now().UTC() |
| 396 | if evidenceAt.IsZero() || !stopDigest(kernel.InventorySHA256) || !stopDigest(kernel.LocalJournalSHA256) || len(kernel.CgroupIdentities) == 0 { |
| 397 | return StopResult{}, ErrClaimStopReceipt |
| 398 | } |
| 399 | receipt := StopReceipt{RequestLimitNS: int64(c.RequestLimit), Region: req.Region, ClaimETag: target.ClaimETag, ObservedBootID: kernel.ObservedBootID, V: 1, ID: receiptID, Host: req.Host, Incarnation: req.Incarnation, |
| 400 | InstallationStamp: req.InstallationStamp, WriterSession: req.WriterSession, OperationID: req.OperationID, |
| 401 | Phase: req.Phase, ObligationID: req.ObligationID, Box: req.Box, Enrollment: req.Enrollment, |
| 402 | BootID: req.BootID, ControllerID: req.ControllerID, ClaimGen: target.ClaimGen, |
| 403 | DirectoryRevision: numeral, Binding: target.DirectoryRevision, Kind: StopReceiptStopped, EvidenceAt: evidenceAt, FenceID: fenceID, |
| 404 | InventorySHA256: kernel.InventorySHA256, LocalJournalSHA256: kernel.LocalJournalSHA256, |
| 405 | ProducerDrainDigest: ack.Digest, CgroupIdentities: append([]string(nil), kernel.CgroupIdentities...), |
| 406 | UnitInvocationIDs: append([]string(nil), kernel.UnitInvocationIDs...)} |
| 407 | body, err := json.Marshal(receipt) |
| 408 | if err != nil { |
| 409 | return StopResult{}, err |
| 410 | } |
| 411 | verify := func(_ context.Context, got hostguard.FenceTarget, exact []byte, at time.Time) error { |
| 412 | if got != target || !at.Equal(evidenceAt) || !bytes.Equal(exact, body) { |
| 413 | return ErrClaimStopReceipt |
| 414 | } |
| 415 | return nil |
| 416 | } |
| 417 | if err := c.Guard.CompleteFence(ctx, target, body, evidenceAt, verify); err != nil { |
| 418 | return StopResult{}, err |
| 419 | } |
| 420 | key := stopEvidenceKey(req.Host, req.OperationID, req.Box, receiptID) |
| 421 | sum := sha256.Sum256(body) |
| 422 | result := StopResult{Target: target, Key: key, Digest: hex.EncodeToString(sum[:]), Bytes: append([]byte(nil), body...), EvidenceAt: evidenceAt} |
| 423 | if !req.Cleanup { |
| 424 | if err := c.Publish(ctx, result); err != nil { |
| 425 | return result, err |
| 426 | } |
| 427 | } |
| 428 | return result, nil |
| 429 | } |
| 430 | |
| 431 | // witnessNoProcess is the positive no-process witness for writer session 0 |
| 432 | // (claim Plan 11, R4): the holder never admitted a writer, root's launch |
| 433 | // journal has no entry for the host, the host's containment is empty or |
| 434 | // absent, and root's descriptor scan finds no holder of the installation's |
| 435 | // files. No session ever ran, so there is no fence and no producer drain: |
| 436 | // the receipt binds the claim's ETag, which any admission changes, and root |
| 437 | // re-reads the claim after its observation. The receipt is published like |
| 438 | // any stop receipt. |
| 439 | func (c ClaimStopCoordinator) witnessNoProcess(ctx context.Context, req StopRequest) (StopResult, error) { |
| 440 | if ctx == nil || req.WriterSession != 0 || req.Cleanup || req.Recover || c.validateShape(req) != nil { |
| 441 | return StopResult{}, ErrClaimStopUnavailable |
| 442 | } |
| 443 | kernel, ok := c.Kernel.(stopNoProcessKernel) |
| 444 | if !ok { |
| 445 | return StopResult{}, ErrClaimStopUnavailable |
| 446 | } |
| 447 | peer, err := c.Peers.AuthenticatedPeer(ctx) |
| 448 | if err != nil { |
| 449 | return StopResult{}, err |
| 450 | } |
| 451 | if peer.UID != c.CarlosUID || peer.PID <= 0 || peer.ProcessStart == "" || peer.BootID != req.BootID || peer.ControllerID != req.ControllerID { |
| 452 | return StopResult{}, ErrClaimStopIdentity |
| 453 | } |
| 454 | observed, err := c.Claims.Read(ctx, req.Host) |
| 455 | if err != nil { |
| 456 | return StopResult{}, err |
| 457 | } |
| 458 | if observed.Absent || observed.Claim == nil || observed.Claim.Holder == nil || observed.ETag != req.ExpectedClaimETag || observed.Claim.Host != req.Host { |
| 459 | return StopResult{}, ErrClaimStopIdentity |
| 460 | } |
| 461 | claim, holder := observed.Claim, observed.Claim.Holder |
| 462 | if holder.Incarnation != req.Incarnation || holder.Stamp != req.InstallationStamp || holder.Session != 0 || (holder.WriterBox != "" && (c.RetainNoProcess == nil || holder.WriterBox != req.Box || claim.Op.Kind != hostclaim.KindServing)) || holder.Generation != "" || len(holder.Admissions) != 0 { |
| 463 | return StopResult{}, ErrClaimStopIdentity |
| 464 | } |
| 465 | target := hostguard.FenceTarget{Region: req.Region, ClaimGen: claim.Gen, ObligationID: req.ObligationID, Host: req.Host, Incarnation: req.Incarnation, Stamp: req.InstallationStamp, |
| 466 | Op: req.OperationID, Phase: req.Phase, Box: req.Box, Enrollment: req.Enrollment, BootID: req.BootID, ControllerID: req.ControllerID, |
| 467 | ClaimETag: req.ExpectedClaimETag, DirectoryRevision: req.ExpectedDirectoryRevision, RosterRevision: req.RosterRevision} |
| 468 | if claim.Op.Kind == hostclaim.KindServing { |
| 469 | if req.OperationID != "" || req.Region == "" || claim.Op.Exec != req.Box { |
| 470 | return StopResult{}, ErrClaimStopIdentity |
| 471 | } |
| 472 | } else if !hostguard.StopAuthorityMatches(claim, target) { |
| 473 | return StopResult{}, ErrClaimStopIdentity |
| 474 | } |
| 475 | if err := c.boundRosterRevision(ctx, req.RosterRevision); err != nil { |
| 476 | return StopResult{}, err |
| 477 | } |
| 478 | // No session ran, so there is no fence: the witness's own target is the |
| 479 | // receipt's one source, retained with the result. |
| 480 | numeral, err := stopReceiptNumeral(target) |
| 481 | if err != nil { |
| 482 | return StopResult{}, err |
| 483 | } |
| 484 | inv, err := c.Inventory.ReadStopInventory(ctx, req.Host) |
| 485 | if err != nil { |
| 486 | return StopResult{}, err |
| 487 | } |
| 488 | if inv.Host != req.Host || inv.Incarnation != req.Incarnation || inv.InstallationStamp != req.InstallationStamp || |
| 489 | inv.Box != req.Box || inv.Enrollment != req.Enrollment || inv.DirectoryRevision != req.ExpectedDirectoryRevision { |
| 490 | return StopResult{}, ErrClaimStopInventory |
| 491 | } |
| 492 | kernelEvidence, err := kernel.VerifyNoProcess(ctx, target, inv) |
| 493 | if err != nil { |
| 494 | return StopResult{}, err |
| 495 | } |
| 496 | again, err := c.Claims.Read(ctx, req.Host) |
| 497 | if err != nil { |
| 498 | return StopResult{}, err |
| 499 | } |
| 500 | if again.ETag != req.ExpectedClaimETag { |
| 501 | return StopResult{}, ErrClaimStopIdentity |
| 502 | } |
| 503 | evidenceAt := c.Now().UTC() |
| 504 | receiptID := c.NewID() |
| 505 | if evidenceAt.IsZero() || !stopComponent(receiptID) || !stopDigest(kernelEvidence.InventorySHA256) || !stopDigest(kernelEvidence.LocalJournalSHA256) || len(kernelEvidence.CgroupIdentities) == 0 { |
| 506 | return StopResult{}, ErrClaimStopReceipt |
| 507 | } |
| 508 | receipt := StopReceipt{RequestLimitNS: int64(c.RequestLimit), Region: req.Region, ClaimETag: req.ExpectedClaimETag, ObservedBootID: kernelEvidence.ObservedBootID, V: 1, ID: receiptID, Host: req.Host, |
| 509 | Incarnation: req.Incarnation, InstallationStamp: req.InstallationStamp, OperationID: req.OperationID, Phase: req.Phase, ObligationID: req.ObligationID, |
| 510 | Box: req.Box, Enrollment: req.Enrollment, BootID: req.BootID, ControllerID: req.ControllerID, ClaimGen: claim.Gen, DirectoryRevision: numeral, Binding: target.DirectoryRevision, |
| 511 | Kind: StopReceiptNoProcess, EvidenceAt: evidenceAt, InventorySHA256: kernelEvidence.InventorySHA256, LocalJournalSHA256: kernelEvidence.LocalJournalSHA256, |
| 512 | CgroupIdentities: append([]string(nil), kernelEvidence.CgroupIdentities...), UnitInvocationIDs: append([]string{}, kernelEvidence.UnitInvocationIDs...)} |
| 513 | body, err := json.Marshal(receipt) |
| 514 | if err != nil { |
| 515 | return StopResult{}, err |
| 516 | } |
| 517 | sum := sha256.Sum256(body) |
| 518 | result := StopResult{Target: target, Key: stopEvidenceKey(req.Host, req.OperationID, req.Box, receiptID), Digest: hex.EncodeToString(sum[:]), Bytes: body, EvidenceAt: evidenceAt} |
| 519 | if c.RetainNoProcess != nil && claim.Op.Kind == hostclaim.KindServing { |
| 520 | if err := c.RetainNoProcess(ctx, observed, result); err != nil { |
| 521 | return StopResult{}, err |
| 522 | } |
| 523 | } |
| 524 | if err := c.Publish(ctx, result); err != nil { |
| 525 | return result, err |
| 526 | } |
| 527 | return result, nil |
| 528 | } |
| 529 | |
| 530 | func stopResultFromDurable(req StopRequest, durable hostguard.FenceReceipt) (StopResult, error) { |
| 531 | var receipt StopReceipt |
| 532 | if err := json.Unmarshal(durable.Bytes, &receipt); err != nil { |
| 533 | return StopResult{}, ErrClaimStopReceipt |
| 534 | } |
| 535 | if receipt.Region != req.Region || receipt.V != 1 || receipt.ID == "" || receipt.Host != req.Host || receipt.Incarnation != req.Incarnation || |
| 536 | receipt.InstallationStamp != req.InstallationStamp || receipt.WriterSession != req.WriterSession || |
| 537 | receipt.OperationID != req.OperationID || receipt.Phase != req.Phase || receipt.ObligationID != req.ObligationID || |
| 538 | receipt.Box != req.Box || receipt.Enrollment != req.Enrollment || receipt.BootID != req.BootID || |
| 539 | receipt.ControllerID != req.ControllerID || receipt.FenceID != durable.Target.FenceID || |
| 540 | !receipt.EvidenceAt.Equal(durable.EvidenceAt) || !bytes.Equal(durable.Bytes, mustMarshalStopReceipt(receipt)) { |
| 541 | return StopResult{}, ErrClaimStopReceipt |
| 542 | } |
| 543 | key := stopEvidenceKey(req.Host, req.OperationID, req.Box, receipt.ID) |
| 544 | sum := sha256.Sum256(durable.Bytes) |
| 545 | return StopResult{Target: durable.Target, Key: key, Digest: hex.EncodeToString(sum[:]), Bytes: append([]byte(nil), durable.Bytes...), EvidenceAt: durable.EvidenceAt}, nil |
| 546 | } |
| 547 | |
| 548 | func mustMarshalStopReceipt(receipt StopReceipt) []byte { |
| 549 | b, _ := json.Marshal(receipt) |
| 550 | return b |
| 551 | } |
| 552 | |
| 553 | // Publish retries only the exact bytes and key returned by FenceAndStop. |
| 554 | func (c ClaimStopCoordinator) Publish(ctx context.Context, result StopResult) error { |
| 555 | if ctx == nil || c.Evidence == nil || result.Key == "" || len(result.Bytes) == 0 || result.EvidenceAt.IsZero() { |
| 556 | return ErrClaimStopUnavailable |
| 557 | } |
| 558 | var receipt StopReceipt |
| 559 | if err := json.Unmarshal(result.Bytes, &receipt); err != nil { |
| 560 | return ErrClaimStopReceipt |
| 561 | } |
| 562 | wantKey := stopEvidenceKey(receipt.Host, receipt.OperationID, receipt.Box, receipt.ID) |
| 563 | sum := sha256.Sum256(result.Bytes) |
| 564 | if result.Key != wantKey || result.Digest != hex.EncodeToString(sum[:]) || !result.EvidenceAt.Equal(receipt.EvidenceAt) || |
| 565 | !bytes.Equal(result.Bytes, mustMarshalStopReceipt(receipt)) { |
| 566 | return ErrClaimStopReceipt |
| 567 | } |
| 568 | if len(c.EvidenceSigner) != ed25519.PrivateKeySize || c.RequestLimit != hostbox.RequestLimit || receipt.RequestLimitNS != int64(c.RequestLimit) { |
| 569 | return ErrClaimStopUnavailable |
| 570 | } |
| 571 | return hostevidence.Publish(ctx, c.Evidence, c.EvidenceSigner, result.Key, result.Bytes) |
| 572 | } |
| 573 | |
| 574 | func stopOperationTuple(op, phase, obligation string) bool { |
| 575 | if op == "" || phase == "" || obligation == "" { |
| 576 | return op == "" && phase == "" && obligation == "" |
| 577 | } |
| 578 | return stopComponent(op) && stopComponent(phase) && stopComponent(obligation) |
| 579 | } |
| 580 | func stopEvidenceKey(host, op, box, id string) string { |
| 581 | if op == "" { |
| 582 | return fmt.Sprintf("control/hosts/%s/serving-evidence/%s/%s.json", host, box, id) |
| 583 | } |
| 584 | return fmt.Sprintf("control/hosts/%s/evidence/%s/%s/%s.json", host, op, box, id) |
| 585 | } |
| 586 | |