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 "encoding/base64"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "net/http"
12 "os"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/carlosframework/platform/internal/boxid"
18 "github.com/carlosframework/platform/internal/release"
19)
20
21// regionHAEnabled is CARLOS_REGION_HA: on only for a value
22// strconv.ParseBool reads as true ("1", "t", "true" in any case); "",
23// "0", "false", "off", "no" and anything unparseable are off. Every region
24// HA behaviour on a box is behind it (spec 1 §8 step 2).
25func regionHAEnabled(getenv func(string) string) bool {
26 on, err := strconv.ParseBool(strings.TrimSpace(getenv("CARLOS_REGION_HA")))
27 return err == nil && on
28}
29
30const (
31 imdsBase = "http://169.254.169.254"
32 imdsIdentityDocument = "/latest/dynamic/instance-identity/document"
33 imdsIdentitySignature = "/latest/dynamic/instance-identity/signature"
34 boxRecordRetry = time.Minute
35)
36
37// imdsGetRaw reads one IMDSv2 path and returns its body untouched. The
38// identity document is signed byte for byte, so unlike imdsLeaf nothing is
39// trimmed.
40func imdsGetRaw(client *http.Client, base, token, path string) ([]byte, error) {
41 req, err := http.NewRequest(http.MethodGet, base+path, nil)
42 if err != nil {
43 return nil, err
44 }
45 req.Header.Set("X-aws-ec2-metadata-token", token)
46 resp, err := client.Do(req)
47 if err != nil {
48 return nil, err
49 }
50 defer resp.Body.Close()
51 body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
52 if err != nil {
53 return nil, err
54 }
55 if resp.StatusCode != http.StatusOK {
56 return nil, fmt.Errorf("IMDS %s answered %d", path, resp.StatusCode)
57 }
58 return body, nil
59}
60
61// readIdentityRecord builds this box's record (spec §5.1) from IMDS: the
62// identity document exactly as served, and its base64 RSA-SHA256
63// signature with any line breaks removed (base64 ignores them; Go's
64// decoder does not).
65func readIdentityRecord(client *http.Client, base, box string, now time.Time) (boxid.Record, error) {
66 req, err := http.NewRequest(http.MethodPut, base+"/latest/api/token", nil)
67 if err != nil {
68 return boxid.Record{}, err
69 }
70 req.Header.Set("X-aws-ec2-metadata-token-ttl-seconds", "60")
71 resp, err := client.Do(req)
72 if err != nil {
73 return boxid.Record{}, fmt.Errorf("IMDS token: %w", err)
74 }
75 tok, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
76 resp.Body.Close()
77 if resp.StatusCode != http.StatusOK || len(strings.TrimSpace(string(tok))) == 0 {
78 return boxid.Record{}, fmt.Errorf("IMDS token answered %d", resp.StatusCode)
79 }
80 token := strings.TrimSpace(string(tok))
81 doc, err := imdsGetRaw(client, base, token, imdsIdentityDocument)
82 if err != nil {
83 return boxid.Record{}, err
84 }
85 sig, err := imdsGetRaw(client, base, token, imdsIdentitySignature)
86 if err != nil {
87 return boxid.Record{}, err
88 }
89 cleaned := strings.Join(strings.Fields(string(sig)), "")
90 if _, err := base64.StdEncoding.DecodeString(cleaned); err != nil || cleaned == "" {
91 return boxid.Record{}, errors.New("IMDS signature is not base64")
92 }
93 return boxid.Record{V: 1, Box: box, IdentityDocument: base64.StdEncoding.EncodeToString(doc),
94 IdentitySignature: cleaned, WrittenAt: now.UTC().Format(time.RFC3339)}, nil
95}
96
97// recordInstanceID is the instanceId in a record's identity document. It
98// does not check the signature: it only tells whether a record names this
99// box's instance.
100func recordInstanceID(rec boxid.Record) (string, error) {
101 doc, err := base64.StdEncoding.DecodeString(rec.IdentityDocument)
102 if err != nil {
103 return "", errors.New("identity document is not base64")
104 }
105 var d struct {
106 InstanceID string `json:"instanceId"`
107 }
108 if err := json.Unmarshal(doc, &d); err != nil || d.InstanceID == "" {
109 return "", errors.New("identity document names no instance")
110 }
111 return d.InstanceID, nil
112}
113
114// publishBoxRecord makes one attempt to publish this box's enrollment
115// evidence. done is true once there is nothing left to retry: the record
116// is in place, or a different one already holds this reference, which
117// only an operator can sort out and which is logged loudly. A record
118// already under this reference is never rewritten, but it is checked
119// against this box's own identity: one signed for another instance, or
120// one that cannot be read, is reported the same loud way.
121func publishBoxRecord(ctx context.Context, s release.Store, box string, read func() (boxid.Record, error)) (done bool, err error) {
122 if !boxid.ValidBox(box) {
123 return true, fmt.Errorf("box reference %q is not a label; no enrollment evidence is published", box)
124 }
125 existing, err := boxid.Load(ctx, s, box)
126 if err != nil && !errors.Is(err, os.ErrNotExist) {
127 return false, err
128 }
129 rec, rerr := read()
130 if rerr != nil {
131 return false, rerr
132 }
133 if err == nil {
134 var held boxid.Record
135 if jerr := json.Unmarshal(existing, &held); jerr != nil {
136 return true, fmt.Errorf("the record already under %s cannot be read: %v", box, jerr)
137 }
138 theirs, ierr := recordInstanceID(held)
139 if ierr != nil {
140 return true, fmt.Errorf("the record already under %s: %v", box, ierr)
141 }
142 mine, ierr := recordInstanceID(rec)
143 if ierr != nil {
144 return false, fmt.Errorf("this box's own identity: %v", ierr)
145 }
146 if theirs != mine {
147 return true, fmt.Errorf("the record already under %s is signed for instance %s, this box is %s", box, theirs, mine)
148 }
149 return true, nil // create-only: an existing record is never rewritten
150 }
151 err = boxid.Publish(ctx, s, rec)
152 if errors.Is(err, release.ErrCASConflict) {
153 return true, fmt.Errorf("another record already holds %s: %w", box, err)
154 }
155 return err == nil, err
156}
157
158// boxRecordLoop publishes this box's enrollment evidence once, at start,
159// when CARLOS_REGION_HA is on, retrying each minute until it is in place.
160// The record grants nothing: enrollment (an operator, or for Auto Scaling
161// boxes the console with a membership confirmation) reads it later.
162// Platform-box job: skipped on fleet boxes.
163func boxRecordLoop() {
164 if !regionHAEnabled(os.Getenv) || shouldSkipPublisherElection(os.Getenv) {
165 return
166 }
167 ctx := context.Background()
168 store, err := openDeployStore(ctx)
169 if err != nil {
170 log.Printf("agent: box record not published: %v", err)
171 return
172 }
173 client := &http.Client{Timeout: 2 * time.Second}
174 for attempt := 1; ; attempt++ {
175 // A group box's reference comes from its identity document, which
176 // IMDS may not answer yet: that is a retry, never a refusal
177 // (boxreference.go).
178 box := metricsBoxLabel(os.Getenv, readMachineID())
179 read := func() (boxid.Record, error) { return readIdentityRecord(client, imdsBase, box, time.Now()) }
180 done, err := false, errors.New("this box's group reference could not be read")
181 if box != "" {
182 done, err = publishBoxRecord(ctx, store, box, read)
183 }
184 name := box
185 if name == "" {
186 name = "this group box"
187 }
188 if done {
189 if err != nil {
190 log.Printf("agent: box record for %s: %v (region HA cannot enroll this box until an operator resolves it)", name, err)
191 }
192 return
193 }
194 if attempt == 1 || attempt%60 == 0 {
195 log.Printf("agent: box record for %s not published yet (#%d): %v", name, attempt, err)
196 }
197 time.Sleep(boxRecordRetry)
198 }
199}
200