| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "crypto/ed25519" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "fmt" |
| 11 | "net/http" |
| 12 | "os" |
| 13 | "path/filepath" |
| 14 | "strings" |
| 15 | "sync/atomic" |
| 16 | "syscall" |
| 17 | "testing" |
| 18 | "time" |
| 19 | |
| 20 | "bytes" |
| 21 | "github.com/carlosframework/platform/internal/hostactors" |
| 22 | "github.com/carlosframework/platform/internal/hostbox" |
| 23 | "github.com/carlosframework/platform/internal/hostevidence" |
| 24 | "github.com/carlosframework/platform/internal/hostguard" |
| 25 | "github.com/carlosframework/platform/internal/registry" |
| 26 | "github.com/carlosframework/platform/internal/release" |
| 27 | ) |
| 28 | |
| 29 | func plan9Fix1RootRetire(t *testing.T) (ClaimRootHTTP, hostguard.FenceTarget, *registry.Registry, string) { |
| 30 | t.Helper() |
| 31 | coordinator, req, _ := claimStopFixture(t) |
| 32 | result, err := coordinator.FenceAndStop(t.Context(), req) |
| 33 | if err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | base := t.TempDir() |
| 37 | inventory, err := openLegacyInventory(t, filepath.Join(base, "inventory.db")) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | t.Cleanup(func() { inventory.Close() }) |
| 42 | reg, err := registry.Open(filepath.Join(base, "registry.db")) |
| 43 | if err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | t.Cleanup(func() { reg.Close() }) |
| 47 | owner := registry.ClaimOwnership{ClaimIncarnation: req.Incarnation, ClaimStamp: req.InstallationStamp, ClaimOwner: "installation", ClaimRowID: "route-1"} |
| 48 | if err = reg.Upsert(registry.Route{Host: req.Host, ClaimOwnership: owner}); err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | in := hostbox.StopInventory{Complete: true, Host: req.Host, Incarnation: req.Incarnation, InstallationStamp: req.InstallationStamp, Box: req.Box, Enrollment: req.Enrollment, DirectoryRevision: req.ExpectedDirectoryRevision, Entries: []hostbox.StopInventoryEntry{{Kind: "route", ArtifactID: owner.ClaimRowID, ManagedRoot: "registry", RelativePath: req.Host, RowIdentity: owner.ClaimRowID, Ownership: owner.ClaimOwner, Materialization: "materialized"}}} |
| 52 | if err = inventory.WriteStopInventory(t.Context(), in); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | journal := filepath.Join(base, "retire") |
| 56 | root := ClaimRootHTTP{Stop: ClaimStopHTTP{Coordinator: coordinator}, Roots: map[string]string{"data": base}, Reconcile: hostbox.ReconcileAdapter{Inventory: inventory, Registry: reg, Retirement: hostbox.TypedRetirement{JournalDir: journal}}} |
| 57 | return root, result.Target, reg, base |
| 58 | } |
| 59 | |
| 60 | // C-1: an agent-planted retirement journal cannot add artifacts that root's |
| 61 | // own stop inventory does not name, even when it decodes strictly and matches |
| 62 | // the host, incarnation, stamp, ownership, target and stop receipt. |
| 63 | func TestPlan9Fix1RootRetireRefusesPlantedJournalArtifacts(t *testing.T) { |
| 64 | t.Parallel() |
| 65 | for _, rootName := range []string{"data", "opt", "units"} { |
| 66 | t.Run(rootName, func(t *testing.T) { |
| 67 | root, target, reg, base := plan9Fix1RootRetire(t) |
| 68 | root.Roots[rootName] = base |
| 69 | secret := filepath.Join(base, "secret") |
| 70 | if err := os.WriteFile(secret, []byte("root-only"), 0600); err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | var st syscall.Stat_t |
| 74 | if err := syscall.Lstat(secret, &st); err != nil { |
| 75 | t.Fatal(err) |
| 76 | } |
| 77 | receipt, err := root.Stop.Coordinator.Guard.FenceReceiptFor(t.Context(), target) |
| 78 | if err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | planted, err := hostbox.NewRetirementJournal(target.FenceID, hostbox.RetirementInventory{Host: target.Host, Captured: hostguard.Captured{Incarnation: target.Incarnation, Stamp: target.Stamp}, Ownership: "installation", Artifacts: []hostbox.Artifact{ |
| 82 | {Kind: hostbox.ArtifactRoute, Root: "registry", Name: target.Host, Owner: "installation", RowID: "route-1"}, |
| 83 | {Kind: hostbox.ArtifactPath, Root: rootName, Name: "secret", Owner: "installation", Dev: uint64(st.Dev), Ino: st.Ino, Type: st.Mode & syscall.S_IFMT}, |
| 84 | }}) |
| 85 | if err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | planted.Target = &target |
| 89 | planted.StopReceipt = receipt.Bytes |
| 90 | body, _ := json.Marshal(planted) |
| 91 | dir := root.Reconcile.Retirement.JournalDir |
| 92 | if err = os.Mkdir(dir, 0700); err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | if err = os.WriteFile(filepath.Join(dir, target.FenceID+".json"), body, 0600); err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | err = root.retire(t.Context(), target) |
| 99 | if raw, readErr := os.ReadFile(secret); readErr != nil || string(raw) != "root-only" { |
| 100 | t.Fatalf("root parked an artifact its own inventory never named: %v (retire: %v)", readErr, err) |
| 101 | } |
| 102 | if !errors.Is(err, hostbox.ErrTypedJournal) { |
| 103 | t.Fatalf("planted journal inventory was not refused: %v", err) |
| 104 | } |
| 105 | if row, err := reg.Get(target.Host); err != nil || row == nil { |
| 106 | t.Fatalf("route retired from a planted journal: %v", err) |
| 107 | } |
| 108 | |
| 109 | }) |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | // C-1: a symlinked retirement journal directory is refused before any write. |
| 114 | func TestPlan9Fix1RootRetireRefusesSymlinkedJournalDir(t *testing.T) { |
| 115 | t.Parallel() |
| 116 | root, target, reg, base := plan9Fix1RootRetire(t) |
| 117 | victim := filepath.Join(base, "victim") |
| 118 | if err := os.Mkdir(victim, 0700); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | if err := os.Symlink(victim, root.Reconcile.Retirement.JournalDir); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | if err := root.retire(t.Context(), target); err == nil { |
| 125 | t.Fatal("symlinked journal directory accepted") |
| 126 | } |
| 127 | if entries, _ := os.ReadDir(victim); len(entries) != 0 { |
| 128 | t.Fatalf("root wrote through the symlink: %v", entries) |
| 129 | } |
| 130 | if row, err := reg.Get(target.Host); err != nil || row == nil { |
| 131 | t.Fatalf("route retired through a symlinked journal: %v", err) |
| 132 | } |
| 133 | if err := root.RecoverPending(t.Context()); err == nil { |
| 134 | t.Fatal("recovery read a symlinked journal directory") |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // C-1: root modes keep retirement and cold-handover journals in root's own |
| 139 | // namespace, never under the agent-owned data directory. |
| 140 | func TestPlan9Fix1RootRetireJournalIsRootNamespace(t *testing.T) { |
| 141 | for _, mode := range []string{"adopt", "instance-control"} { |
| 142 | dir := claimRetireJournalDir(mode, "/data") |
| 143 | if !strings.HasPrefix(dir, "/var/lib/carlos-claims/") || strings.HasPrefix(dir, "/data") { |
| 144 | t.Fatalf("%s retirement journal outside root's namespace: %s", mode, dir) |
| 145 | } |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | // M5: a refusal after a lost reply is uncertain, never a typed refusal. |
| 150 | func TestPlan9Fix1JournalRefusalAfterLostReplyIsUncertain(t *testing.T) { |
| 151 | for _, status := range []int{http.StatusBadRequest, http.StatusForbidden, http.StatusConflict} { |
| 152 | t.Run(fmt.Sprint(status), func(t *testing.T) { |
| 153 | var calls atomic.Int32 |
| 154 | call := plan9JournalSocket(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 155 | if calls.Add(1) == 1 { |
| 156 | if conn, _, err := w.(http.Hijacker).Hijack(); err == nil { |
| 157 | conn.Close() |
| 158 | } |
| 159 | return |
| 160 | } |
| 161 | w.WriteHeader(status) |
| 162 | })) |
| 163 | client, _ := hostbox.RemoteJournals(call) |
| 164 | err := client.RecordProducer(t.Context(), hostguard.FenceTarget{Host: "app.example"}, "p") |
| 165 | var uncertain *hostbox.JournalUncertainError |
| 166 | if !errors.As(err, &uncertain) || errors.Is(err, hostbox.ErrTypedJournal) || errors.Is(err, hostbox.ErrTypedInventory) { |
| 167 | t.Fatalf("refusal after a lost reply reads as definite: %v", err) |
| 168 | } |
| 169 | }) |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | // M3: serving writers publish their stop under serving-evidence, not evidence. |
| 174 | func TestPlan9Fix1LegacyProofAcceptsServingEvidence(t *testing.T) { |
| 175 | root := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, ed25519.SeedSize)) |
| 176 | signer := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, ed25519.SeedSize)) |
| 177 | store := release.NewDirStore(t.TempDir()) |
| 178 | enrollment := strings.Repeat("a", 32) |
| 179 | roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 3, RequestLimitNS: int64(hostbox.RequestLimit), Regions: map[string]hostactors.Region{"ie": {}}, Boxes: map[string]hostactors.Box{ |
| 180 | "box": {CensusPublicKey: signer.Public().(ed25519.PublicKey), Enrollment: enrollment, Namespace: "platform", BoxID: "box", Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "ie", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"}, |
| 181 | }, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}} |
| 182 | raw, err := release.SignRootEnvelope(release.RootSigner(root), roster) |
| 183 | if err != nil { |
| 184 | t.Fatal(err) |
| 185 | } |
| 186 | if _, err = store.Put(t.Context(), hostactors.Key, raw, ""); err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "legacy.db")) |
| 190 | if err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | defer j.Close() |
| 194 | in := hostbox.LaunchInventory{Target: hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: enrollment, BootID: "boot", ControllerID: "controller"}, ControllerPID: 1, ProcessStart: "1", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}} |
| 195 | if err = j.Create(t.Context(), in); err != nil { |
| 196 | t.Fatal(err) |
| 197 | } |
| 198 | journal, err := j.ReadStopKernelJournal(t.Context(), in.Target) |
| 199 | if err != nil { |
| 200 | t.Fatal(err) |
| 201 | } |
| 202 | inventory := hostbox.StopInventory{Complete: true, Host: in.Target.Host, Incarnation: "inc", InstallationStamp: 1, Box: "box", Enrollment: enrollment, DirectoryRevision: "1"} |
| 203 | target := in.Target |
| 204 | receipt := StopReceipt{FenceID: "fence", ClaimGen: 1, ObservedBootID: "boot", DirectoryRevision: "1", Binding: "1", CgroupIdentities: []string{"session"}, V: 1, ID: "stop1", Kind: "stopped", Host: target.Host, Incarnation: target.Incarnation, InstallationStamp: target.Stamp, WriterSession: target.Session, Box: target.Box, Enrollment: target.Enrollment, BootID: target.BootID, ControllerID: target.ControllerID, RequestLimitNS: int64(hostbox.RequestLimit), EvidenceAt: time.Now().UTC(), InventorySHA256: digestStop(inventory), LocalJournalSHA256: digestStop(journal)} |
| 205 | body, _ := json.Marshal(receipt) |
| 206 | key := stopEvidenceKey(target.Host, "", target.Box, receipt.ID) |
| 207 | if err = hostevidence.Publish(t.Context(), store, signer, key, body); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | verify := legacyJournalProof(store, hostactors.Directory{Store: store, PublicKey: root.Public().(ed25519.PublicKey), Bucket: "bucket"}) |
| 211 | if err = verify(t.Context(), in, inventory, journal); err != nil { |
| 212 | t.Fatalf("published serving stop refused: %v", err) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // M3: an active edge is refused before any runtime mask is written. |
| 217 | func TestPlan9Fix1QuiesceChecksEdgeBeforeMasking(t *testing.T) { |
| 218 | var calls []string |
| 219 | restore := claimSystemctl |
| 220 | t.Cleanup(func() { claimSystemctl = restore }) |
| 221 | restoreOwner := claimQuiesceOwner |
| 222 | t.Cleanup(func() { claimQuiesceOwner = restoreOwner }) |
| 223 | claimQuiesceOwner = func() (int, int, error) { return 1 << 30, 1 << 30, nil } |
| 224 | claimSystemctl = func(_ context.Context, args ...string) ([]byte, error) { |
| 225 | calls = append(calls, strings.Join(args, " ")) |
| 226 | switch { |
| 227 | case args[0] == "is-enabled": |
| 228 | return []byte("enabled\n"), nil |
| 229 | case args[0] == "show": |
| 230 | return []byte("active\n"), nil |
| 231 | } |
| 232 | return nil, nil |
| 233 | } |
| 234 | if _, err := quiesceLegacyClaimJournals(t.Context()); err == nil { |
| 235 | t.Fatal("active edge accepted") |
| 236 | } |
| 237 | if len(calls) == 0 { |
| 238 | t.Fatal("edge state never inspected") |
| 239 | } |
| 240 | for _, call := range calls { |
| 241 | if strings.HasPrefix(call, "mask") || strings.HasPrefix(call, "unmask") { |
| 242 | t.Fatalf("active edge was masked before its state was checked: %v", calls) |
| 243 | } |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | // M6: the root unit gets memory for the journal service, migration and fence |
| 248 | // pass only when the claim flag renders it; flag-off bytes are unchanged. |
| 249 | func TestPlan9Fix1ClaimControlUnitMemory(t *testing.T) { |
| 250 | raw, err := os.ReadFile("units/carlos-instance-control.service") |
| 251 | if err != nil { |
| 252 | t.Fatal(err) |
| 253 | } |
| 254 | if got := claimUnitContent(claimControlUnit, raw, claimUnitSettings{}); !bytes.Equal(got, raw) { |
| 255 | t.Fatal("flag-off unit changed") |
| 256 | } |
| 257 | got := string(claimUnitContent(claimControlUnit, raw, claimUnitSettings{enabled: true, data: "/data", opt: "/opt/carlos"})) |
| 258 | if strings.Contains(got, "MemoryMax=64M") || !strings.Contains(got, "MemoryMax=256M") || !strings.Contains(got, "GOMEMLIMIT=") { |
| 259 | t.Fatalf("flag-on root unit memory not raised:\n%s", got) |
| 260 | } |
| 261 | } |
| 262 | |