| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "flag" |
| 10 | "fmt" |
| 11 | "os" |
| 12 | "path/filepath" |
| 13 | "runtime" |
| 14 | "strings" |
| 15 | |
| 16 | "github.com/carlosframework/platform/internal/cliflag" |
| 17 | "github.com/carlosframework/platform/internal/hostfloor" |
| 18 | "github.com/carlosframework/platform/internal/release" |
| 19 | "github.com/carlosframework/platform/internal/version" |
| 20 | ) |
| 21 | |
| 22 | // carlos system install-channel (region HA spec 2 §5 step 3, A1-6, A2-2). |
| 23 | // |
| 24 | // A group box boots from a bootstrap object, which is whatever build the |
| 25 | // deployment last uploaded there, not its neighbours' release. This puts |
| 26 | // the release the box's signed channel pointer names in place instead, |
| 27 | // through the same path `carlos system update` takes: the pointer verified |
| 28 | // with CARLOS_RELEASE_PUBKEY (verifyPointer), the artifact fetched against |
| 29 | // the signed sha256 into the agent's cache, then copied into root's |
| 30 | // staging, checked again and asked its version (stageCandidate). |
| 31 | // |
| 32 | // Both protected slots, carlos and carlos.prev, become that artifact |
| 33 | // (A2-2): guard-arm checks both against the release identity and refuses |
| 34 | // a box with any other build in either. The release identity is kept |
| 35 | // beside them (channelIdentityPath) for the join step's guard-arm, and the |
| 36 | // release must pass the published host floor (hostfloor.CheckImage, the |
| 37 | // check guard-arm makes) before anything is fetched. |
| 38 | // |
| 39 | // It is for a fresh box. A box whose guard is armed, or that already runs |
| 40 | // another release this way or through an update, is refused: its releases |
| 41 | // come through `carlos system update`. Run again on a box it has already |
| 42 | // installed, it changes nothing. |
| 43 | // |
| 44 | // --policy FILE (claim trial, prev-rehearsal ruling R1) is for arming a box |
| 45 | // before the host floor is published. FILE is exactly what `carlos system |
| 46 | // claims run` sign-policy writes. Its floor is verified as guard-arm |
| 47 | // verifies it (the root-signed moves-protocol authorization, for this |
| 48 | // deployment's bucket, under CARLOS_RELEASE_PUBKEY) and stands in for the |
| 49 | // published floor while there is none; a published floor, or either half |
| 50 | // of one, must be the policy's own bytes. Every other refusal stands. It |
| 51 | // also admits the one updated box A2-2 would otherwise leave unarmable: |
| 52 | // one whose carlos already is the channel release's signed artifact. Its |
| 53 | // carlos.prev, the release before, is replaced by that artifact |
| 54 | // (refreshPrev), because guard-arm requires both slots to be the |
| 55 | // identity's release. Without --policy nothing here changes. |
| 56 | // |
| 57 | // --identity FILE (claim trial, untagged-pointer ruling R1) goes with |
| 58 | // --policy, and only with it. FILE is exactly what `carlos system claims run` |
| 59 | // publish-identity writes. The channel pointer `carlos system update` writes |
| 60 | // before a floor is published carries no release tag (the console tags a |
| 61 | // release only once a floor is published), so with --policy the release's |
| 62 | // identity is never found through the pointer: --identity must verify under |
| 63 | // CARLOS_RELEASE_PUBKEY, be byte for byte the object published under its own |
| 64 | // tag, and name the version and this platform's artifact sha256 the verified |
| 65 | // pointer names (and its tag, if the pointer carries one). That identity is |
| 66 | // the one the floor is checked against and the one kept for guard-arm. |
| 67 | |
| 68 | const installChannelUsage = "usage: carlos system install-channel [--policy FILE --identity FILE]\n\nInstall the release this box's channel names, as root, on a box that has none yet.\n" |
| 69 | |
| 70 | const installChannelPolicyFlag = "the armed policy carlos system claims run sign-policy wrote, used as the host floor until one is published" |
| 71 | |
| 72 | const installChannelIdentityFlag = "the release identity carlos system claims run publish-identity wrote, used with --policy in place of the channel's release tag" |
| 73 | |
| 74 | const installChannelNotRoot = "carlos system install-channel runs as root: it installs the platform binary" |
| 75 | |
| 76 | // channelIdentityPath is where the installed release's signed identity is |
| 77 | // kept for guard-arm (§5 step 5.3). |
| 78 | func channelIdentityPath(optDir string) string { |
| 79 | return filepath.Join(systemRootDir(optDir), "channel-identity.json") |
| 80 | } |
| 81 | |
| 82 | // channelInstall is one run. fetchTo is release.FetchToFile over the |
| 83 | // pointer's store, and own hands a directory tree to the service user |
| 84 | // (ownByService); both are seams for tests. |
| 85 | type channelInstall struct { |
| 86 | a *systemApplier |
| 87 | bucket string |
| 88 | fetchTo func(ctx context.Context, s release.Store, key, dst, sha string) error |
| 89 | own func(path string) error |
| 90 | // policy is --policy's file, byte for byte; nil without the flag. |
| 91 | policy []byte |
| 92 | // identity is --identity's file, byte for byte; set with policy and |
| 93 | // only with it. |
| 94 | identity []byte |
| 95 | } |
| 96 | |
| 97 | // slotKind is what one protected slot holds (slotState). |
| 98 | type slotKind int |
| 99 | |
| 100 | const ( |
| 101 | slotAbsent slotKind = iota // nothing there |
| 102 | slotHolds // the signed artifact |
| 103 | slotForeign // a symlink, a non-regular file or any other build |
| 104 | ) |
| 105 | |
| 106 | // channelInstalled is what a run did. |
| 107 | type channelInstalled struct { |
| 108 | Version, Tag, Channel string |
| 109 | Already bool |
| 110 | // Replaced: --policy replaced an updated box's carlos.prev (refreshPrev). |
| 111 | Replaced bool |
| 112 | } |
| 113 | |
| 114 | // RunSystemInstallChannel is `carlos system install-channel`. |
| 115 | func RunSystemInstallChannel(args []string) { |
| 116 | fs := flag.NewFlagSet("system install-channel", flag.ExitOnError) |
| 117 | policyPath := fs.String("policy", "", installChannelPolicyFlag) |
| 118 | identityPath := fs.String("identity", "", installChannelIdentityFlag) |
| 119 | fs.Usage = cliflag.Usage(fs, installChannelUsage) |
| 120 | _ = fs.Parse(args) |
| 121 | if fs.NArg() != 0 { |
| 122 | fs.Usage() |
| 123 | os.Exit(2) |
| 124 | } |
| 125 | if (*policyPath == "") != (*identityPath == "") { |
| 126 | fmt.Fprintln(os.Stderr, errChannelPolicyIdentity) |
| 127 | os.Exit(2) |
| 128 | } |
| 129 | if os.Geteuid() != 0 { |
| 130 | fmt.Fprintln(os.Stderr, installChannelNotRoot) |
| 131 | os.Exit(1) |
| 132 | } |
| 133 | channel, invalid := systemChannel(os.Getenv) |
| 134 | if invalid != "" { |
| 135 | fmt.Fprintln(os.Stderr, fmt.Sprintf("%s=%q is not a channel, so nothing was installed", systemChannelEnv, invalid)) |
| 136 | os.Exit(1) |
| 137 | } |
| 138 | policy, identity, err := readChannelInputs(*policyPath, *identityPath) |
| 139 | if err != nil { |
| 140 | fmt.Fprintln(os.Stderr, err) |
| 141 | os.Exit(1) |
| 142 | } |
| 143 | in := newChannelInstall(channel, policy, identity) |
| 144 | got, err := in.run(context.Background()) |
| 145 | if err != nil { |
| 146 | fmt.Fprintln(os.Stderr, err) |
| 147 | os.Exit(1) |
| 148 | } |
| 149 | fmt.Println(installedLine(got)) |
| 150 | } |
| 151 | |
| 152 | // channelPolicyLimit caps --policy's file: sign-policy writes about 500 |
| 153 | // bytes. |
| 154 | const channelPolicyLimit = 64 << 10 |
| 155 | |
| 156 | // errChannelPolicy is --policy's one refusal of the policy itself, said |
| 157 | // alike before and after its signature is checked. |
| 158 | var errChannelPolicy = errors.New("the --policy file is not an armed policy signed for this deployment, so nothing was installed") |
| 159 | |
| 160 | // errChannelPolicyIdentity refuses --policy without --identity, or |
| 161 | // --identity without --policy. |
| 162 | var errChannelPolicyIdentity = errors.New("--policy and --identity go together, so nothing was installed") |
| 163 | |
| 164 | // errChannelIdentity is --identity's refusal of a file that is not a release |
| 165 | // identity signed under the box's release key. |
| 166 | var errChannelIdentity = errors.New("the --identity file is not a release identity signed for this deployment, so nothing was installed") |
| 167 | |
| 168 | // newChannelInstall is the production run. The --policy path refreshes |
| 169 | // carlos.prev as an update does, so its applier reads the root |
| 170 | // participation census as the update's does (systemapply.go): on a census |
| 171 | // box it takes the root moves floor branch, where a floor step that fails |
| 172 | // fails (fix round 1, I2). Without --policy the applier is as it was. |
| 173 | func newChannelInstall(channel string, policy, identity []byte) channelInstall { |
| 174 | in := channelInstall{ |
| 175 | a: &systemApplier{ops: productionSysOps(), openStore: openSystemStore, channel: channel, optDir: release.OptDir(), |
| 176 | dataDir: dataDir(), etcDir: "/etc", build: version.Build, platform: runtime.GOOS + "-" + runtime.GOARCH}, |
| 177 | bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), |
| 178 | fetchTo: func(ctx context.Context, s release.Store, key, dst, sha string) error { |
| 179 | return release.FetchToFile(ctx, s, key, dst, systemArtifactLimit, sha) |
| 180 | }, |
| 181 | own: ownByService, |
| 182 | policy: policy, |
| 183 | identity: identity, |
| 184 | } |
| 185 | if policy != nil { |
| 186 | in.a.rootMoves = rootMovesFloorIdentity |
| 187 | } |
| 188 | return in |
| 189 | } |
| 190 | |
| 191 | // readChannelInputs reads --policy's and --identity's files, before the lock |
| 192 | // or any store read: both or neither (errChannelPolicyIdentity), the policy |
| 193 | // first (readChannelPolicy), then the identity through the same hardened |
| 194 | // open (openChannelPolicy). The identity is verified once the release key is |
| 195 | // known (channelIdentity). With neither flag nothing is read. |
| 196 | func readChannelInputs(policyPath, identityPath string) (policy, identity []byte, err error) { |
| 197 | if (policyPath == "") != (identityPath == "") { |
| 198 | return nil, nil, errChannelPolicyIdentity |
| 199 | } |
| 200 | if policyPath == "" { |
| 201 | return nil, nil, nil |
| 202 | } |
| 203 | if policy, err = readChannelPolicy(policyPath); err != nil { |
| 204 | return nil, nil, err |
| 205 | } |
| 206 | if identity, err = openChannelPolicy(identityPath, channelPolicyLimit); err != nil { |
| 207 | return nil, nil, fmt.Errorf("the identity %s could not be read, so nothing was installed: %w", identityPath, err) |
| 208 | } |
| 209 | return policy, identity, nil |
| 210 | } |
| 211 | |
| 212 | // readChannelPolicy reads --policy's file and refuses, before anything else |
| 213 | // is read, a file that is not an armed policy at all. It is opened without |
| 214 | // following a symlink or waiting on a FIFO, and must be a regular file of |
| 215 | // at most channelPolicyLimit bytes (openChannelPolicy). Its signature is |
| 216 | // checked once the release key is known (channelPolicy). |
| 217 | func readChannelPolicy(path string) ([]byte, error) { |
| 218 | raw, err := openChannelPolicy(path, channelPolicyLimit) |
| 219 | if err != nil { |
| 220 | return nil, fmt.Errorf("the policy %s could not be read, so nothing was installed: %w", path, err) |
| 221 | } |
| 222 | var p hostfloor.Policy |
| 223 | if json.Unmarshal(raw, &p) != nil || p.Mode != "armed" { |
| 224 | return nil, errChannelPolicy |
| 225 | } |
| 226 | return raw, nil |
| 227 | } |
| 228 | |
| 229 | // channelPolicy is guard-arm's check of --policy (guardArmInputs, first |
| 230 | // half): an armed policy whose floor carries the root-signed |
| 231 | // moves-protocol authorization for this deployment's bucket. |
| 232 | func channelPolicy(raw []byte, pub ed25519.PublicKey, bucket string) (hostfloor.Policy, hostfloor.Verified, error) { |
| 233 | var p hostfloor.Policy |
| 234 | if json.Unmarshal(raw, &p) != nil || p.Mode != "armed" { |
| 235 | return hostfloor.Policy{}, hostfloor.Verified{}, hostfloor.ErrFloor |
| 236 | } |
| 237 | f, err := p.Verify(pub, bucket) |
| 238 | if err != nil { |
| 239 | return hostfloor.Policy{}, hostfloor.Verified{}, err |
| 240 | } |
| 241 | return p, f, nil |
| 242 | } |
| 243 | |
| 244 | func installedLine(got channelInstalled) string { |
| 245 | if got.Replaced { |
| 246 | return fmt.Sprintf("replaced the rollback copy carlos.prev with %s (%s) from the %s channel, the release carlos already runs. the release carlos.prev held before is no longer on this box", got.Version, got.Tag, got.Channel) |
| 247 | } |
| 248 | if got.Already { |
| 249 | return fmt.Sprintf("%s (%s) from the %s channel is already installed", got.Version, got.Tag, got.Channel) |
| 250 | } |
| 251 | return fmt.Sprintf("installed %s (%s) from the %s channel as carlos and carlos.prev", got.Version, got.Tag, got.Channel) |
| 252 | } |
| 253 | |
| 254 | // run takes the apply lock, so no update runs beside it, and installs. |
| 255 | func (in channelInstall) run(ctx context.Context) (channelInstalled, error) { |
| 256 | a := in.a |
| 257 | if err := os.MkdirAll(a.rootDir(), 0o755); err != nil { |
| 258 | return channelInstalled{}, err |
| 259 | } |
| 260 | lock, err := os.OpenFile(a.lockPath(), os.O_CREATE|os.O_RDWR, 0o600) |
| 261 | if err != nil { |
| 262 | return channelInstalled{}, err |
| 263 | } |
| 264 | defer lock.Close() |
| 265 | if err := flockExclusiveNonblock(lock); err != nil { |
| 266 | return channelInstalled{}, fmt.Errorf("an update is running on this box, so nothing was installed: %w", err) |
| 267 | } |
| 268 | return in.install(ctx) |
| 269 | } |
| 270 | |
| 271 | func (in channelInstall) install(ctx context.Context) (channelInstalled, error) { |
| 272 | a := in.a |
| 273 | var pub ed25519.PublicKey |
| 274 | open := a.openStore |
| 275 | a.openStore = func(ctx context.Context) (release.Store, ed25519.PublicKey, error) { |
| 276 | s, k, err := open(ctx) |
| 277 | pub = k |
| 278 | return s, k, err |
| 279 | } |
| 280 | p, err := a.verifyPointer(ctx) |
| 281 | if err != nil { |
| 282 | return channelInstalled{}, fmt.Errorf("the %s channel could not be read, so nothing was installed: %w", a.channel, err) |
| 283 | } |
| 284 | got := channelInstalled{Version: p.Current.Version, Tag: p.Current.ReleaseTag, Channel: a.channel} |
| 285 | var policy *hostfloor.Policy |
| 286 | var policyFloor hostfloor.Verified |
| 287 | if in.policy != nil { |
| 288 | if in.identity == nil { |
| 289 | return got, errChannelPolicyIdentity |
| 290 | } |
| 291 | pol, f, err := channelPolicy(in.policy, pub, in.bucket) |
| 292 | if err != nil { |
| 293 | return got, errChannelPolicy |
| 294 | } |
| 295 | policy, policyFloor = &pol, f |
| 296 | } |
| 297 | art, ok := p.Current.Artifacts[a.platform] |
| 298 | if !ok { |
| 299 | return got, fmt.Errorf("the %s channel names %s with no artifact for %s, so nothing was installed", a.channel, p.Current.Version, a.platform) |
| 300 | } |
| 301 | // A box whose guard is armed takes releases only through an update. |
| 302 | guard := hostfloor.Disk{Dir: systemGuardDir(a.optDir), Bucket: in.bucket, PublicKey: pub} |
| 303 | if policy, _, err := guard.Load(); err == nil && policy.Mode != "unpublished" { |
| 304 | return got, errors.New("this box's host lifecycle guard is armed, so carlos system update installs its releases") |
| 305 | } else if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 306 | return got, fmt.Errorf("this box's host lifecycle guard could not be read, so nothing was installed: %w", err) |
| 307 | } |
| 308 | applied, err := readSystemApplied(a.optDir) |
| 309 | if err != nil { |
| 310 | return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err) |
| 311 | } |
| 312 | if applied != nil && applied.Version != p.Current.Version { |
| 313 | return got, fmt.Errorf("this box already runs %s, so carlos system update installs its releases", applied.Version) |
| 314 | } |
| 315 | wire, tag, err := in.checkFloor(ctx, p, art, pub, policy, policyFloor) |
| 316 | if err != nil { |
| 317 | return got, err |
| 318 | } |
| 319 | // The tag is the identity's: with --policy it is --identity's, which an |
| 320 | // untagged pointer cannot name. |
| 321 | got.Tag = tag |
| 322 | // With --policy, a box whose update has not finished (an open journal, |
| 323 | // or an update-begun marker beside the guard) is refused, as guard-arm |
| 324 | // refuses it: recovery owns carlos.prev until then (fix round 1, I1). |
| 325 | if policy != nil { |
| 326 | if err := guardArmIdle(systemGuardDir(a.optDir), systemRootDir(a.optDir)); err != nil { |
| 327 | // The reason is the journal or marker guard-arm names, without |
| 328 | // guard-arm's floor prefix: it is an unfinished update. |
| 329 | if errors.Is(err, hostfloor.ErrFloor) { |
| 330 | err = errors.New(strings.TrimPrefix(err.Error(), hostfloor.ErrFloor.Error()+": ")) |
| 331 | } |
| 332 | return got, fmt.Errorf("an update on this box has not finished, so nothing was installed: %w", err) |
| 333 | } |
| 334 | } |
| 335 | already := systemCacheHolds(systemBinaryPath(a.optDir), art.SHA256) && systemCacheHolds(systemPrevPath(a.optDir), art.SHA256) && |
| 336 | systemCacheHolds(channelIdentityPath(a.optDir), hostfloor.Digest(wire)) |
| 337 | if applied != nil { |
| 338 | // applied.json is the last thing a run writes, so a box with one is |
| 339 | // either this install, complete, or a box an update has since |
| 340 | // managed: its releases come through carlos system update. |
| 341 | if already { |
| 342 | // With --policy, "already installed" also needs carlos.prev's |
| 343 | // version record to name this release, and its judgement to have |
| 344 | // run. A record a cut-off run left missing or stale is completed |
| 345 | // first (which judges); otherwise the judgement runs again, as it |
| 346 | // is idempotent, so one a run left unfinished is finished. Either |
| 347 | // failing fails the run (fix round 2). |
| 348 | if policy != nil { |
| 349 | var err error |
| 350 | if !prevRecorded(a.optDir, p.Current.Version) { |
| 351 | err = in.completePrev(ctx, p.Current.Version, false) |
| 352 | } else { |
| 353 | err = channelPrevJudge(a, ctx) |
| 354 | } |
| 355 | if err != nil { |
| 356 | return got, err |
| 357 | } |
| 358 | } |
| 359 | got.Already = true |
| 360 | return got, nil |
| 361 | } |
| 362 | if policy != nil { |
| 363 | return in.refreshPrev(ctx, p, art, wire, got) |
| 364 | } |
| 365 | return got, fmt.Errorf("this box already runs %s, so carlos system update installs its releases", applied.Version) |
| 366 | } |
| 367 | // With no applied.json, a slot that holds anything but the signed |
| 368 | // artifact is a build this command did not install, and is refused and |
| 369 | // left as it is. A run cut off part way leaves only the artifact behind |
| 370 | // (carlos.prev is written first), so a retry passes. Each slot is opened |
| 371 | // without following a symlink and hashed from the descriptor, against the |
| 372 | // verified pointer's sha256; nothing is executed to learn its version. |
| 373 | for _, slot := range []string{systemBinaryPath(a.optDir), systemPrevPath(a.optDir)} { |
| 374 | state, err := slotState(slot, art.SHA256) |
| 375 | if err != nil { |
| 376 | return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err) |
| 377 | } |
| 378 | if state == slotForeign { |
| 379 | return got, fmt.Errorf("%s holds a build this command did not install, so nothing was installed", slot) |
| 380 | } |
| 381 | } |
| 382 | cache := systemCachePath(a.dataDir, p.Current.Version) |
| 383 | defer os.RemoveAll(a.stagingDir()) |
| 384 | if err := in.cacheDir(p.Current.Version); err != nil { |
| 385 | return got, err |
| 386 | } |
| 387 | // Only now is the version directory ours to remove, and it is removed |
| 388 | // through the held router directory, never by a path that could pass a |
| 389 | // symlink (the service user owns the parents). |
| 390 | defer func() { _ = removeCacheEntry(a.dataDir, p.Current.Version) }() |
| 391 | if err := in.fetchTo(ctx, a.store, art.Key, cache, art.SHA256); err != nil { |
| 392 | return got, fmt.Errorf("%s could not be fetched, so nothing was installed: %w", p.Current.Version, err) |
| 393 | } |
| 394 | staged, err := a.stageCandidate(ctx, p, systemRequest{Version: p.Current.Version, PromotedAt: p.PromotedAt}) |
| 395 | if err != nil { |
| 396 | return got, fmt.Errorf("%s did not verify, so nothing was installed: %w", p.Current.Version, err) |
| 397 | } |
| 398 | // carlos.prev first: a run cut off between the two copies leaves the |
| 399 | // new build in .prev and none in the current slot, which the next run |
| 400 | // replaces, never the other way round. |
| 401 | for _, slot := range []string{systemPrevPath(a.optDir), systemBinaryPath(a.optDir)} { |
| 402 | if err := copyFileDurable(staged, slot, 0o755); err != nil { |
| 403 | return got, err |
| 404 | } |
| 405 | if !systemCacheHolds(slot, art.SHA256) { |
| 406 | return got, fmt.Errorf("%s does not hold %s after the copy", slot, p.Current.Version) |
| 407 | } |
| 408 | } |
| 409 | // With --policy, carlos.prev's version record is part of the install |
| 410 | // (completePrev); without it the install is exactly as it was. |
| 411 | if policy != nil { |
| 412 | if err := in.completePrev(ctx, p.Current.Version, false); err != nil { |
| 413 | return got, err |
| 414 | } |
| 415 | } |
| 416 | if err := hostfloor.Durable(channelIdentityPath(a.optDir), wire, 0o644); err != nil { |
| 417 | return got, err |
| 418 | } |
| 419 | seen, err := readSystemSeen(a.optDir) |
| 420 | if err != nil { |
| 421 | return got, err |
| 422 | } |
| 423 | seen[a.channel] = p.PromotedAt |
| 424 | if err := writeJSONDurable(a.file("seen.json"), seen, 0o644); err != nil { |
| 425 | return got, err |
| 426 | } |
| 427 | if err := writeJSONDurable(a.file("applied.json"), systemApplied{Version: p.Current.Version, Channel: a.channel, At: a.ops.now().UTC()}, 0o644); err != nil { |
| 428 | return got, err |
| 429 | } |
| 430 | return got, nil |
| 431 | } |
| 432 | |
| 433 | // checkFloor reads the published host floor and the release's signed |
| 434 | // identity, and returns the identity once the release passes the floor |
| 435 | // exactly as guard-arm will check it. No floor, or one that cannot be read, |
| 436 | // refuses: a joining box could not arm its guard without one, and a read |
| 437 | // that failed says nothing about where the floor is. With --policy |
| 438 | // (policy non-nil) the policy's verified floor is the floor instead |
| 439 | // (policyFloorPublished), and the identity is --identity's, never one found |
| 440 | // through the pointer's tag (channelIdentity). The tag returned is the |
| 441 | // identity's. |
| 442 | func (in channelInstall) checkFloor(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, pub ed25519.PublicKey, policy *hostfloor.Policy, policyFloor hostfloor.Verified) ([]byte, string, error) { |
| 443 | s := in.a.store |
| 444 | var floor hostfloor.Verified |
| 445 | var wire []byte |
| 446 | tag := p.Current.ReleaseTag |
| 447 | if policy != nil { |
| 448 | if err := policyFloorPublished(ctx, s, *policy); err != nil { |
| 449 | return nil, "", err |
| 450 | } |
| 451 | floor = policyFloor |
| 452 | var err error |
| 453 | if wire, tag, err = in.channelIdentity(ctx, s, p, art, pub); err != nil { |
| 454 | return nil, "", err |
| 455 | } |
| 456 | } else { |
| 457 | raw, _, err := s.Get(ctx, hostfloor.Key) |
| 458 | if errors.Is(err, os.ErrNotExist) { |
| 459 | return nil, "", errors.New("no host floor is published, so this box could not arm its guard and nothing was installed") |
| 460 | } |
| 461 | if err != nil { |
| 462 | return nil, "", fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| 463 | } |
| 464 | auth, _, err := s.Get(ctx, hostfloor.AuthKey) |
| 465 | if err != nil { |
| 466 | return nil, "", fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| 467 | } |
| 468 | floor, err = hostfloor.Verify(pub, in.bucket, raw, auth) |
| 469 | if err != nil { |
| 470 | return nil, "", fmt.Errorf("the host floor does not verify, so nothing was installed: %w", err) |
| 471 | } |
| 472 | key, err := release.IdentityKey(p.Current.ReleaseTag) |
| 473 | if err != nil { |
| 474 | return nil, "", fmt.Errorf("the %s channel names %s with no release tag, so nothing was installed", in.a.channel, p.Current.Version) |
| 475 | } |
| 476 | wire, _, err = s.Get(ctx, key) |
| 477 | if err != nil { |
| 478 | return nil, "", fmt.Errorf("the release identity for %s could not be read, so nothing was installed: %w", p.Current.ReleaseTag, err) |
| 479 | } |
| 480 | } |
| 481 | if err := hostfloor.CheckImage(pub, floor, wire, p.Current.Version, in.a.platform, art.SHA256); err != nil { |
| 482 | return nil, "", fmt.Errorf("%s (%s) is below the host floor %s or is not the release its identity names, so nothing was installed", p.Current.Version, tag, floor.Floor.MinRelease) |
| 483 | } |
| 484 | return wire, tag, nil |
| 485 | } |
| 486 | |
| 487 | // channelIdentity is --identity's check (untagged-pointer ruling R1), once |
| 488 | // the release key is known: the file verifies as a release identity under |
| 489 | // it, is byte for byte the object published at release.IdentityKey of its |
| 490 | // own tag (none published under it is its own refusal), and names what the |
| 491 | // verified pointer names: the same version and |
| 492 | // the same sha256 for this platform's artifact, and, when the pointer |
| 493 | // carries a release tag, that tag. It returns the identity's bytes and tag. |
| 494 | // Nothing is read through the pointer's tag. |
| 495 | func (in channelInstall) channelIdentity(ctx context.Context, s release.Store, p *release.SystemPointer, art release.SystemArtifact, pub ed25519.PublicKey) ([]byte, string, error) { |
| 496 | id, err := release.VerifySystemIdentity(pub, in.identity) |
| 497 | if err != nil { |
| 498 | return nil, "", errChannelIdentity |
| 499 | } |
| 500 | tag := id.Current.ReleaseTag |
| 501 | key, err := release.IdentityKey(tag) |
| 502 | if err != nil { |
| 503 | return nil, "", errChannelIdentity |
| 504 | } |
| 505 | published, _, err := s.Get(ctx, key) |
| 506 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 507 | return nil, "", fmt.Errorf("the release identity for %s could not be read, so nothing was installed: %w", tag, err) |
| 508 | } |
| 509 | if err != nil { |
| 510 | return nil, "", fmt.Errorf("no release identity is published for %s, so nothing was installed", tag) |
| 511 | } |
| 512 | if !bytes.Equal(published, in.identity) { |
| 513 | return nil, "", fmt.Errorf("the --identity file is not the identity published for %s, so nothing was installed", tag) |
| 514 | } |
| 515 | router, ok := id.Current.Artifacts[in.a.platform] |
| 516 | if !ok || router.SHA256 != art.SHA256 || id.Current.Version != p.Current.Version || p.Current.ReleaseTag != "" && p.Current.ReleaseTag != tag { |
| 517 | return nil, "", fmt.Errorf("the --identity file names %s (%s), not the release the %s channel names, so nothing was installed", id.Current.Version, tag, in.a.channel) |
| 518 | } |
| 519 | return in.identity, tag, nil |
| 520 | } |
| 521 | |
| 522 | // policyFloorPublished checks --policy against what the bucket holds: no |
| 523 | // floor published, or a published pair (or the half of one that a torn |
| 524 | // publication leaves; hostfloor.Publish writes the authorization first) |
| 525 | // that is the policy's floor and authorization byte for byte. Anything |
| 526 | // else, a read that fails included, refuses. |
| 527 | func policyFloorPublished(ctx context.Context, s release.Store, policy hostfloor.Policy) error { |
| 528 | for _, half := range []struct { |
| 529 | key string |
| 530 | want []byte |
| 531 | }{{hostfloor.AuthKey, policy.Authorization}, {hostfloor.Key, policy.Floor}} { |
| 532 | got, _, err := s.Get(ctx, half.key) |
| 533 | if errors.Is(err, os.ErrNotExist) { |
| 534 | continue |
| 535 | } |
| 536 | if err != nil { |
| 537 | return fmt.Errorf("the host floor could not be read, so nothing was installed: %w", err) |
| 538 | } |
| 539 | if !bytes.Equal(got, half.want) { |
| 540 | return errors.New("the published host floor is not the floor in the --policy file, so nothing was installed") |
| 541 | } |
| 542 | } |
| 543 | return nil |
| 544 | } |
| 545 | |
| 546 | // channelPrevVersionWrite is writePrevVersion, a seam for the tests that |
| 547 | // the record's write failing fails the --policy install. |
| 548 | var channelPrevVersionWrite = writePrevVersion |
| 549 | |
| 550 | // prevRecorded reports whether carlos.prev's version record names version. |
| 551 | func prevRecorded(optDir, version string) bool { |
| 552 | v, ok := readLine(prevVersionPath(optDir)) |
| 553 | return ok && v == version |
| 554 | } |
| 555 | |
| 556 | // completePrev is --policy's refresh of carlos.prev's records, the steps |
| 557 | // an update's notePrev takes but with every one mandatory, armed floor or |
| 558 | // not (prev-rehearsal fix round 1): under the prev lock, the verdicts and |
| 559 | // the version record of what carlos.prev held are removed, carlos is |
| 560 | // copied over it when copyCurrent is set, and the version record is |
| 561 | // written and read back. Any failure fails the run, which then keeps no |
| 562 | // identity and reports nothing done; a retry completes it. The floor's |
| 563 | // judgement follows as an update makes it (notePrev without a replace): |
| 564 | // a judgement that cannot be made leaves no verdict, which refuses. |
| 565 | func (in channelInstall) completePrev(ctx context.Context, version string, copyCurrent bool) error { |
| 566 | a := in.a |
| 567 | err := withPrevLock(a.optDir, func() error { |
| 568 | for _, path := range []string{prevAllowedPath(a.optDir), seedsPrevAllowedPath(a.optDir), prevVersionPath(a.optDir)} { |
| 569 | if err := removeDurable(path); err != nil { |
| 570 | return err |
| 571 | } |
| 572 | } |
| 573 | if copyCurrent { |
| 574 | if err := copyFileDurable(systemBinaryPath(a.optDir), systemPrevPath(a.optDir), 0o755); err != nil { |
| 575 | return err |
| 576 | } |
| 577 | } |
| 578 | return channelPrevVersionWrite(a.optDir, version) |
| 579 | }) |
| 580 | if err == nil && !prevRecorded(a.optDir, version) { |
| 581 | err = fmt.Errorf("%s does not name %s", prevVersionPath(a.optDir), version) |
| 582 | } |
| 583 | if err != nil { |
| 584 | return fmt.Errorf("the record of the release in carlos.prev could not be written, so the install is not complete. run it again: %w", err) |
| 585 | } |
| 586 | return channelPrevJudge(a, ctx) |
| 587 | } |
| 588 | |
| 589 | // channelPrevJudge is the floor's judgement of carlos.prev, as an update |
| 590 | // makes it (notePrev without a replace); a seam for the tests of a |
| 591 | // judgement that fails or is cut off. |
| 592 | var channelPrevJudge = func(a *systemApplier, ctx context.Context) error { return a.notePrev(ctx, false, "") } |
| 593 | |
| 594 | // refreshPrev is --policy's one admission of an updated box (applied.json |
| 595 | // names the channel release, and the slots and the identity are not yet |
| 596 | // install-channel's): carlos must already hold the channel release's |
| 597 | // signed artifact, hashed from a descriptor opened with O_NOFOLLOW |
| 598 | // (slotState). Its carlos.prev, the release the update replaced, is then |
| 599 | // refreshed from carlos the way an update refreshes it (notePrev: the |
| 600 | // sidecar and verdicts of the old carlos.prev removed first, the copy, the |
| 601 | // version sidecar, the floor's judgement), every step mandatory here |
| 602 | // (completePrev), and only then is the identity kept beside |
| 603 | // them. Nothing is fetched, and applied.json and seen.json are the |
| 604 | // update's and stay as they are. The caller has already refused an armed |
| 605 | // guard, another release and a release below the policy's floor. |
| 606 | // |
| 607 | // Why A2-2 allows it: A2-2 puts the identity's artifact in both slots so |
| 608 | // guard-arm's two-slot rule can pass, and on a fresh box carlos.prev is |
| 609 | // then a copy of carlos. Here carlos already is that artifact, so this |
| 610 | // reaches A2-2's end state exactly. What is lost is the rollback to the |
| 611 | // release before, which is below the floor whenever the floor is the |
| 612 | // release being armed, and which the armed guard would refuse to run. |
| 613 | func (in channelInstall) refreshPrev(ctx context.Context, p *release.SystemPointer, art release.SystemArtifact, wire []byte, got channelInstalled) (channelInstalled, error) { |
| 614 | a := in.a |
| 615 | current, prev := systemBinaryPath(a.optDir), systemPrevPath(a.optDir) |
| 616 | state, err := slotState(current, art.SHA256) |
| 617 | if err != nil { |
| 618 | return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err) |
| 619 | } |
| 620 | if state != slotHolds { |
| 621 | return got, fmt.Errorf("%s holds a build this command did not install, so nothing was installed", current) |
| 622 | } |
| 623 | held, err := slotState(prev, art.SHA256) |
| 624 | if err != nil { |
| 625 | return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err) |
| 626 | } |
| 627 | if held == slotForeign { |
| 628 | // The release before is replaced; a symlink or anything but a |
| 629 | // regular file is not what an update leaves, and is refused. |
| 630 | if fi, err := os.Lstat(prev); err != nil { |
| 631 | return got, fmt.Errorf("this box's install record could not be read, so nothing was installed: %w", err) |
| 632 | } else if !fi.Mode().IsRegular() { |
| 633 | return got, fmt.Errorf("%s holds a build this command did not install, so nothing was installed", prev) |
| 634 | } |
| 635 | } |
| 636 | // Always, even over a carlos.prev a cut-off run already copied, so the |
| 637 | // version record is this release's. The record is mandatory here. |
| 638 | if err := in.completePrev(ctx, p.Current.Version, true); err != nil { |
| 639 | return got, err |
| 640 | } |
| 641 | // The replaced line names a release that was there; a retry that found |
| 642 | // carlos.prev already this release, or none, says only what is installed. |
| 643 | replaced := held == slotForeign |
| 644 | for _, slot := range []string{prev, current} { |
| 645 | if state, err := slotState(slot, art.SHA256); err != nil || state != slotHolds { |
| 646 | return got, fmt.Errorf("%s does not hold %s after the copy", slot, p.Current.Version) |
| 647 | } |
| 648 | } |
| 649 | if err := hostfloor.Durable(channelIdentityPath(a.optDir), wire, 0o644); err != nil { |
| 650 | return got, err |
| 651 | } |
| 652 | got.Replaced = replaced |
| 653 | return got, nil |
| 654 | } |
| 655 | |
| 656 | // cacheDir makes the agent's cache entry for version and reconciles the |
| 657 | // ownership of every directory on its path below the data dir, on every |
| 658 | // run: system, system/router and system/router/<version> are created as |
| 659 | // bootstrap creates directories (bootstrapMakeDir: each component opened |
| 660 | // with no symlink followed, so a symlinked parent is refused) and each is |
| 661 | // handed to the service user, as bootstrap's chown of /data does |
| 662 | // (privilegedPlan), whether this run created it or an earlier run that |
| 663 | // failed before its handoff did. Nothing else is chowned. The agent |
| 664 | // (User=carlos) later downloads releases beside the entry and writes its |
| 665 | // update requests there. Only the entry itself is removed after the |
| 666 | // install; its parents stay, owned by the agent. |
| 667 | func (in channelInstall) cacheDir(version string) error { |
| 668 | parts := systemCacheParts(version) |
| 669 | dir := in.a.dataDir |
| 670 | for _, part := range parts[:len(parts)-1] { |
| 671 | dir = filepath.Join(dir, part) |
| 672 | if err := bootstrapMakeDir(dir, 0o755); err != nil { |
| 673 | return err |
| 674 | } |
| 675 | if err := in.own(dir); err != nil { |
| 676 | return err |
| 677 | } |
| 678 | } |
| 679 | return nil |
| 680 | } |
| 681 | |