| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/ed25519" |
| 6 | "errors" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/hostwrite" |
| 14 | "github.com/carlosframework/platform/internal/provision" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | ) |
| 17 | |
| 18 | func TestPlan14Incident20261006WithLedger(t *testing.T) { |
| 19 | t.Parallel() |
| 20 | for _, state := range []string{"refused", "missing", "malformed", "unwritable"} { |
| 21 | t.Run(state, func(t *testing.T) { |
| 22 | e, rt, u, c, _ := plan14AdoptionFixture(t) |
| 23 | h := seedHostRootHistory(t, rt) |
| 24 | b := release.HistoryBinding{Host: rt.Host, Account: rt.Account, App: rt.App} |
| 25 | if err := h.AddRefusal(b, release.Refusal{Host: rt.Host, App: rt.App, Version: "v2", Kind: release.RefusalWrongProgram, RefusedAt: time.Now()}); err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | mem := h.Store.(*rollbackMemory) |
| 29 | switch state { |
| 30 | case "missing": |
| 31 | mem.files = map[string][]byte{} |
| 32 | case "malformed": |
| 33 | for k := range mem.files { |
| 34 | mem.files[k] = []byte("{") |
| 35 | } |
| 36 | case "unwritable": |
| 37 | mem.failWrite = true |
| 38 | } |
| 39 | pub, key, err := ed25519.GenerateKey(nil) |
| 40 | if err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | if err := e.store.Delete(t.Context(), release.ChannelKey(rt.Account, rt.App, rt.Channel)); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | if err := release.WriteDigestMarker(t.Context(), e.store, release.RootSigner(key), rt.Account, "test"); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if err := release.Promote(t.Context(), e.store, key, rt.Account, rt.App, rt.Channel, "v1"); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | if _, _, err := release.StageSignedHead(t.Context(), e.store, pub, nil, u.opt, rt); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | if err := release.StageArtifact(u.opt, rt.Account, "v2", rt.App, []byte("new build")); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | if err := release.PointLive(u.opt, rt.Host, rt.Account, "v2", rt.App); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | u.props.ActiveState, u.props.SubState, u.props.MainPID = "failed", "failed", 0 |
| 62 | source := &proofBindingSource{store: e.store, sys: u, optDir: u.opt, openFile: os.Open, unitDirs: []string{filepath.Dir(u.props.FragmentPath)}} |
| 63 | recovery := namedUnitRecovery{source: source, pub: pub, writes: hostwrite.Config{}, readLive: os.Readlink, history: func() (*release.RootHistoryPass, error) { return &release.RootHistoryPass{History: h}, nil }} |
| 64 | done, err := recovery.repair(t.Context(), rt.Host) |
| 65 | if err != nil || !done { |
| 66 | t.Fatalf("signed recovery: %v %v", done, err) |
| 67 | } |
| 68 | if got, err := release.LiveVersion(u.opt, rt.Host); err != nil || got != "v1" { |
| 69 | t.Fatalf("promotion blocked: %s %v", got, err) |
| 70 | } |
| 71 | if len(u.effects) != 0 { |
| 72 | t.Fatalf("recovery started an operator-stopped unit: %v", u.effects) |
| 73 | } |
| 74 | if err := u.ResetFailed(rt.Unit); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | u.props.ActiveState, u.props.SubState = "inactive", "dead" |
| 78 | if err := u.Start(rt.Unit); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | if _, err := c.host(t.Context(), rt.Host); err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | if u.props.MainPID <= 0 || u.exe != release.VersionPath(u.opt, rt.Account, "v1", rt.App) { |
| 85 | t.Fatal("incident recipe did not restore service") |
| 86 | } |
| 87 | }) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | type namedRecoveryWork struct { |
| 92 | before func() |
| 93 | deny bool |
| 94 | } |
| 95 | |
| 96 | func (w namedRecoveryWork) Select(context.Context, string, string) (hostwrite.Work, error) { |
| 97 | return w, nil |
| 98 | } |
| 99 | func (w namedRecoveryWork) With(_ context.Context, id hostwrite.Identity, fn func() error) error { |
| 100 | if !strings.HasPrefix(id.Release, "sha256:") { |
| 101 | return os.ErrPermission |
| 102 | } |
| 103 | if w.before != nil { |
| 104 | w.before() |
| 105 | } |
| 106 | if w.deny { |
| 107 | return os.ErrPermission |
| 108 | } |
| 109 | return fn() |
| 110 | } |
| 111 | |
| 112 | func TestPlan14SignedNamedRecoveryAuthority(t *testing.T) { |
| 113 | t.Parallel() |
| 114 | for _, attack := range []string{"positive", "claims on", "ordinary", "active", "missing record", "wrong signature", "unit path", "untrusted live", "foreign live", "record changes", "unit changes", "live changes", "artifact changes", "held claim", "process starts"} { |
| 115 | t.Run(attack, func(t *testing.T) { |
| 116 | e, rt, u, _, pub := plan14AdoptionFixture(t) |
| 117 | u.props.ActiveState, u.props.SubState, u.props.MainPID = "failed", "failed", 0 |
| 118 | source := &proofBindingSource{store: e.store, sys: u, optDir: u.opt, openFile: os.Open, unitDirs: []string{filepath.Dir(u.props.FragmentPath)}} |
| 119 | n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink} |
| 120 | change := func() {} |
| 121 | switch attack { |
| 122 | case "claims on": |
| 123 | n.writes = hostwrite.Config{Enabled: true, Selector: namedRecoveryWork{}} |
| 124 | case "ordinary": |
| 125 | rec, _, err := provision.FindRecordByHost(t.Context(), e.store, rt.Host) |
| 126 | if err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | rec.Unit = "" |
| 130 | plan14PutRecord(t, e.store, *rec) |
| 131 | case "active": |
| 132 | u.props.ActiveState, u.props.MainPID = "active", 7 |
| 133 | case "missing record": |
| 134 | if err := e.store.Delete(t.Context(), provision.RecordKey(rt.Account, rt.App, rt.Host)); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | case "wrong signature": |
| 138 | n.pub, _, _ = ed25519.GenerateKey(nil) |
| 139 | case "unit path": |
| 140 | u.props.ExecPath = "/untrusted" |
| 141 | case "untrusted live": |
| 142 | n.readLive = func(string) (string, error) { return "", os.ErrPermission } |
| 143 | case "foreign live": |
| 144 | if err := release.StageArtifact(u.opt, "bap", "v1", rt.App, []byte("foreign")); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | if err := release.PointLive(u.opt, rt.Host, "bap", "v1", rt.App); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | case "record changes": |
| 151 | change = func() { |
| 152 | rec, _, err := provision.FindRecordByHost(t.Context(), e.store, rt.Host) |
| 153 | if err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | rec.RecordSeq++ |
| 157 | plan14PutRecord(t, e.store, *rec) |
| 158 | } |
| 159 | case "unit changes": |
| 160 | change = func() { |
| 161 | if err := os.WriteFile(u.props.FragmentPath, []byte("changed"), 0644); err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | } |
| 165 | case "live changes": |
| 166 | change = func() { |
| 167 | if err := os.Remove(release.LiveLink(u.opt, rt.Host)); err != nil { |
| 168 | t.Fatal(err) |
| 169 | } |
| 170 | } |
| 171 | case "artifact changes": |
| 172 | change = func() { |
| 173 | if err := os.WriteFile(release.VersionPath(u.opt, rt.Account, "v2", rt.App), []byte("forged"), 0755); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | } |
| 177 | case "held claim": |
| 178 | n.writes = hostwrite.Config{Enabled: true, Selector: namedRecoveryWork{deny: true}} |
| 179 | case "process starts": |
| 180 | change = func() { u.props.ActiveState, u.props.MainPID = "active", 7 } |
| 181 | } |
| 182 | if strings.HasSuffix(attack, "changes") || attack == "process starts" { |
| 183 | n.writes = hostwrite.Config{Enabled: true, Selector: namedRecoveryWork{before: change}} |
| 184 | } |
| 185 | done, err := n.repair(t.Context(), rt.Host) |
| 186 | live, liveErr := release.LiveVersion(u.opt, rt.Host) |
| 187 | if attack == "positive" || attack == "claims on" { |
| 188 | if err != nil || !done || liveErr != nil || live != "v2" { |
| 189 | t.Fatalf("recovery refused: %v %v %s %v", done, err, live, liveErr) |
| 190 | } |
| 191 | } else if attack == "ordinary" || attack == "active" { |
| 192 | if done || err != nil || live != "v1" { |
| 193 | t.Fatalf("excluded route changed: %v %v %s", done, err, live) |
| 194 | } |
| 195 | } else if err == nil || live == "v2" { |
| 196 | t.Fatalf("unsafe recovery: %v %s", err, live) |
| 197 | } |
| 198 | if len(u.effects) != 0 { |
| 199 | t.Fatalf("implicit start: %v", u.effects) |
| 200 | } |
| 201 | if liveErr != nil && !errors.Is(liveErr, os.ErrNotExist) { |
| 202 | t.Fatal(liveErr) |
| 203 | } |
| 204 | }) |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | func (u *plan14RestartUnits) Start(unit string) error { |
| 209 | before := len(u.effects) |
| 210 | err := u.Restart(unit) |
| 211 | u.effects[before] = "start " + unit |
| 212 | return err |
| 213 | } |
| 214 | |
| 215 | type namedRecordSwitchStore struct { |
| 216 | release.Store |
| 217 | key string |
| 218 | after func() |
| 219 | } |
| 220 | |
| 221 | func (s *namedRecordSwitchStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 222 | raw, etag, err := s.Store.Get(ctx, key) |
| 223 | if key == s.key && s.after != nil { |
| 224 | fn := s.after |
| 225 | s.after = nil |
| 226 | fn() |
| 227 | } |
| 228 | return raw, etag, err |
| 229 | } |
| 230 | |
| 231 | func TestPlan14SignedNamedRecoveryUsesOneCanonicalRecord(t *testing.T) { |
| 232 | t.Parallel() |
| 233 | e, rt, u, _, _ := plan14AdoptionFixture(t) |
| 234 | if _, err := e.store.Put(t.Context(), "accounts/bac/account.json", []byte(`{"id":2,"sqid":"bac"}`), ""); err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | pub, key, err := ed25519.GenerateKey(nil) |
| 238 | if err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | pointer := release.ChannelPointer{Schema: 1, App: rt.App, Channel: rt.Channel, Version: "v2", PromotedAt: time.Now().UTC()} |
| 242 | raw, err := release.SignRootEnvelope(release.RootSigner(key), pointer) |
| 243 | if err != nil { |
| 244 | t.Fatal(err) |
| 245 | } |
| 246 | for _, acct := range []string{rt.Account, "bac"} { |
| 247 | if err := release.Ship(t.Context(), e.store, acct, release.Manifest{App: rt.App, Version: "v2", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}}, map[string][]byte{release.AdoptPlatform(): []byte("new build")}); err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | channel := release.ChannelKey(acct, rt.App, rt.Channel) |
| 251 | _, etag, err := e.store.Get(t.Context(), channel) |
| 252 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | if _, err := e.store.Put(t.Context(), channel, raw, etag); err != nil { |
| 256 | t.Fatal(err) |
| 257 | } |
| 258 | if err := release.StageArtifact(u.opt, acct, "v2", rt.App, []byte("new build")); err != nil { |
| 259 | t.Fatal(err) |
| 260 | } |
| 261 | } |
| 262 | oldKey := provision.RecordKey(rt.Account, rt.App, rt.Host) |
| 263 | st := &namedRecordSwitchStore{Store: e.store, key: oldKey} |
| 264 | st.after = func() { |
| 265 | rec, _, err := provision.FindRecordByHost(t.Context(), e.store, rt.Host) |
| 266 | if err != nil { |
| 267 | t.Fatal(err) |
| 268 | } |
| 269 | if err := e.store.Delete(t.Context(), oldKey); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | rec.Account = "bac" |
| 273 | plan14PutRecord(t, e.store, *rec) |
| 274 | } |
| 275 | u.props.ActiveState, u.props.SubState, u.props.MainPID = "failed", "failed", 0 |
| 276 | n := namedUnitRecovery{source: &proofBindingSource{store: st, sys: u, optDir: u.opt, openFile: os.Open, unitDirs: []string{filepath.Dir(u.props.FragmentPath)}}, pub: pub, readLive: os.Readlink} |
| 277 | _, err = n.repair(t.Context(), rt.Host) |
| 278 | live, _ := release.LiveVersion(u.opt, rt.Host) |
| 279 | if err == nil || live != "v1" { |
| 280 | t.Fatalf("mixed account authority repaired old account's link: %s %v", live, err) |
| 281 | } |
| 282 | } |
| 283 | |