| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostbox" |
| 15 | "github.com/carlosframework/platform/internal/hostclaim" |
| 16 | "github.com/carlosframework/platform/internal/hostguard" |
| 17 | "github.com/carlosframework/platform/internal/hostops" |
| 18 | "github.com/carlosframework/platform/internal/registry" |
| 19 | "github.com/carlosframework/platform/internal/rootstate" |
| 20 | ) |
| 21 | |
| 22 | type migrationTimerFault struct { |
| 23 | rootstate.Store |
| 24 | fail bool |
| 25 | } |
| 26 | |
| 27 | func (s *migrationTimerFault) Write(n string, b []byte) error { |
| 28 | if err := s.Store.Write(n, b); err != nil { |
| 29 | return err |
| 30 | } |
| 31 | if s.fail { |
| 32 | s.fail = false |
| 33 | return os.ErrInvalid |
| 34 | } |
| 35 | return nil |
| 36 | } |
| 37 | func TestPlan14RootMigrationTimerAdoption(t *testing.T) { |
| 38 | testRootMigrationTimerAdoption(t, func(t *testing.T) rootstate.Store { return &rollbackDisk{dir: t.TempDir()} }, nil) |
| 39 | } |
| 40 | func testRootMigrationTimerAdoption(t *testing.T, open func(*testing.T) rootstate.Store, prepareRegistry func(*testing.T, *ClaimRuntimeResources)) { |
| 41 | for _, attack := range []string{"none", "intent-sync", "after-row", "after-inventory", "after-root", "registration", "contents", "foreign-owner", "erased-row", "extra-row", "missing-receipt", "missing-deny"} { |
| 42 | t.Run(attack, func(t *testing.T) { |
| 43 | store, r, owners, _ := fix4JournalFixture(t) |
| 44 | if prepareRegistry != nil { |
| 45 | prepareRegistry(t, &r) |
| 46 | } |
| 47 | actor, err := owners.Identity.Verify(t.Context()) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | target, receipt := migrationHistoricFixture(t, store, r, owners.ClaimOwnerBindings, actor, &hostops.BackfillPlan{Hosts: map[string]hostops.BackfillHost{}}) |
| 52 | j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "root.db")) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | defer j.Close() |
| 57 | svc, err := hostbox.NewJournalService(j) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | if err := svc.MigrateLegacy(t.Context(), filepath.Join(r.DataDir, ".hostclaims"), actor.Box, actor.Enrollment, func(context.Context) (func(), error) { return func() {}, nil }, legacyJournalProof(store, owners.RetirementAuthority.Directory)); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | fstate := &migrationTimerFault{Store: open(t)} |
| 65 | defer fstate.Close() |
| 66 | root := hostguard.NewRootAuthority(fstate) |
| 67 | guard := hostguard.Guard{Root: root, LockDir: t.TempDir(), DataDir: r.DataDir, Claims: hostguard.StoreReader{Store: store}} |
| 68 | item := rootMigrationItem{Installation: hostguard.RootInstallation{Host: target.Host, Incarnation: target.Incarnation, Stamp: target.Stamp, Account: "bab", App: "app", Record: "apps/bab/app/instances/" + target.Host + ".json", Box: target.Box, Enrollment: target.Enrollment, Migration: 1}, Session: target.Session, ClaimETag: target.ClaimETag} |
| 69 | if attack != "missing-deny" { |
| 70 | if err := root.RetainMigrationDeny(t.Context(), item.Installation, item.Session); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | } |
| 74 | if attack == "missing-deny" { |
| 75 | if err := guard.EstablishRoot(t.Context(), item.Installation, func(*hostclaim.Claim) error { return nil }); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | } |
| 79 | if attack != "missing-receipt" { |
| 80 | if _, err := guard.BeginFence(t.Context(), target); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | if err := guard.CompleteFence(t.Context(), target, receipt, time.Now(), func(context.Context, hostguard.FenceTarget, []byte, time.Time) error { return nil }); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | } |
| 87 | state := &migrationTimerFault{Store: open(t), fail: attack == "intent-sync"} |
| 88 | defer state.Close() |
| 89 | m := rootMigrationTimers{state: state, registry: r.Registry, inventory: svc.Inventory, guard: guard} |
| 90 | original, err := r.Registry.GetTimer(target.Host, "refresh") |
| 91 | if err != nil { |
| 92 | t.Fatal(err) |
| 93 | } |
| 94 | canonical := registry.Route{Host: target.Host, Account: "bab", App: "app"} |
| 95 | if attack == "missing-receipt" || attack == "missing-deny" { |
| 96 | if m.adopt(t.Context(), item, target, canonical) == nil { |
| 97 | t.Fatal("adoption without stopped root authority") |
| 98 | } |
| 99 | got, err := r.Registry.GetTimer(target.Host, "refresh") |
| 100 | if err != nil || *got != *original { |
| 101 | t.Fatal("row changed before root authority", got, err) |
| 102 | } |
| 103 | if _, exists, err := m.load(item); err != nil || exists { |
| 104 | t.Fatal("intent written before root authority", exists, err) |
| 105 | } |
| 106 | return |
| 107 | } |
| 108 | if attack != "none" { |
| 109 | state.fail = true |
| 110 | if m.adopt(t.Context(), item, target, canonical) == nil { |
| 111 | t.Fatal("intent sync acknowledged") |
| 112 | } |
| 113 | got, _ := r.Registry.GetTimer(target.Host, "refresh") |
| 114 | if *got != *original { |
| 115 | t.Fatal("row changed before durable intent") |
| 116 | } |
| 117 | rec, ok, err := m.load(item) |
| 118 | if err != nil || !ok { |
| 119 | t.Fatal(ok, err) |
| 120 | } |
| 121 | owned := rec.Rows[0] |
| 122 | owned.ClaimOwnership = m.owner(item, owned.Registration) |
| 123 | switch attack { |
| 124 | case "after-row", "after-inventory", "after-root": |
| 125 | if err := r.Registry.AdoptClaimTimer(t.Context(), rec.Rows[0], owned.ClaimOwnership); err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | if attack != "after-row" { |
| 129 | if err := svc.Inventory.WriteStopInventory(t.Context(), rec.Next); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | } |
| 133 | if attack == "after-root" { |
| 134 | fstate.fail = true |
| 135 | if err := root.CompleteMigrationInventory(t.Context(), target, rec.Revision+1, rec.Artifacts, rec.Revision); err == nil { |
| 136 | t.Fatal("root sync acknowledged") |
| 137 | } |
| 138 | } |
| 139 | case "foreign-owner": |
| 140 | if err := r.Registry.AdoptClaimTimer(t.Context(), *original, registry.ClaimOwnership{ClaimIncarnation: "foreign", ClaimStamp: 2, ClaimOwner: "foreign", ClaimRowID: original.Registration}); err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | case "registration", "contents": |
| 144 | changed := *original |
| 145 | if attack == "registration" { |
| 146 | changed.Registration = "replacement" |
| 147 | } |
| 148 | if attack == "contents" { |
| 149 | changed.Path = "/changed" |
| 150 | } |
| 151 | if err := r.Registry.ReplaceTimers(target.Host, []registry.Timer{changed}); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | case "erased-row": |
| 155 | if err := r.Registry.DeleteTimer(target.Host, "refresh"); err != nil { |
| 156 | t.Fatal(err) |
| 157 | } |
| 158 | case "extra-row": |
| 159 | if err := r.Registry.UpsertTimer(registry.Timer{Host: target.Host, Name: "extra", Kind: "cron", Spec: "0 0 * * *", Path: "/extra"}); err != nil { |
| 160 | t.Fatal(err) |
| 161 | } |
| 162 | } |
| 163 | } |
| 164 | err = m.adopt(t.Context(), item, target, canonical) |
| 165 | refused := attack == "registration" || attack == "contents" || attack == "foreign-owner" || attack == "erased-row" || attack == "extra-row" |
| 166 | if (err != nil) != refused { |
| 167 | t.Fatalf("refused=%v: %v", refused, err) |
| 168 | } |
| 169 | if refused { |
| 170 | return |
| 171 | } |
| 172 | if err = m.adopt(t.Context(), item, target, canonical); err != nil { |
| 173 | t.Fatal("replay", err) |
| 174 | } |
| 175 | got, err := r.Registry.GetTimer(target.Host, "refresh") |
| 176 | want := *original |
| 177 | want.ClaimOwnership = m.owner(item, original.Registration) |
| 178 | if err != nil || *got != want { |
| 179 | t.Fatal("identity or bookkeeping changed", got, err) |
| 180 | } |
| 181 | in, rev, err := svc.Inventory.Snapshot(t.Context(), target.Host, hostguard.Captured{Incarnation: target.Incarnation, Stamp: target.Stamp}) |
| 182 | if err != nil || rev != 2 { |
| 183 | t.Fatal("inventory revision", rev, err) |
| 184 | } |
| 185 | e := in.Entries[1] |
| 186 | if e.ManagedRoot != "registry" || e.RowIdentity != original.Registration || e.Ownership != want.ClaimOwner { |
| 187 | t.Fatal("timer not inventoried", e) |
| 188 | } |
| 189 | retained, err := root.Installation(t.Context(), target.Host, hostguard.Captured{Incarnation: target.Incarnation, Stamp: target.Stamp}) |
| 190 | if err != nil || retained.InventoryRevision != rev { |
| 191 | t.Fatal("root revision", retained, err) |
| 192 | } |
| 193 | var decoded StopReceipt |
| 194 | if json.Unmarshal(receipt, &decoded) != nil { |
| 195 | t.Fatal("receipt") |
| 196 | } |
| 197 | saved, err := guard.FenceReceiptFor(t.Context(), target) |
| 198 | if err != nil || string(saved.Bytes) != string(receipt) { |
| 199 | t.Fatal("receipt changed", err) |
| 200 | } |
| 201 | if _, err := state.Read("absent"); !errors.Is(err, os.ErrNotExist) { |
| 202 | t.Fatal(err) |
| 203 | } |
| 204 | }) |
| 205 | } |
| 206 | } |
| 207 | |