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 "log"
11 "os"
12 "path/filepath"
13 "runtime"
14
15 "github.com/carlosframework/platform/internal/boxid"
16 "github.com/carlosframework/platform/internal/cliflag"
17 "github.com/carlosframework/platform/internal/hostactors"
18 "github.com/carlosframework/platform/internal/hostfloor"
19 "github.com/carlosframework/platform/internal/hostwrite"
20 "github.com/carlosframework/platform/internal/release"
21 "github.com/carlosframework/platform/internal/version"
22)
23
24// carlos system join (region HA spec 2 §5 step 5.3): root's half of
25// joining, run by carlos-system-join.service when the joining agent asks.
26//
27// The invitation grants no membership: it is the console's signed nonce
28// for this box's identity record, with which the box installs its guard
29// and reports. Root reads it itself and checks it: signed by the release
30// key, for this deployment and this box, for the identity record this box
31// published, which must be this instance's. Then it writes the enrollment
32// the invitation names, builds the armed policy from the published host
33// floor, and runs guard-arm with the release identity install-channel
34// kept (channelIdentityPath). guard-arm checks both protected slots against
35// that identity and writes /etc/carlos/host-actor.json.
36//
37// A box already armed, a box with no invitation yet, and a box with claim
38// mode or region HA off have nothing to do.
39
40const joinUsage = "usage: carlos system join\n\n" +
41 "Arm the host lifecycle guard of a joining box, as root, once the console has invited it.\n"
42
43const joinNotRoot = "carlos system join runs as root: it arms the host lifecycle guard"
44
45// joinDir holds what root hands guard-arm: the enrollment and the policy.
46func joinDir(optDir string) string { return filepath.Join(optDir, "system", "join") }
47
48// rootJoin is one run; production's is RunSystemJoin's.
49type rootJoin struct {
50 box string
51 optDir string
52 bucket string
53 store release.Store
54 pub ed25519.PublicKey
55 instance func() string
56 // armed reports whether the guard is armed for host-actor.json.
57 armed func() bool
58 // arm is guard-arm, given the three files.
59 arm func(policy, identity, enrollment string) error
60}
61
62// joinOutcome is what a run did.
63type joinOutcome string
64
65const (
66 joinArmedAlready joinOutcome = "armed already"
67 joinNotInvited joinOutcome = "not invited"
68 joinArmed joinOutcome = "armed"
69)
70
71// RunSystemJoin is `carlos system join`.
72func RunSystemJoin(args []string) {
73 fs := flag.NewFlagSet("system join", flag.ExitOnError)
74 fs.Usage = cliflag.Usage(fs, joinUsage)
75 _ = fs.Parse(args)
76 if fs.NArg() != 0 {
77 fs.Usage()
78 os.Exit(2)
79 }
80 if os.Geteuid() != 0 {
81 fmt.Fprintln(os.Stderr, joinNotRoot)
82 os.Exit(1)
83 }
84 if !hostwrite.Enabled(os.Getenv) || !regionHAEnabled(os.Getenv) {
85 fmt.Println("CARLOS_HOST_CLAIM or CARLOS_REGION_HA is off, so this box does not join")
86 return
87 }
88 ctx := context.Background()
89 store, pub, err := openSystemStore(ctx)
90 if err != nil {
91 fmt.Fprintln(os.Stderr, err)
92 os.Exit(1)
93 }
94 optDir := release.OptDir()
95 box, err := joinBoxLabel(os.Getenv, readMachineID())
96 if err != nil {
97 fmt.Fprintln(os.Stderr, err)
98 os.Exit(1)
99 }
100 j := rootJoin{
101 box: box, optDir: optDir, bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"),
102 store: store, pub: pub, instance: cachedInstanceID,
103 armed: func() bool {
104 if _, err := hostfloor.ReadEnrollment(hostActorPath); err != nil {
105 return false
106 }
107 d, err := floorDisk(optDir)
108 if err != nil {
109 return false
110 }
111 r, err := d.Running(version.Build, runtime.GOOS+"-"+runtime.GOARCH)
112 return err == nil && r.GuardReady
113 },
114 arm: func(policy, identity, enrollment string) error {
115 // guard-arm exits the process on any refusal, with its reason.
116 RunSystemGuardArm([]string{"-policy", policy, "-identity", identity, "-enrollment", enrollment})
117 return nil
118 },
119 }
120 got, err := j.run(ctx)
121 if err != nil {
122 fmt.Fprintln(os.Stderr, err)
123 os.Exit(1)
124 }
125 log.Printf("system join: %s: %s", j.box, got)
126}
127
128// joinBoxLabel is the name this box joins under: metricsBoxLabel's, except
129// that a group box whose reference IMDS has not given yet is an error with
130// IMDS's reason. Its label would be "", and "" names no invitation, so the
131// run would end as "not invited" on a box that may well be invited.
132func joinBoxLabel(getenv func(string) string, machineID string) (string, error) {
133 if groupReferenced(getenv) {
134 ref, err := resolveGroupBoxReference()
135 if err != nil {
136 return "", fmt.Errorf("this box's AWS identity could not be read, so it cannot join yet: %w", err)
137 }
138 return ref, nil
139 }
140 return metricsBoxLabel(getenv, machineID), nil
141}
142
143func (j rootJoin) run(ctx context.Context) (joinOutcome, error) {
144 if j.box == "" {
145 return "", errors.New("this box has no name to join under")
146 }
147 if j.armed() {
148 return joinArmedAlready, nil
149 }
150 wire, _, err := j.store.Get(ctx, invitationKey(j.box))
151 if errors.Is(err, os.ErrNotExist) {
152 return joinNotInvited, nil
153 }
154 if err != nil {
155 return "", fmt.Errorf("the invitation for %s could not be read: %w", j.box, err)
156 }
157 var inv hostactors.Invitation
158 if release.VerifyRootEnvelope(j.pub, wire, &inv) != nil || inv.V != 1 || inv.Bucket != j.bucket || inv.Reference != j.box || inv.Enrollment == "" {
159 return "", fmt.Errorf("the invitation for %s does not verify, so the guard was not armed", j.box)
160 }
161 raw, err := boxid.Load(ctx, j.store, j.box)
162 if err != nil {
163 return "", fmt.Errorf("the identity record for %s could not be read: %w", j.box, err)
164 }
165 var rec boxid.Record
166 if hostfloor.Digest(raw) != inv.EvidenceSHA256 || json.Unmarshal(raw, &rec) != nil {
167 return "", fmt.Errorf("the invitation for %s is for another identity record, so the guard was not armed", j.box)
168 }
169 instance, err := recordInstanceID(rec)
170 if err != nil || instance == "" || instance != j.instance() {
171 return "", fmt.Errorf("the identity record for %s is not this instance's, so the guard was not armed", j.box)
172 }
173 // guard-arm refuses, naming the file, when install-channel has not
174 // kept the release identity.
175 identity := channelIdentityPath(j.optDir)
176 floorRaw, _, err := j.store.Get(ctx, hostfloor.Key)
177 if err != nil {
178 return "", fmt.Errorf("the host floor could not be read: %w", err)
179 }
180 auth, _, err := j.store.Get(ctx, hostfloor.AuthKey)
181 if err != nil {
182 return "", fmt.Errorf("the host floor could not be read: %w", err)
183 }
184 if _, err := hostfloor.Verify(j.pub, j.bucket, floorRaw, auth); err != nil {
185 return "", fmt.Errorf("the host floor does not verify, so the guard was not armed: %w", err)
186 }
187 policy, err := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: j.bucket, Mode: "armed", Floor: floorRaw, Authorization: auth})
188 if err != nil {
189 return "", err
190 }
191 enrollment, err := json.Marshal(hostfloor.Enrollment{Box: j.box, Enrollment: inv.Enrollment, InstanceID: instance})
192 if err != nil {
193 return "", err
194 }
195 dir := joinDir(j.optDir)
196 if err := os.MkdirAll(dir, 0o700); err != nil {
197 return "", err
198 }
199 policyPath, enrollmentPath := filepath.Join(dir, "policy.json"), filepath.Join(dir, "enrollment.json")
200 if err := hostfloor.Durable(policyPath, policy, 0o600); err != nil {
201 return "", err
202 }
203 if err := hostfloor.Durable(enrollmentPath, enrollment, 0o600); err != nil {
204 return "", err
205 }
206 if err := j.arm(policyPath, identity, enrollmentPath); err != nil {
207 return "", err
208 }
209 return joinArmed, nil
210}
211