carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "crypto/ed25519"
6 "encoding/json"
7 "errors"
8 "flag"
9 "fmt"
10 "github.com/carlosframework/platform/internal/hostactors"
11 "github.com/carlosframework/platform/internal/hostfloor"
12 "github.com/carlosframework/platform/internal/release"
13 "github.com/carlosframework/platform/internal/version"
14 "log"
15 "os"
16 "os/user"
17 "path/filepath"
18 "runtime"
19 "strconv"
20 "strings"
21)
22
23func RunSystemGuardArm(args []string) {
24 fs := flag.NewFlagSet("system guard-arm", flag.ExitOnError)
25 consolePath := fs.String("console", "", "")
26 policyPath := fs.String("policy", "", "")
27 identityPath := fs.String("identity", "", "")
28 enrollmentPath := fs.String("enrollment", "", "")
29 _ = fs.Parse(args)
30 if os.Geteuid() != 0 || *policyPath == "" || *identityPath == "" || *enrollmentPath == "" {
31 log.Fatal(hostfloor.ErrFloor)
32 }
33 lockDir := systemRootDir(release.OptDir())
34 if err := prepareGuardArmLockDir(release.OptDir(), lockDir); err != nil {
35 log.Fatal(err)
36 }
37 lock, err := os.OpenFile(filepath.Join(lockDir, systemLockFile), os.O_CREATE|os.O_RDWR, 0600)
38 if err != nil {
39 log.Fatal(err)
40 }
41 defer lock.Close()
42 if err = flockExclusiveNonblock(lock); err != nil {
43 log.Fatal(err)
44 }
45 d, err := floorDisk(release.OptDir())
46 if err != nil {
47 log.Fatal(err)
48 }
49 raw, err := os.ReadFile(*policyPath)
50 if err != nil {
51 log.Fatal(err)
52 }
53 identity, err := os.ReadFile(*identityPath)
54 if err != nil {
55 log.Fatal(err)
56 }
57 hash, err := hostfloor.ExecutingHash()
58 if err != nil {
59 log.Fatal(err)
60 }
61 platform := runtime.GOOS + "-" + runtime.GOARCH
62 p, f, err := guardArmInputs(raw, identity, d.PublicKey, d.Bucket, version.Build, platform, hash)
63 if err != nil {
64 log.Fatal(err)
65 }
66 if err = guardArmIdle(d.Dir, systemRootDir(release.OptDir())); err != nil {
67 log.Fatal(err)
68 }
69 for _, path := range []string{systemBinaryPath(release.OptDir()), systemPrevPath(release.OptDir())} {
70 candidate, err := hostfloor.OpenProtected(path)
71 if err != nil {
72 log.Fatal(err)
73 }
74 b, err := os.ReadFile(candidate.Name())
75 candidate.Close()
76 if err != nil || hostfloor.CheckImage(d.PublicKey, f, identity, version.Build, platform, hostfloor.Digest(b)) != nil {
77 log.Fatal(hostfloor.ErrFloor)
78 }
79 }
80 // Enrollment input is reporting configuration, verified against the signed
81 // directory before root persists it. It cannot grant roster membership.
82 enrollment, err := hostfloor.ReadEnrollment(*enrollmentPath)
83 if err != nil {
84 log.Fatal(err)
85 }
86 store, pub, err := openSystemStore(context.Background())
87 if err != nil {
88 log.Fatal(err)
89 }
90 roster, err := readGuardRoster(context.Background(), store, pub, d.Bucket)
91 if err != nil {
92 log.Fatal(err)
93 }
94 b, ok := roster.Boxes[enrollment.Box]
95 if ok {
96 if b.Enrollment != enrollment.Enrollment || b.Cloud.InstanceID != enrollment.InstanceID {
97 log.Fatal(hostfloor.ErrFloor)
98 }
99 } else {
100 // A post-floor join must arm and report before it becomes an actor.
101 // The invitation grants a reporting nonce, never execution authority.
102 wire, _, e := store.Get(context.Background(), "control/host-actor-invitations/"+enrollment.Box+".json")
103 if e != nil {
104 log.Fatal(e)
105 }
106 var invitation hostactors.Invitation
107 if release.VerifyRootEnvelope(pub, wire, &invitation) != nil || invitation.Bucket != d.Bucket || invitation.Reference != enrollment.Box || invitation.Enrollment != enrollment.Enrollment {
108 log.Fatal(hostfloor.ErrFloor)
109 }
110 }
111 if err = guardArmControllers(dataDir()); err != nil {
112 log.Fatal(err)
113 }
114 // The guard directories are traversed by non-root units (the console
115 // and the edge exec the guard binary through them), so their mode
116 // cannot depend on the caller's umask: a umask 077 shell once left
117 // them 0700 and the guarded console failed with exit 203.
118 for _, dir := range []string{d.Dir, filepath.Join(d.Dir, "identities")} {
119 if err = mkdirAllExact(dir, 0755); err != nil {
120 log.Fatal(err)
121 }
122 }
123 path := filepath.Join(d.Dir, "policy.json")
124 if _, err = os.Stat(path); os.IsNotExist(err) {
125 initial, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: d.Bucket, Mode: "unpublished"})
126 if err = hostfloor.Durable(path, initial, 0644); err != nil {
127 log.Fatal(err)
128 }
129 }
130 // A migrated box retains its recovery checker until the root floor and
131 // selector are durable. No-census arming keeps its original ordering.
132 if id, required, e := rootMovesFloorIdentity(); e != nil {
133 log.Fatal(e)
134 } else if required {
135 projection, e := readRootFloorProjection(release.OptDir())
136 if e != nil || projection.Identity != id {
137 log.Fatal(errRootFloorHistory)
138 }
139 signed, e := release.VerifySystemIdentity(d.PublicKey, identity)
140 if e != nil {
141 log.Fatal(e)
142 }
143 image := rootFloorBinary{Version: signed.Current.ReleaseTag, Protocol: signed.Current.MovesProtocol, Digest: hash}
144 if !image.supports(projection.Floor) {
145 log.Fatal(errRootFloorHistory)
146 }
147 }
148 // The checker is outside system/router and outside every restore set.
149 if err = copyFileDurable("/proc/self/exe", filepath.Join(d.Dir, "carlos"), 0755); err != nil {
150 log.Fatal(err)
151 }
152 snapshot, err := release.VerifySystemIdentity(d.PublicKey, identity)
153 if err != nil {
154 log.Fatal(err)
155 }
156 if err = hostfloor.Durable(filepath.Join(d.Dir, "identities", snapshot.Current.ReleaseTag+".json"), identity, 0644); err != nil {
157 log.Fatal(err)
158 }
159 enrollmentRaw, _ := json.Marshal(enrollment)
160 if err = hostfloor.Durable("/etc/carlos/host-actor.json", enrollmentRaw, 0644); err != nil {
161 log.Fatal(err)
162 }
163 dropins := map[string]string{
164 "carlos-system-update.service": "[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec apply\n",
165 "carlos-system-recover.service": "[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec recover\n",
166 "carlos-edge.service": "[Unit]\nRequires=carlos-system-recover.service\nAfter=carlos-system-recover.service\n[Service]\nExecStart=\nExecStart=/opt/carlos/system/guard/carlos system guard-exec edge\n",
167 }
168 if *consolePath != "" {
169 if !filepath.IsAbs(*consolePath) || filepath.Clean(*consolePath) != *consolePath {
170 log.Fatal(hostfloor.ErrFloor)
171 }
172 for _, path := range []string{*consolePath, *consolePath + ".prev"} {
173 file, e := hostfloor.OpenProtected(path)
174 if e != nil {
175 log.Fatal(e)
176 }
177 body, e := os.ReadFile(file.Name())
178 file.Close()
179 if e != nil || hostfloor.CheckComponent(d.PublicKey, f, identity, version.Build, platform, hostfloor.Digest(body), "console") != nil {
180 log.Fatal(hostfloor.ErrFloor)
181 }
182 }
183 dropins["console.service"] = hostfloor.ConsoleGuardDropIn(*consolePath)
184 }
185 for unit, body := range dropins {
186 dir := filepath.Join("/etc/systemd/system", unit+".d")
187 if err = os.MkdirAll(dir, 0755); err != nil {
188 log.Fatal(err)
189 }
190 if err = hostfloor.Durable(filepath.Join(dir, "90-host-floor.conf"), []byte(body), 0644); err != nil {
191 log.Fatal(err)
192 }
193 }
194 if _, err = productionSysOps().systemctl("daemon-reload"); err != nil {
195 log.Fatal(err)
196 }
197 if *consolePath != "" {
198 if err = hostfloor.Durable(filepath.Join(d.Dir, "console-ready.json"), []byte(*consolePath), 0644); err != nil {
199 log.Fatal(err)
200 }
201 }
202 if err = d.Persist(p, map[string][]byte{snapshot.Current.ReleaseTag: identity}); err != nil {
203 log.Fatal(err)
204 }
205}
206
207// guardArmInputs is guard-arm's parse of its --policy and --identity files,
208// byte for byte as read: an armed policy for this deployment, and an identity
209// that names the running build (build, platform and the executing image's
210// hash) at or above the policy's floor. The claims phases sign-policy and
211// publish-identity write exactly these files (--out).
212func guardArmInputs(policy, identity []byte, pub ed25519.PublicKey, bucket, build, platform, hash string) (hostfloor.Policy, hostfloor.Verified, error) {
213 var p hostfloor.Policy
214 if json.Unmarshal(policy, &p) != nil || p.Mode != "armed" {
215 return hostfloor.Policy{}, hostfloor.Verified{}, hostfloor.ErrFloor
216 }
217 f, err := p.Verify(pub, bucket)
218 if err != nil {
219 return hostfloor.Policy{}, hostfloor.Verified{}, err
220 }
221 if err = hostfloor.CheckImage(pub, f, identity, build, platform, hash); err != nil {
222 return hostfloor.Policy{}, hostfloor.Verified{}, err
223 }
224 return p, f, nil
225}
226
227// guardArmIdle refuses to arm while root's update state is on the box: an
228// open journal, or any update-begun.json* in the guard directory. Main's
229// region marker, its temporary file or a stale copy beside an armed policy
230// would wedge every guarded update and recovery (Opus re-review M1-3).
231// guard-arm calls it holding the apply lock, so no update writes either
232// while it looks.
233func guardArmIdle(guardDir, rootDir string) error {
234 journal := filepath.Join(rootDir, "journal.json")
235 if _, err := os.Lstat(journal); err == nil {
236 return fmt.Errorf("%w: %s is open; let the update finish or recover first", hostfloor.ErrFloor, journal)
237 } else if !errors.Is(err, os.ErrNotExist) {
238 return err
239 }
240 entries, err := os.ReadDir(guardDir)
241 if errors.Is(err, os.ErrNotExist) {
242 return nil
243 }
244 if err != nil {
245 return err
246 }
247 for _, e := range entries {
248 if strings.HasPrefix(e.Name(), updateBegunFile) {
249 return fmt.Errorf("%w: %s is present; let the update finish or recover first", hostfloor.ErrFloor, filepath.Join(guardDir, e.Name()))
250 }
251 }
252 return nil
253}
254
255// lookupServiceUser resolves the controllers' account; a test seam.
256var lookupServiceUser = user.Lookup
257
258// guardArmControllers creates <DataDir>/.hostexec for the service account
259// before any armed state is written. The armed update path refuses while it
260// is missing (Opus re-review M1-2).
261func guardArmControllers(dataDir string) error {
262 u, err := lookupServiceUser(serviceUser)
263 if err != nil {
264 return err
265 }
266 uid, err := strconv.Atoi(u.Uid)
267 if err != nil {
268 return fmt.Errorf("%w: %s has uid %q", hostfloor.ErrFloor, serviceUser, u.Uid)
269 }
270 gid, err := strconv.Atoi(u.Gid)
271 if err != nil {
272 return fmt.Errorf("%w: %s has gid %q", hostfloor.ErrFloor, serviceUser, u.Gid)
273 }
274 return hostfloor.EnsureControllerDir(dataDir, uid, gid)
275}
276
277// mkdirAllExact is os.MkdirAll with the caller's umask taken out: every
278// directory it creates, and dir itself whether or not it was created, ends
279// with exactly mode. Existing parents keep their modes.
280func mkdirAllExact(dir string, mode os.FileMode) error {
281 var created []string
282 for p := filepath.Clean(dir); ; p = filepath.Dir(p) {
283 if _, err := os.Lstat(p); err == nil {
284 break
285 } else if !os.IsNotExist(err) {
286 return err
287 }
288 created = append(created, p)
289 if parent := filepath.Dir(p); parent == p {
290 break
291 }
292 }
293 if err := os.MkdirAll(dir, mode); err != nil {
294 return err
295 }
296 for _, p := range append(created, filepath.Clean(dir)) {
297 if err := os.Chmod(p, mode); err != nil {
298 return err
299 }
300 }
301 return nil
302}
303
304// prepareGuardArmLockDir makes guard-arm's lock directory under
305// <opt>/system without the caller's umask, and repairs <opt>/system itself
306// to 0755: the guard directories sit under the same parent and non-root
307// units exec the guard binary through it, so a umask 077 run must not
308// leave it closed.
309func prepareGuardArmLockDir(optDir, lockDir string) error {
310 if err := mkdirAllExact(filepath.Join(optDir, "system"), 0755); err != nil {
311 return err
312 }
313 return mkdirAllExact(lockDir, 0755)
314}
315