| 1 | package hostguard |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "io" |
| 9 | "os" |
| 10 | "strings" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/hostclaim" |
| 13 | "github.com/carlosframework/platform/internal/rootstate" |
| 14 | ) |
| 15 | |
| 16 | type InstallationRegistration struct { |
| 17 | Op, Kind, Phase, RowID string |
| 18 | Session uint64 |
| 19 | } |
| 20 | |
| 21 | type InstallationArtifacts struct { |
| 22 | Schema int |
| 23 | StateDirectory bool |
| 24 | Timers string |
| 25 | } |
| 26 | |
| 27 | type RootInstallation struct { |
| 28 | Artifacts InstallationArtifacts `json:",omitzero"` |
| 29 | Registration InstallationRegistration `json:",omitempty"` |
| 30 | Empty bool |
| 31 | Host, Incarnation, Account, App, Record, Box, Enrollment string |
| 32 | Stamp, Migration, InventoryRevision uint64 |
| 33 | } |
| 34 | type RootAuthority struct { |
| 35 | store rootstate.Store |
| 36 | inventoryRevision func(context.Context, string, Captured) (uint64, error) |
| 37 | } |
| 38 | |
| 39 | func NewRootAuthority(store rootstate.Store) *RootAuthority { return &RootAuthority{store: store} } |
| 40 | |
| 41 | type rootRecord struct { |
| 42 | MigrationDenies []MigrationDeny `json:",omitempty"` |
| 43 | Quarantined bool `json:",omitempty"` |
| 44 | Settled bool `json:",omitempty"` |
| 45 | RebindCommit []byte `json:",omitempty"` |
| 46 | Schema int |
| 47 | Installation RootInstallation |
| 48 | Retired []Captured |
| 49 | Barrier *FenceTarget |
| 50 | PendingInventory []byte `json:",omitempty"` |
| 51 | PendingInstallation *RootInstallation `json:",omitempty"` |
| 52 | State record |
| 53 | } |
| 54 | |
| 55 | func (a *RootAuthority) read(host string) (record, error) { |
| 56 | if a == nil || a.store == nil || validateFileHost(host) != nil { |
| 57 | return record{}, ErrInvalidInput |
| 58 | } |
| 59 | b, err := a.store.Read(host + ".json") |
| 60 | if errors.Is(err, os.ErrNotExist) { |
| 61 | return record{}, ErrNoRecord |
| 62 | } |
| 63 | if err != nil { |
| 64 | return record{}, err |
| 65 | } |
| 66 | var saved rootRecord |
| 67 | dec := json.NewDecoder(bytes.NewReader(b)) |
| 68 | dec.DisallowUnknownFields() |
| 69 | if dec.Decode(&saved) != nil || dec.Decode(new(any)) != io.EOF || saved.Schema != 1 || saved.Installation.Host != host || saved.Installation.Incarnation != saved.State.Incarnation || saved.Installation.Migration == 0 { |
| 70 | return record{}, ErrMalformedRecord |
| 71 | } |
| 72 | r := saved.State |
| 73 | if r.ActiveStamp != 0 { |
| 74 | if saved.Installation.Stamp != r.ActiveStamp { |
| 75 | return record{}, ErrMalformedRecord |
| 76 | } |
| 77 | } else { |
| 78 | // Older retired records cleared both stamps. Recover only the last |
| 79 | // exact root-retained tuple, never an agent supplied installation. |
| 80 | if len(saved.Retired) == 0 { |
| 81 | return record{}, ErrMalformedRecord |
| 82 | } |
| 83 | last := saved.Retired[len(saved.Retired)-1] |
| 84 | if last.Incarnation != r.Incarnation || last.Stamp == 0 || (saved.Installation.Stamp != 0 && saved.Installation.Stamp != last.Stamp) { |
| 85 | return record{}, ErrMalformedRecord |
| 86 | } |
| 87 | saved.Installation.Stamp = last.Stamp |
| 88 | } |
| 89 | r.migrationDenies = saved.MigrationDenies |
| 90 | for _, deny := range r.migrationDenies { |
| 91 | if deny.Incarnation == "" || deny.Stamp == 0 || deny.Session == 0 { |
| 92 | return record{}, ErrMalformedRecord |
| 93 | } |
| 94 | } |
| 95 | if saved.Settled && !saved.Quarantined { |
| 96 | return record{}, ErrMalformedRecord |
| 97 | } |
| 98 | r.quarantined, r.settled = saved.Quarantined, saved.Settled |
| 99 | r.root = true |
| 100 | r.installation = saved.Installation |
| 101 | r.rootRetired = saved.Retired |
| 102 | r.barrier = saved.Barrier |
| 103 | r.rebindCommit = saved.RebindCommit |
| 104 | r.pendingInventory = saved.PendingInventory |
| 105 | r.pendingInstallation = saved.PendingInstallation |
| 106 | if (len(r.pendingInventory) > 0) != (r.pendingInstallation != nil) { |
| 107 | return record{}, ErrMalformedRecord |
| 108 | } |
| 109 | if err := r.validate(host); err != nil { |
| 110 | return record{}, err |
| 111 | } |
| 112 | return r, nil |
| 113 | } |
| 114 | func (a *RootAuthority) write(r record) error { |
| 115 | if a == nil || a.store == nil || !r.root || r.validate(r.Host) != nil { |
| 116 | return ErrMalformedRecord |
| 117 | } |
| 118 | r.installation.Host, r.installation.Incarnation = r.Host, r.Incarnation |
| 119 | if r.ActiveStamp != 0 { |
| 120 | r.installation.Stamp = r.ActiveStamp |
| 121 | } |
| 122 | b, err := json.Marshal(rootRecord{MigrationDenies: r.migrationDenies, Quarantined: r.quarantined, Settled: r.settled, RebindCommit: r.rebindCommit, Schema: 1, Installation: r.installation, Retired: r.rootRetired, Barrier: r.barrier, PendingInventory: r.pendingInventory, PendingInstallation: r.pendingInstallation, State: r}) |
| 123 | if err != nil { |
| 124 | return err |
| 125 | } |
| 126 | return a.store.Write(r.Host+".json", b) |
| 127 | } |
| 128 | func (g Guard) Capture(host string) (Captured, error) { |
| 129 | if g.Root == nil { |
| 130 | return Capture(g.DataDir, host) |
| 131 | } |
| 132 | r, err := g.Root.read(host) |
| 133 | if err != nil { |
| 134 | return Captured{}, err |
| 135 | } |
| 136 | return Captured{Incarnation: r.Incarnation, Stamp: r.ActiveStamp}, nil |
| 137 | } |
| 138 | func (g Guard) EstablishRoot(ctx context.Context, in RootInstallation, check func(*hostclaim.Claim) error) error { |
| 139 | return g.CommitRootInstallation(ctx, in, check, func() error { return nil }) |
| 140 | } |
| 141 | |
| 142 | func (g Guard) CommitRootInstallation(ctx context.Context, in RootInstallation, check func(*hostclaim.Claim) error, commit func() error) error { |
| 143 | if commit == nil { |
| 144 | return ErrInvalidInput |
| 145 | } |
| 146 | return g.CommitRootInventoryArtifacts(ctx, in, nil, check, nil, func(RootInstallation) error { return commit() }, nil) |
| 147 | } |
| 148 | |
| 149 | func (g Guard) CommitRootInventoryArtifacts(ctx context.Context, in RootInstallation, intent []byte, check func(*hostclaim.Claim) error, validate func(RootInstallation) error, commit func(RootInstallation) error, artifacts func(RootInstallation) (InstallationArtifacts, error)) error { |
| 150 | if g.Root == nil || check == nil || commit == nil || in.Host == "" || in.Incarnation == "" || in.Stamp == 0 || in.Migration == 0 || in.Account == "" || in.App == "" || in.Record == "" || in.Box == "" || in.Enrollment == "" { |
| 151 | return ErrInvalidInput |
| 152 | } |
| 153 | return g.locked(ctx, in.Host, func(d *recordDir) error { |
| 154 | c, err := g.fresh(ctx, in.Host) |
| 155 | if err != nil { |
| 156 | return err |
| 157 | } |
| 158 | if err := check(c); err != nil { |
| 159 | return err |
| 160 | } |
| 161 | if c.Holder.Incarnation != in.Incarnation || c.Holder.Stamp != in.Stamp || c.Holder.Account != in.Account || c.Holder.App != in.App { |
| 162 | return ErrClaimIncarnation |
| 163 | } |
| 164 | r, err := d.read(in.Host) |
| 165 | if err != nil && !errors.Is(err, ErrNoRecord) { |
| 166 | return err |
| 167 | } |
| 168 | if errors.Is(err, ErrNoRecord) { |
| 169 | r = record{V: 1, Host: in.Host, Incarnation: in.Incarnation, root: true} |
| 170 | } |
| 171 | want := Captured{Incarnation: in.Incarnation, Stamp: in.Stamp} |
| 172 | for _, old := range r.rootRetired { |
| 173 | if old == want { |
| 174 | return ErrRetiredStamp |
| 175 | } |
| 176 | } |
| 177 | if r.Incarnation == in.Incarnation && r.ActiveStamp == in.Stamp { |
| 178 | prior := r.installation |
| 179 | if validate != nil { |
| 180 | if err := validate(prior); err != nil { |
| 181 | return err |
| 182 | } |
| 183 | } |
| 184 | if prior.Account != in.Account || prior.App != in.App || prior.Record != in.Record || prior.Box != in.Box || prior.Enrollment != in.Enrollment || prior.Migration != in.Migration || prior.InventoryRevision > in.InventoryRevision { |
| 185 | return ErrRecordIncarnation |
| 186 | } |
| 187 | } |
| 188 | if migrationAdmissionDenied(r.migrationDenies, r.FenceJournal, want, c.Holder.Session) { |
| 189 | return ErrFenced |
| 190 | } |
| 191 | if r.ActiveFence != nil && r.ActiveFence.matchesSession(c, want) { |
| 192 | return ErrFenced |
| 193 | } |
| 194 | for _, receipt := range r.FenceJournal { |
| 195 | if receipt.Target.matchesSession(c, want) { |
| 196 | return ErrFenced |
| 197 | } |
| 198 | } |
| 199 | if r.barrier != nil { |
| 200 | return ErrFenced |
| 201 | } |
| 202 | if r.Incarnation == in.Incarnation && r.ActiveStamp > in.Stamp { |
| 203 | return ErrOldStamp |
| 204 | } |
| 205 | prior := r.installation |
| 206 | if prior.Incarnation == in.Incarnation && prior.Stamp == in.Stamp && in.Registration == (InstallationRegistration{}) { |
| 207 | in.Registration = prior.Registration |
| 208 | } |
| 209 | if artifacts != nil { |
| 210 | in.Artifacts, err = artifacts(prior) |
| 211 | if err != nil { |
| 212 | return err |
| 213 | } |
| 214 | } else if prior.Incarnation == in.Incarnation && in.Artifacts == (InstallationArtifacts{}) { |
| 215 | in.Artifacts = prior.Artifacts |
| 216 | } |
| 217 | if r.pendingInstallation != nil && (*r.pendingInstallation != in || !bytes.Equal(r.pendingInventory, intent)) { |
| 218 | return ErrFenced |
| 219 | } |
| 220 | if validate != nil { |
| 221 | if err := validate(prior); err != nil { |
| 222 | return err |
| 223 | } |
| 224 | } |
| 225 | if len(intent) > 0 { |
| 226 | r.pendingInventory = append([]byte(nil), intent...) |
| 227 | pending := in |
| 228 | r.pendingInstallation = &pending |
| 229 | if r.ActiveStamp == 0 { |
| 230 | r.ActiveStamp = in.Stamp |
| 231 | r.installation = in |
| 232 | r.installation.InventoryRevision = 0 |
| 233 | } |
| 234 | if err := d.write(r); err != nil { |
| 235 | return err |
| 236 | } |
| 237 | } |
| 238 | if err := commit(prior); err != nil { |
| 239 | return err |
| 240 | } |
| 241 | if r.Incarnation != in.Incarnation || r.ActiveStamp != in.Stamp { |
| 242 | r.retire() |
| 243 | r.RetiredStamps = nil |
| 244 | } |
| 245 | r.pendingInventory, r.pendingInstallation = nil, nil |
| 246 | r.Incarnation, r.ActiveStamp, r.installation = in.Incarnation, in.Stamp, in |
| 247 | r.quarantined, r.settled = false, false |
| 248 | return d.write(r) |
| 249 | }) |
| 250 | } |
| 251 | |
| 252 | func NewRootAuthorityWithInventory(store rootstate.Store, revision func(context.Context, string, Captured) (uint64, error)) *RootAuthority { |
| 253 | return &RootAuthority{store: store, inventoryRevision: revision} |
| 254 | } |
| 255 | func (g Guard) refreshRootInventory(ctx context.Context, r *record) error { |
| 256 | if g.Root == nil || g.Root.inventoryRevision == nil { |
| 257 | return nil |
| 258 | } |
| 259 | revision, err := g.Root.inventoryRevision(ctx, r.Host, Captured{Incarnation: r.Incarnation, Stamp: r.ActiveStamp}) |
| 260 | if err != nil { |
| 261 | return err |
| 262 | } |
| 263 | if revision == 0 { |
| 264 | return ErrNoRecord |
| 265 | } |
| 266 | r.installation.InventoryRevision = revision |
| 267 | return nil |
| 268 | } |
| 269 | |
| 270 | func (a *RootAuthority) PendingInstallations() ([][]byte, error) { |
| 271 | names, err := a.store.Names() |
| 272 | if err != nil { |
| 273 | return nil, err |
| 274 | } |
| 275 | var out [][]byte |
| 276 | for _, name := range names { |
| 277 | if !strings.HasSuffix(name, ".json") { |
| 278 | continue |
| 279 | } |
| 280 | r, err := a.read(strings.TrimSuffix(name, ".json")) |
| 281 | if err != nil { |
| 282 | return nil, err |
| 283 | } |
| 284 | if r.pendingInstallation != nil { |
| 285 | out = append(out, append([]byte(nil), r.pendingInventory...)) |
| 286 | } |
| 287 | } |
| 288 | return out, nil |
| 289 | } |
| 290 | |
| 291 | func (a *RootAuthority) PendingInstallation(ctx context.Context, host string) (RootInstallation, []byte, error) { |
| 292 | if err := ctx.Err(); err != nil { |
| 293 | return RootInstallation{}, nil, err |
| 294 | } |
| 295 | r, err := a.read(host) |
| 296 | if err != nil { |
| 297 | return RootInstallation{}, nil, err |
| 298 | } |
| 299 | if r.pendingInstallation == nil { |
| 300 | return RootInstallation{}, nil, ErrNoRecord |
| 301 | } |
| 302 | return *r.pendingInstallation, append([]byte(nil), r.pendingInventory...), nil |
| 303 | } |
| 304 | |
| 305 | // AbandonRootInventory resolves a pending root inventory intent that a |
| 306 | // fresh claim proves superseded: the exact intent bytes, then superseded |
| 307 | // over the guard's fresh claim read, under the host lock. The installation |
| 308 | // ends in the same terminal state as an obsolete-intent settlement |
| 309 | // (resolveRootInventory): quarantined and settled, still refused for every |
| 310 | // execution and admission, inspectable by the migration census, with a |
| 311 | // landed commit's revision reconciled first (fix round 2: an abandoned |
| 312 | // installation no longer blocks cleanup startup on every restart). |
| 313 | func (g Guard) AbandonRootInventory(ctx context.Context, host string, intent []byte, superseded func(*hostclaim.Claim) bool, applied func(context.Context, RootInstallation) (bool, error)) error { |
| 314 | if superseded == nil { |
| 315 | return ErrInvalidInput |
| 316 | } |
| 317 | return g.resolveRootInventory(ctx, host, intent, superseded, applied) |
| 318 | } |
| 319 | |
| 320 | // SettleObsoleteRootInventory settles a pending root inventory intent that |
| 321 | // root's startup found obsolete: stamped under a roster binding other than |
| 322 | // the startup binding (roster binding N3). The binding difference alone |
| 323 | // supersedes it, so no claim is consulted. Under the host lock, with the |
| 324 | // exact intent bytes, the intent is cleared and the installation is |
| 325 | // quarantined and marked settled: every execution and admission path still |
| 326 | // refuses it, and only the refusal-only migration census may inspect it. |
| 327 | // |
| 328 | // applied reports whether the pending installation's inventory already |
| 329 | // holds this intent: root's commit landed and only the final record write |
| 330 | // was lost. The record then takes the installation that commit would have |
| 331 | // recorded, exactly as CommitRootInventoryArtifacts records it, so the |
| 332 | // guard and the inventory agree on the revision. Otherwise the record keeps |
| 333 | // the installation it had. |
| 334 | func (g Guard) SettleObsoleteRootInventory(ctx context.Context, host string, intent []byte, applied func(context.Context, RootInstallation) (bool, error)) error { |
| 335 | return g.resolveRootInventory(ctx, host, intent, nil, applied) |
| 336 | } |
| 337 | |
| 338 | // resolveRootInventory is the one terminal resolution of a pending root |
| 339 | // inventory intent, for an obsolete binding (superseded nil: the binding |
| 340 | // difference alone supersedes it, no claim is read) or a proven superseded |
| 341 | // claim (superseded over the fresh claim, ErrFenced when it does not hold). |
| 342 | // The intent must match exactly. applied reconciles a commit that landed |
| 343 | // before its record write was lost; then the intent is cleared and the |
| 344 | // record marked quarantined and settled. A settled record is terminal: no |
| 345 | // pending intent remains to resolve it again, so it must stay inspectable |
| 346 | // by the census, and the quarantine keeps every execution path refusing. |
| 347 | func (g Guard) resolveRootInventory(ctx context.Context, host string, intent []byte, superseded func(*hostclaim.Claim) bool, applied func(context.Context, RootInstallation) (bool, error)) error { |
| 348 | if g.Root == nil || applied == nil || len(intent) == 0 { |
| 349 | return ErrInvalidInput |
| 350 | } |
| 351 | return g.readLocked(ctx, host, func(d *recordDir) error { |
| 352 | r, err := d.read(host) |
| 353 | if err != nil { |
| 354 | return err |
| 355 | } |
| 356 | if !bytes.Equal(r.pendingInventory, intent) || r.pendingInstallation == nil { |
| 357 | return ErrInvalidInput |
| 358 | } |
| 359 | if superseded != nil { |
| 360 | c, err := g.fresh(ctx, host) |
| 361 | if err != nil { |
| 362 | return err |
| 363 | } |
| 364 | if !superseded(c) { |
| 365 | return ErrFenced |
| 366 | } |
| 367 | } |
| 368 | pending := *r.pendingInstallation |
| 369 | landed, err := applied(ctx, pending) |
| 370 | if err != nil { |
| 371 | return err |
| 372 | } |
| 373 | if landed { |
| 374 | if r.Incarnation != pending.Incarnation || r.ActiveStamp != pending.Stamp { |
| 375 | r.retire() |
| 376 | r.RetiredStamps = nil |
| 377 | } |
| 378 | r.Incarnation, r.ActiveStamp, r.installation = pending.Incarnation, pending.Stamp, pending |
| 379 | } |
| 380 | r.pendingInventory, r.pendingInstallation = nil, nil |
| 381 | r.quarantined, r.settled = true, true |
| 382 | return d.write(r) |
| 383 | }) |
| 384 | } |
| 385 | |