| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "github.com/carlosframework/platform/internal/hostbox" |
| 7 | "github.com/carlosframework/platform/internal/hostclaim" |
| 8 | "github.com/carlosframework/platform/internal/hostguard" |
| 9 | "github.com/carlosframework/platform/internal/provision" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "testing" |
| 13 | ) |
| 14 | |
| 15 | func plan14RecoveryRebind(t *testing.T) (*recoveryFixture, ClaimRootHTTP, *rollbackDisk, recoveryRebindRequest) { |
| 16 | t.Helper() |
| 17 | f, o, h := plan12Handover(t, hostclaim.KindRestoring, "stop") |
| 18 | holder := o.Claim.Holder |
| 19 | plan14PutRecord(t, f.claims, provision.Record{Schema: 1, Host: o.Host, Account: holder.Account, App: holder.App, Channel: "stable", CreatedAt: f.now}) |
| 20 | disk := &rollbackDisk{dir: t.TempDir()} |
| 21 | g := f.guard |
| 22 | g.Root = hostguard.NewRootAuthority(disk) |
| 23 | in := hostguard.RootInstallation{Host: o.Host, Incarnation: holder.Incarnation, Stamp: holder.Stamp, Migration: 1, InventoryRevision: 1, Account: holder.Account, App: holder.App, Record: holder.Key, Box: "box-1", Enrollment: "enrollment-1"} |
| 24 | if err := g.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | next, err := hostclaim.Update(t.Context(), f.claims, o, hostclaim.AdmitWriter(hostclaim.Admission{ObservedOpID: o.Claim.Op.ID, ObservedPhase: o.Claim.Op.Phase, ObservedSession: holder.Session, ObservedGeneration: holder.Generation, Box: "box-1", Mode: "cold", NewGeneration: "recovered", Region: "ie", PredecessorRegion: "ie"})) |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | n := next.Claim.Holder |
| 32 | _, etag, err := f.claims.Get(t.Context(), holder.Key) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | actor := hostbox.Actor{Box: "box-1", Enrollment: "enrollment-1", Revision: 7, Binding: "7"} |
| 37 | root := ClaimRootHTTP{Stop: ClaimStopHTTP{Coordinator: ClaimStopCoordinator{Guard: g, Evidence: f.claims}}, InventoryActor: func(context.Context) (hostbox.Actor, error) { return actor, nil }, Roots: map[string]string{"data": g.DataDir}, Reconcile: hostbox.ReconcileAdapter{Inventory: h.Inventory, Registry: h.Registry, Box: actor.Box, Enrollment: actor.Enrollment}} |
| 38 | q := recoveryRebindRequest{V: 1, Prior: hostguard.Captured{Incarnation: holder.Incarnation, Stamp: holder.Stamp}, Revision: 1, RecordKey: holder.Key, RecordETag: etag, Next: hostbox.ColdSuccessor{Host: next.Host, Incarnation: n.Incarnation, Stamp: n.Stamp, Session: n.Session, Generation: n.Generation, Box: actor.Box, Enrollment: actor.Enrollment, DirectoryRevision: "7", ETag: next.ETag, Op: next.Claim.Op.ID, Phase: next.Claim.Op.Phase}} |
| 39 | return f, root, disk, q |
| 40 | } |
| 41 | func TestPlan14RecoveryRebindAuthority(t *testing.T) { |
| 42 | t.Parallel() |
| 43 | for _, attack := range []string{"none", "claim etag", "stamp", "session", "generation", "op", "phase", "box", "enrollment", "record etag", "record binding", "revision", "source identity", "missing root", "missing inventory", "plain stopped report", "before commit"} { |
| 44 | t.Run(attack, func(t *testing.T) { |
| 45 | f, root, disk, q := plan14RecoveryRebind(t) |
| 46 | switch attack { |
| 47 | case "claim etag": |
| 48 | q.Next.ETag = "stale" |
| 49 | case "stamp": |
| 50 | q.Next.Stamp++ |
| 51 | case "session": |
| 52 | q.Next.Session++ |
| 53 | case "generation": |
| 54 | q.Next.Generation = "other" |
| 55 | case "op": |
| 56 | q.Next.Op = "other" |
| 57 | case "phase": |
| 58 | q.Next.Phase = "other" |
| 59 | case "box": |
| 60 | q.Next.Box = "other" |
| 61 | case "enrollment": |
| 62 | q.Next.Enrollment = "other" |
| 63 | case "record etag": |
| 64 | q.RecordETag = "stale" |
| 65 | case "record binding": |
| 66 | body, oldETag, err := f.claims.Get(t.Context(), q.RecordKey) |
| 67 | if err != nil { |
| 68 | t.Fatal(err) |
| 69 | } |
| 70 | var rec provision.Record |
| 71 | json.Unmarshal(body, &rec) |
| 72 | rec.Account = "other" |
| 73 | body, _ = json.Marshal(rec) |
| 74 | q.RecordETag, err = f.claims.Put(t.Context(), q.RecordKey, body, oldETag) |
| 75 | if err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | case "revision": |
| 79 | q.Revision++ |
| 80 | case "source identity": |
| 81 | q.Prior.Stamp++ |
| 82 | case "missing root": |
| 83 | if err := os.Remove(filepath.Join(disk.dir, q.Next.Host+".json")); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | case "missing inventory": |
| 87 | root.Reconcile.Inventory, _ = openLegacyInventory(t, filepath.Join(t.TempDir(), "missing.db")) |
| 88 | t.Cleanup(func() { root.Reconcile.Inventory.Close() }) |
| 89 | case "plain stopped report": |
| 90 | o := f.read() |
| 91 | o.Claim.Holder.Admissions = nil |
| 92 | body, _ := json.Marshal(o.Claim) |
| 93 | var err error |
| 94 | key, _ := hostclaim.Key(o.Host) |
| 95 | q.Next.ETag, err = f.claims.Put(t.Context(), key, body, o.ETag) |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | case "before commit": |
| 100 | disk.afterWrite = func(string, []byte) { |
| 101 | disk.afterWrite = nil |
| 102 | o := f.read() |
| 103 | _, err := hostclaim.Update(t.Context(), f.claims, o, func(c *hostclaim.Claim) error { c.Op.Phase = "changed"; return nil }) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | before, _ := disk.Read(q.Next.Host + ".json") |
| 110 | row, _ := root.Reconcile.Registry.Get(q.Next.Host) |
| 111 | err := root.rebindRecoveryInventory(t.Context(), q) |
| 112 | if attack != "none" { |
| 113 | if err == nil { |
| 114 | t.Fatal("untrusted rebind accepted") |
| 115 | } |
| 116 | after, _ := disk.Read(q.Next.Host + ".json") |
| 117 | if attack != "before commit" && string(before) != string(after) { |
| 118 | t.Fatal("refusal changed root") |
| 119 | } |
| 120 | _, rev, e := root.Reconcile.Inventory.Snapshot(t.Context(), q.Next.Host, hostguard.Captured{Incarnation: q.Next.Incarnation, Stamp: q.Next.Stamp}) |
| 121 | if e != nil || rev != 0 { |
| 122 | t.Fatal("refusal wrote successor", rev, e) |
| 123 | } |
| 124 | return |
| 125 | } |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | after, _ := root.Reconcile.Registry.Get(q.Next.Host) |
| 130 | if after.ClaimOwnership != row.ClaimOwnership { |
| 131 | t.Fatal("root rebound registry") |
| 132 | } |
| 133 | saved, _ := disk.Read(q.Next.Host + ".json") |
| 134 | if err = root.rebindRecoveryInventory(t.Context(), q); err != nil { |
| 135 | t.Fatal("replay", err) |
| 136 | } |
| 137 | again, _ := disk.Read(q.Next.Host + ".json") |
| 138 | if string(saved) != string(again) { |
| 139 | t.Fatal("replay changed root") |
| 140 | } |
| 141 | _, rev, err := root.Reconcile.Inventory.Snapshot(t.Context(), q.Next.Host, hostguard.Captured{Incarnation: q.Next.Incarnation, Stamp: q.Next.Stamp}) |
| 142 | if err != nil || rev != 1 { |
| 143 | t.Fatal(rev, err) |
| 144 | } |
| 145 | }) |
| 146 | } |
| 147 | } |
| 148 | |