| 1 | package instancectl |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "encoding/hex" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "net/http" |
| 10 | "os" |
| 11 | "path/filepath" |
| 12 | "strings" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/instancefs" |
| 15 | "github.com/carlosframework/platform/internal/provision" |
| 16 | "github.com/carlosframework/platform/internal/registry" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | ) |
| 19 | |
| 20 | // StagedExe is a nested generation's executable as the agent names it: the |
| 21 | // staged build of the attempt, its version and the sha256 the attempt |
| 22 | // verified (zero-downtime spec §7.5 step 3). |
| 23 | type StagedExe struct { |
| 24 | Path string |
| 25 | Version string |
| 26 | Digest string |
| 27 | } |
| 28 | |
| 29 | // ErrStagedExe is root refusing to mount a staged executable it cannot |
| 30 | // vouch for. The agent abandons the swap; nothing is mounted. |
| 31 | var ErrStagedExe = errors.New("instancectl: staged executable refused") |
| 32 | |
| 33 | // validateStagedExe is root's check before it mounts a staged build into a |
| 34 | // tenant's namespace. Root mounts only what is provably that host's own |
| 35 | // app's staged build: |
| 36 | // - The path must be exactly release.VersionPath for the host's account |
| 37 | // and app, as its registry row records them, at the attempt's version. |
| 38 | // Another app's build, another account's, or any other path is refused. |
| 39 | // - The file is opened beneath the version store with every component |
| 40 | // resolved without a symlink (instancefs.OpenBeneath). |
| 41 | // - Its sha256 must be the attempt's digest. |
| 42 | // |
| 43 | // The version store is written by root alone, so what is checked here is |
| 44 | // what the bind will find. |
| 45 | func (s *Server) validateStagedExe(host string, x StagedExe) error { |
| 46 | if s.HostApp == nil || s.OptDir == "" { |
| 47 | return fmt.Errorf("%w: this service has no registry view", ErrStagedExe) |
| 48 | } |
| 49 | if !release.ValidVersion(x.Version) || len(x.Digest) != 2*sha256.Size { |
| 50 | return fmt.Errorf("%w: version %q, digest %q", ErrStagedExe, x.Version, x.Digest) |
| 51 | } |
| 52 | acct, app, err := s.HostApp(host) |
| 53 | if err != nil || app == "" { |
| 54 | return fmt.Errorf("%w: %s has no app on this box (%v)", ErrStagedExe, host, err) |
| 55 | } |
| 56 | want := release.VersionPath(s.OptDir, release.RouteAccount(registry.Route{Account: acct}), x.Version, app) |
| 57 | if x.Path != want { |
| 58 | return fmt.Errorf("%w: %q is not %s's staged %s (%q)", ErrStagedExe, x.Path, host, x.Version, want) |
| 59 | } |
| 60 | versions := filepath.Join(s.OptDir, "versions") |
| 61 | rel, err := filepath.Rel(versions, want) |
| 62 | if err != nil { |
| 63 | return fmt.Errorf("%w: %v", ErrStagedExe, err) |
| 64 | } |
| 65 | f, err := instancefs.OpenBeneath(versions, rel, os.O_RDONLY, 0) |
| 66 | if err != nil { |
| 67 | return fmt.Errorf("%w: %v", ErrStagedExe, err) |
| 68 | } |
| 69 | defer f.Close() |
| 70 | h := sha256.New() |
| 71 | if _, err := io.Copy(h, f); err != nil { |
| 72 | return fmt.Errorf("%w: %v", ErrStagedExe, err) |
| 73 | } |
| 74 | if got := hex.EncodeToString(h.Sum(nil)); got != strings.ToLower(x.Digest) { |
| 75 | return fmt.Errorf("%w: %s has digest %s, the attempt verified %s", ErrStagedExe, want, got, x.Digest) |
| 76 | } |
| 77 | return nil |
| 78 | } |
| 79 | |
| 80 | // ValidateStagedExe applies the same host-app check to root claim launches. |
| 81 | func (s *Server) ValidateStagedExe(host string, x StagedExe) error { |
| 82 | return s.validateStagedExe(host, x) |
| 83 | } |
| 84 | |
| 85 | // refuseStagedExe answers 403 for a start whose staged executable root |
| 86 | // cannot vouch for, and reports whether it did. A request that names none |
| 87 | // is not refused here. It is the one place every path that binds a staged |
| 88 | // build validates (a generation's start; the original run, seed and |
| 89 | // sidecar units), before anything is laid out. |
| 90 | func (s *Server) refuseStagedExe(w http.ResponseWriter, req request, unit string) bool { |
| 91 | if req.exe.Path == "" { |
| 92 | return false |
| 93 | } |
| 94 | if err := s.validateStagedExe(req.host, req.exe); err != nil { |
| 95 | s.logf("instance-control: %s: not starting %s: %v", req.host, unit, err) |
| 96 | http.Error(w, "not starting "+unit+": "+err.Error(), http.StatusForbidden) |
| 97 | return true |
| 98 | } |
| 99 | return false |
| 100 | } |
| 101 | |
| 102 | // ensureGenExeAt lays out generation n's executable mount. It points at a |
| 103 | // validated staged build when x names one, and at the live link otherwise |
| 104 | // (plan 2's ensureGenExe). |
| 105 | func (s *Server) ensureGenExeAt(host string, n uint64, x StagedExe) error { |
| 106 | if x.Path == "" { |
| 107 | return s.ensureGenExe(host, n) |
| 108 | } |
| 109 | return ensureExe(s.ExeRoot, GenKey(host, n), x.Path, host) |
| 110 | } |
| 111 | |
| 112 | // ensureHostExeAt lays out the host's own executable mount (the one the |
| 113 | // original templates bind: <ExeRoot>/<id>/app). It points at a validated |
| 114 | // staged build when x names one (a legacy sidecar's start, or a run or seed |
| 115 | // unit running the build its cold wake recorded), and at the live link |
| 116 | // otherwise (ensureHostExe). A run's command has a mount of its own |
| 117 | // (ensureCmdExeAt), so none of these starts can repoint the source a |
| 118 | // command is about to bind. |
| 119 | func (s *Server) ensureHostExeAt(host string, x StagedExe) error { |
| 120 | if x.Path == "" { |
| 121 | return s.ensureHostExe(host) |
| 122 | } |
| 123 | return ensureExe(s.ExeRoot, provision.InstanceID(host), x.Path, host) |
| 124 | } |
| 125 | |
| 126 | // ensureCmdExeAt lays out a run's executable mount, apart from the host's |
| 127 | // (merge ruling M4): <ExeRoot>/<id>/cmd-app and cmd-live/<host>, which only |
| 128 | // the cmd template binds. A command and a sidecar starting on different |
| 129 | // builds around a swap therefore never overwrite the source the other is |
| 130 | // about to bind. It points at a validated staged build when x names one, |
| 131 | // and at the live link otherwise. |
| 132 | func (s *Server) ensureCmdExeAt(host string, x StagedExe) error { |
| 133 | target := x.Path |
| 134 | if target == "" { |
| 135 | target = filepath.Join(LiveDir, host) |
| 136 | } |
| 137 | return ensureExeAs(s.ExeRoot, provision.InstanceID(host), target, host, CmdExeApp, CmdExeLive) |
| 138 | } |
| 139 | |
| 140 | // removeCmdExe removes a run's executable mount sources, once its unit is |
| 141 | // stopped: they name a build the instance may no longer serve. Best-effort, |
| 142 | // as removeGenFiles is: a leftover is two links in /run, rewritten by the |
| 143 | // next run's start. |
| 144 | func (s *Server) removeCmdExe(iid string) { |
| 145 | if s.ExeRoot == "" || iid == "" || strings.ContainsAny(iid, "/\x00") || iid == "." || iid == ".." { |
| 146 | return |
| 147 | } |
| 148 | dir := filepath.Join(s.ExeRoot, iid) |
| 149 | _ = os.Remove(filepath.Join(dir, CmdExeApp)) |
| 150 | _ = os.RemoveAll(filepath.Join(dir, CmdExeLive)) |
| 151 | } |
| 152 | |