package host import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net" "net/http" "reflect" "slices" "strconv" "time" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostguard" ) type recoveryRebindRequest struct { V int Prior hostguard.Captured Revision uint64 RecordKey, RecordETag string Next hostbox.ColdSuccessor } type recoveryRebindIntent struct{ Recovery *recoveryRebindRequest } func recoveryInventory(in hostbox.StopInventory, next hostbox.ColdSuccessor) hostbox.StopInventory { in.Entries = append([]hostbox.StopInventoryEntry(nil), in.Entries...) in.InstallationStamp, in.DirectoryRevision = next.Stamp, next.DirectoryRevision sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%d\x00%s", next.Host, next.Incarnation, next.Stamp, next.Box))) owner := hex.EncodeToString(sum[:]) for i := range in.Entries { e := &in.Entries[i] e.Ownership = owner if e.Kind == "route" || e.Kind == "timer" { name := e.RelativePath if e.Kind == "timer" { name = name[len(next.Host)+1:] } sum := sha256.Sum256([]byte(owner + "\x00" + e.Kind + "\x00" + name)) e.RowIdentity = hex.EncodeToString(sum[:]) } } return in } func (h ClaimRootHTTP) rebindRecoveryInventory(ctx context.Context, q recoveryRebindRequest) error { guard := h.Stop.Coordinator.Guard if guard.Root == nil || h.Reconcile.Inventory == nil || h.InventoryActor == nil || q.V != 1 || q.Revision == 0 || q.RecordKey == "" || q.RecordETag == "" { return hostbox.ErrUnsupported } if h.RequireNewWork != nil { if err := h.RequireNewWork(ctx); err != nil { return err } } actor, err := h.InventoryActor(ctx) if err != nil { return err } n := q.Next if n.Box != actor.Box || n.Enrollment != actor.Enrollment || actor.Binding == "" || n.DirectoryRevision != actor.Binding || n.Incarnation != q.Prior.Incarnation || n.Stamp <= q.Prior.Stamp || n.Session != n.Stamp || n.Generation == "" || n.ETag == "" { return hostbox.ErrChanged } source, revision, err := h.Reconcile.Inventory.Snapshot(ctx, n.Host, q.Prior) if err != nil { return err } // The predecessor is reused only under today's binding (roster binding // R3): an installation stamped under another binding (before a // quarantine, or a numeral) is refused before any reuse, so the rebound // inventory copies a verified value. if revision != q.Revision || !source.Complete || source.Host != n.Host || source.Incarnation != q.Prior.Incarnation || source.InstallationStamp != q.Prior.Stamp || source.Box != actor.Box || source.Enrollment != actor.Enrollment || source.DirectoryRevision != actor.Binding || len(source.Entries) == 0 { return hostbox.ErrTypedInventory } observed, err := hostclaim.Read(ctx, h.Stop.Coordinator.Evidence, n.Host) if err != nil { return err } if observed.Claim == nil || observed.Claim.Holder == nil { return hostbox.ErrChanged } holder := observed.Claim.Holder next := hostguard.RootInstallation{Host: n.Host, Incarnation: n.Incarnation, Stamp: n.Stamp, Account: holder.Account, App: holder.App, Record: q.RecordKey, Box: n.Box, Enrollment: n.Enrollment, Migration: 1, InventoryRevision: 1} var rebound hostbox.StopInventory check := func(c *hostclaim.Claim) error { if slices.Contains(c.Lost, n.Box) { return hostbox.ErrChanged } fresh, err := hostclaim.Read(ctx, h.Stop.Coordinator.Evidence, n.Host) if err != nil { return err } if fresh.Absent || fresh.ETag != n.ETag || !reflect.DeepEqual(c, fresh.Claim) || c.Holder == nil { return hostbox.ErrChanged } holder := c.Holder stamp, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalSourceStamp], 10, 64) if err != nil || stamp != q.Prior.Stamp || !claimRecoveryKind(c.Op.Kind) || c.Op.Phase != hostclaim.PhaseProbing || c.Op.ID != n.Op || c.Op.Phase != n.Phase || c.Op.Exec != n.Box || c.Op.Intent["source"] != n.Box || holder.Key != q.RecordKey || holder.Incarnation != n.Incarnation || holder.Stamp != n.Stamp || holder.Session != n.Session || holder.Generation != n.Generation || holder.WriterBox != n.Box { return hostbox.ErrChanged } if len(holder.Admissions) == 0 { return hostbox.ErrChanged } a := holder.Admissions[len(holder.Admissions)-1] if a.Mode != "cold" || a.Session != n.Session || a.Generation != n.Generation || a.Box != n.Box { return hostbox.ErrChanged } canonical, etag, err := rootClaimRecord(ctx, h.Stop.Coordinator.Evidence, c, h.Roots["data"]) if err != nil { return err } if etag != q.RecordETag || canonical.Host != n.Host || canonical.Account != next.Account || canonical.App != next.App { return hostbox.ErrChanged } canonical.ClaimIncarnation, canonical.ClaimStamp = q.Prior.Incarnation, q.Prior.Stamp routes := 0 for _, e := range source.Entries { if e.Kind == "route" { routes++ canonical.ClaimOwner, canonical.ClaimRowID = e.Ownership, e.RowIdentity } } if routes != 1 || hostbox.ValidateAgentInventory(source, &canonical, h.Roots["data"]) != nil { return hostbox.ErrTypedInventory } current, rev, err := h.Reconcile.Inventory.Snapshot(ctx, n.Host, q.Prior) if err != nil { return err } if rev != q.Revision || !reflect.DeepEqual(source, current) { return hostbox.ErrChanged } rebound = recoveryInventory(source, n) return nil } raw, err := json.Marshal(recoveryRebindIntent{Recovery: &q}) if err != nil { return err } return guard.RebindRootInventory(ctx, q.Prior, q.Revision, next, raw, check, func() error { current, rev, err := h.Reconcile.Inventory.Snapshot(ctx, n.Host, hostguard.Captured{Incarnation: n.Incarnation, Stamp: n.Stamp}) if err != nil { return err } if rev != 0 { if rev != 1 || !reflect.DeepEqual(current, rebound) { return hostbox.ErrChanged } return nil } return h.Reconcile.Inventory.WriteStopInventory(ctx, rebound) }) } func (h ClaimRootHTTP) serveRecoveryRebind(w http.ResponseWriter, r *http.Request) { var q recoveryRebindRequest dec := json.NewDecoder(io.LimitReader(r.Body, 32<<10)) dec.DisallowUnknownFields() if dec.Decode(&q) != nil || dec.Decode(new(any)) != io.EOF { w.WriteHeader(http.StatusBadRequest) return } if err := h.rebindRecoveryInventory(r.Context(), q); err != nil { w.WriteHeader(http.StatusConflict) return } w.WriteHeader(http.StatusNoContent) } func recoveryRebindClient(socket string) func(context.Context, recoveryRebindRequest) error { return func(ctx context.Context, q recoveryRebindRequest) error { body, err := json.Marshal(q) if err != nil { return err } transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", socket) }} defer transport.CloseIdleConnections() client := &http.Client{Transport: transport, Timeout: 30 * time.Second} for attempt := 0; attempt < 2; attempt++ { req, e := http.NewRequestWithContext(ctx, http.MethodPost, "http://instance-control/claim/recovery-rebind", bytes.NewReader(body)) if e != nil { return e } response, e := client.Do(req) if e != nil { err = e continue } response.Body.Close() if response.StatusCode != http.StatusNoContent { return hostbox.ErrChanged } return nil } return err } }