| 1 | //go:build linux && rootrunner |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/json" |
| 8 | "os" |
| 9 | "os/exec" |
| 10 | "path/filepath" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/hostbox" |
| 14 | "github.com/carlosframework/platform/internal/provision" |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | "github.com/carlosframework/platform/internal/rootstate" |
| 18 | "golang.org/x/sys/unix" |
| 19 | ) |
| 20 | |
| 21 | func TestPlan14GenerationParkedRootRunner(t *testing.T) { |
| 22 | const child = "PLAN14_E38_PARKED_ROOT" |
| 23 | dir := os.Getenv(child) |
| 24 | if dir == "" { |
| 25 | generationRunnerRegistryUser(t) |
| 26 | var err error |
| 27 | dir, err = os.MkdirTemp("/run", "plan14-parked-") |
| 28 | if err != nil { |
| 29 | t.Fatal(err) |
| 30 | } |
| 31 | t.Cleanup(func() { |
| 32 | if err := os.RemoveAll(dir); err != nil { |
| 33 | t.Error(err) |
| 34 | } |
| 35 | }) |
| 36 | if err := os.Chmod(dir, 0755); err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | binary, err := os.Executable() |
| 40 | if err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | cmd := exec.CommandContext(t.Context(), "unshare", "--mount", "--pid", "--fork", "--mount-proc", binary, "-test.run=^TestPlan14GenerationParkedRootRunner$", "-test.v", "-test.timeout=2m") |
| 44 | cmd.Dir = dir |
| 45 | cmd.Env = append(os.Environ(), child+"="+dir, "TMPDIR="+dir) |
| 46 | out, err := cmd.CombinedOutput() |
| 47 | if err != nil { |
| 48 | t.Fatalf("isolated parked inventory: %v\n%s", err, out) |
| 49 | } |
| 50 | t.Logf("%s", out) |
| 51 | return |
| 52 | } |
| 53 | if os.Geteuid() != 0 { |
| 54 | t.Fatal("designated runner requires root") |
| 55 | } |
| 56 | if err := unix.Mount("", "/", "", unix.MS_REC|unix.MS_PRIVATE, ""); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | private := filepath.Join(dir, "varlib") |
| 60 | if err := os.Mkdir(private, 0755); err != nil { |
| 61 | t.Fatal(err) |
| 62 | } |
| 63 | if err := unix.Mount(private, "/var/lib", "", unix.MS_BIND, ""); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | t.Cleanup(func() { |
| 67 | if err := unix.Unmount("/var/lib", 0); err != nil { |
| 68 | t.Error(err) |
| 69 | } |
| 70 | }) |
| 71 | snapshot, j, root := generationParkedFixture(t) |
| 72 | if os.Getenv("PLAN14_R11_DELETION") == "1" { |
| 73 | j.Target.ClaimETag = "fixture-record" |
| 74 | var receipt StopReceipt |
| 75 | if err := json.Unmarshal(j.StopReceipt, &receipt); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | receipt.ClaimETag = j.Target.ClaimETag |
| 79 | j.StopReceipt = mustMarshalStopReceipt(receipt) |
| 80 | snapshot.Installations[0].Receipts[0].Bytes = j.StopReceipt |
| 81 | snapshot.Installations[0].Receipts[0].Target = *j.Target |
| 82 | snapshot.Launches[0].Target = *j.Target |
| 83 | } |
| 84 | t.Setenv("CARLOS_HOST_CLAIM", "true") |
| 85 | t.Setenv("CARLOS_OPT_DIR", dir) |
| 86 | if err := os.Mkdir(filepath.Join(dir, "versions"), 0755); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | for _, path := range []string{filepath.Dir(root), root} { |
| 90 | if err := os.Chmod(path, 0755); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | } |
| 94 | reg, err := registry.Open(filepath.Join(root, "registry.db")) |
| 95 | if err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | owner := registry.ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"} |
| 99 | if err := reg.Upsert(registry.Route{Host: j.Inventory.Host, ClaimOwnership: owner}); err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | owner.ClaimRowID = "timer" |
| 103 | if err := reg.ReplaceTimers(j.Inventory.Host, []registry.Timer{{Host: j.Inventory.Host, Name: "backup", Kind: "cron", Registration: "timer", ClaimOwnership: owner}}); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | // The installation, closed launch and stop receipt are fixtures. Parking, |
| 107 | // conditional row retirement, checked readback and kernel scanning are real. |
| 108 | var deletedStore release.Store |
| 109 | if os.Getenv("PLAN14_R11_DELETION") == "1" { |
| 110 | deployment := filepath.Join(dir, "deployment") |
| 111 | t.Setenv("CARLOS_DEPLOYMENT_DIR", deployment) |
| 112 | t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| 113 | deletedStore = release.NewDirStore(deployment) |
| 114 | in := snapshot.Installations[0].Installation |
| 115 | record := provision.Record{Schema: 1, Host: in.Host, Account: in.Account, App: in.App, Channel: "stable"} |
| 116 | if err := provision.CreateRecord(t.Context(), deletedStore, record); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | if err := deletedStore.Delete(t.Context(), in.Record); err != nil { |
| 120 | t.Fatal(err) |
| 121 | } |
| 122 | } |
| 123 | generationRunPark(t, root, j, reg, "") |
| 124 | if err := reg.Close(); err != nil { |
| 125 | t.Fatal(err) |
| 126 | } |
| 127 | uid, gid := generationRunnerRegistryUser(t) |
| 128 | if err := os.Chown(filepath.Join(root, "registry.db"), uid, gid); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | if err := os.Chown(root, uid, gid); err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | path := claimRetireJournalDir("", "") |
| 135 | state, err := rootstate.Open(path, true, false) |
| 136 | if err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | defer state.Close() |
| 140 | raw, err := os.ReadFile(filepath.Join(root, "retire", j.ID+".json")) |
| 141 | if err != nil { |
| 142 | t.Fatal(err) |
| 143 | } |
| 144 | if err := state.Write(j.ID+".json", raw); err != nil { |
| 145 | t.Fatal(err) |
| 146 | } |
| 147 | if deletedStore != nil { |
| 148 | r11DeletedRecordPins(t, snapshot, deletedStore) |
| 149 | return |
| 150 | } |
| 151 | read := func(ctx context.Context) (generationInventory, error) { |
| 152 | journals, err := productionGenerationRetirements(ctx) |
| 153 | if err != nil { |
| 154 | return generationInventory{}, err |
| 155 | } |
| 156 | return productionGenerationRegistryInventory(ctx, generationParkedSnapshot(t, snapshot, journals)) |
| 157 | } |
| 158 | v := generationReconciler{read: read, kernel: productionGenerationKernelCensus} |
| 159 | accept := func() { |
| 160 | t.Helper() |
| 161 | if _, err := v.reconcile(t.Context(), nil); err != nil { |
| 162 | t.Fatal("checked parked reconciliation", err) |
| 163 | } |
| 164 | } |
| 165 | accept() |
| 166 | for _, attack := range []string{"uid", "writable-file", "symlink", "writable-parent", "missing-journal", "pending", "parked-replacement"} { |
| 167 | t.Run(attack, func(t *testing.T) { |
| 168 | file := filepath.Join(path, j.ID+".json") |
| 169 | switch attack { |
| 170 | case "uid": |
| 171 | if err := os.Chown(file, uid, gid); err != nil { |
| 172 | t.Fatal(err) |
| 173 | } |
| 174 | t.Cleanup(func() { |
| 175 | if err := os.Chown(file, 0, 0); err != nil { |
| 176 | t.Error(err) |
| 177 | } |
| 178 | }) |
| 179 | case "writable-file": |
| 180 | if err := os.Chmod(file, 0666); err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | t.Cleanup(func() { |
| 184 | if err := os.Chmod(file, 0600); err != nil { |
| 185 | t.Error(err) |
| 186 | } |
| 187 | }) |
| 188 | case "writable-parent": |
| 189 | if err := os.Chmod(filepath.Dir(path), 0777); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | t.Cleanup(func() { |
| 193 | if err := os.Chmod(filepath.Dir(path), 0700); err != nil { |
| 194 | t.Error(err) |
| 195 | } |
| 196 | }) |
| 197 | case "symlink", "missing-journal": |
| 198 | if err := os.Rename(file, file+".saved"); err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | if attack == "symlink" { |
| 202 | if err := os.Symlink(file+".saved", file); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | } |
| 206 | t.Cleanup(func() { |
| 207 | if attack == "symlink" { |
| 208 | if err := os.Remove(file); err != nil { |
| 209 | t.Error(err) |
| 210 | } |
| 211 | } |
| 212 | if err := os.Rename(file+".saved", file); err != nil { |
| 213 | t.Error(err) |
| 214 | } |
| 215 | }) |
| 216 | case "pending": |
| 217 | var pending hostbox.RetirementJournal |
| 218 | if err := json.Unmarshal(raw, &pending); err != nil { |
| 219 | t.Fatal(err) |
| 220 | } |
| 221 | pending.Complete = false |
| 222 | body, err := json.Marshal(pending) |
| 223 | if err != nil { |
| 224 | t.Fatal(err) |
| 225 | } |
| 226 | if err := state.Write(j.ID+".json", body); err != nil { |
| 227 | t.Fatal(err) |
| 228 | } |
| 229 | t.Cleanup(func() { |
| 230 | if err := state.Write(j.ID+".json", raw); err != nil { |
| 231 | t.Error(err) |
| 232 | } |
| 233 | }) |
| 234 | case "parked-replacement": |
| 235 | parked := filepath.Join(root, ".parked", "inc", j.Inventory.Host, j.ID, "data", "instances", j.Inventory.Host) |
| 236 | if err := os.Rename(parked, parked+".saved"); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | if err := os.Mkdir(parked, 0700); err != nil { |
| 240 | t.Fatal(err) |
| 241 | } |
| 242 | t.Cleanup(func() { |
| 243 | if err := os.Remove(parked); err != nil { |
| 244 | t.Error(err) |
| 245 | } |
| 246 | if err := os.Rename(parked+".saved", parked); err != nil { |
| 247 | t.Error(err) |
| 248 | } |
| 249 | }) |
| 250 | } |
| 251 | if _, err := v.reconcile(t.Context(), nil); err == nil { |
| 252 | t.Fatal("hostile parked state certified") |
| 253 | } |
| 254 | }) |
| 255 | accept() |
| 256 | } |
| 257 | } |
| 258 | |