carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package activator
2
3// The instance key's adoption record (instance-key plan rev 3, rulings 19
4// to 22). A key in <host>.env or <host>.config.env proves only that one
5// was delivered: an app with a key of its own keeps it (rastrillo's step
6// 1), and its data is sealed under that key. So relocation accepts a
7// delivered key only when the app's helper, having resolved
8// $CARLOS_INSTANCE_KEY (step 2), told the agent so, and the agent recorded
9// it for the writer the process was admitted as. The record is a
10// trusted-helper assertion: it stops accidental loss, and a tenant that
11// forges one can lose only its own data (ruling 19).
12//
13// The verb is mounted only where a launch has a writer session: claim
14// launches, once they bind a control socket (obligation O6). A legacy
15// launch has no session to bind a record to, so nothing mounts it there and
16// the helper's call is a 404 it ignores.
17
18import (
19 "crypto/sha256"
20 "crypto/subtle"
21 "encoding/json"
22 "errors"
23 "io"
24 "net/http"
25 "os"
26 "path/filepath"
27
28 "github.com/carlosframework/platform/internal/instancefs"
29 "github.com/carlosframework/platform/internal/instancekey"
30 "github.com/carlosframework/platform/internal/registry"
31 "github.com/carlosframework/platform/internal/safefile"
32)
33
34// instanceKeyAdoptionPath is the verb's path on the control socket.
35const instanceKeyAdoptionPath = "/instance-key/adoption"
36
37// AdoptionLaunch is one process's launch state as the agent holds it: the
38// writer it was admitted as, the key its env delivered (by digest) and the
39// file that supplied it, where its <host>.env is, and the session's files
40// publisher. O6 builds one per claim launch from the admission (holder
41// incarnation, session, generation) and ClaimLaunchSnapshot's
42// InstanceKeySHA256 and InstanceKeySource. Nothing in it comes from a
43// request.
44type AdoptionLaunch struct {
45 Writer instancekey.Binding
46 KeySHA256 string
47 Source string // instancekey.SourcePlatform or SourceConfig
48 Dir string // the directory that holds <host>.env
49 Beneath bool // Dir is tenant-writable: write through instancefs
50 // Publish hands the record to the session's files publisher as the
51 // platform item instancekey.AdoptionItem (hostfiles.PlatformRelay). It
52 // must not block: publication follows the publisher's continuous bound.
53 Publish func(record []byte)
54}
55
56// MountInstanceKeyAdoption adds PUT /instance-key/adoption to an
57// instance's control socket. host is the listener's, never the request's,
58// and the bearer is the host's own CARLOS_ADMIN_TOKEN, as for the schedule
59// verbs. launch is the calling process's launch state; false (a launch with
60// no writer session) answers 404, as an agent without the verb does.
61//
62// The body is {"sha256": "<hex>", "source": "platform"|"config"}: the
63// SHA-256 of the key the helper resolved at step 2 and where the helper
64// says it came from. The answer:
65// - 204 once the record for the launch's writer is on disk, 0600, file
66// and directory fsynced, and handed to the session's publisher;
67// - 409 when the digest or the source is not the launch's (the app holds
68// some other key), or for a replica row (ruling 26); nothing recorded;
69// - 404 with no launch state, 400 for a malformed body (a session or
70// generation in it included), 401 without the bearer, 500 when the
71// durable write fails, or when the launch state is not a complete
72// writer binding for this host with a publisher (O6's programming
73// error); nothing written or published either way.
74//
75// Bodies are http.StatusText only: the helper treats every failure as
76// non-fatal, and the status is all it reads.
77func (b *ExecBackend) MountInstanceKeyAdoption(mux *http.ServeMux, rt registry.Route, token func() (string, error), launch func() (AdoptionLaunch, bool)) {
78 fail := func(w http.ResponseWriter, code int) { http.Error(w, http.StatusText(code), code) }
79 mux.HandleFunc("PUT "+instanceKeyAdoptionPath, func(w http.ResponseWriter, r *http.Request) {
80 want, err := token()
81 if err != nil || want == "" {
82 fail(w, http.StatusUnauthorized)
83 return
84 }
85 got := sha256.Sum256([]byte(r.Header.Get("Authorization")))
86 exp := sha256.Sum256([]byte("Bearer " + want))
87 if subtle.ConstantTimeCompare(got[:], exp[:]) != 1 {
88 fail(w, http.StatusUnauthorized)
89 return
90 }
91 var body struct {
92 SHA256 string `json:"sha256"`
93 Source string `json:"source"`
94 }
95 dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024))
96 dec.DisallowUnknownFields()
97 // One object and nothing after it: the second Decode must find
98 // the body's end. Decoder.More is not that test: it is false
99 // before a trailing } or ] as well, and hides a read error, so a
100 // record followed by either was a 204.
101 if err := dec.Decode(&body); err != nil || dec.Decode(new(json.RawMessage)) != io.EOF || !instancekey.ValidDigest(body.SHA256) ||
102 (body.Source != instancekey.SourcePlatform && body.Source != instancekey.SourceConfig) {
103 fail(w, http.StatusBadRequest)
104 return
105 }
106 l, ok := launch()
107 switch {
108 case !ok:
109 fail(w, http.StatusNotFound)
110 return
111 case rt.Replica:
112 fail(w, http.StatusConflict)
113 return
114 case l.Writer.Host != rt.Host || l.Publish == nil:
115 fail(w, http.StatusInternalServerError)
116 return
117 case body.SHA256 != l.KeySHA256 || body.Source != l.Source:
118 fail(w, http.StatusConflict)
119 return
120 }
121 record := instancekey.AdoptionRecord(l.Writer, l.Source, l.KeySHA256)
122 // The record must be one the pinner can read back: a launch state
123 // with an incomplete writer (a zero session, say) is O6's
124 // programming error, answered as the other guard is, not a 204
125 // for a record nobody will ever accept.
126 if _, err := instancekey.ParseAdoption(record); err != nil {
127 fail(w, http.StatusInternalServerError)
128 return
129 }
130 if err := writeAdoptionRecord(l, rt.Host, record); err != nil {
131 fail(w, http.StatusInternalServerError)
132 return
133 }
134 l.Publish(record)
135 w.WriteHeader(http.StatusNoContent)
136 })
137}
138
139// adoptionRecordSync is the nested record's directory sync, a seam for
140// tests. Read and replaced only under adminTokenMu.
141var adoptionRecordSync = syncDir
142
143// writeAdoptionRecord writes record beside <host>.env: atomically, 0600,
144// the file and its directory fsynced before it returns. An identical
145// record already there is made durable and not rewritten. It holds
146// adminTokenMu, the lock every write beside <host>.env takes.
147func writeAdoptionRecord(l AdoptionLaunch, host string, record []byte) error {
148 name := host + instancekey.AdoptionSuffix
149 adminTokenMu.Lock()
150 defer adminTokenMu.Unlock()
151 var cur []byte
152 var err error
153 if l.Beneath {
154 cur, err = instancefs.ReadBeneathLimit(l.Dir, name, instancekey.AdoptionReadLimit)
155 } else {
156 cur, err = os.ReadFile(filepath.Join(l.Dir, name))
157 }
158 switch {
159 case err == nil && string(cur) == string(record):
160 return adoptionRecordSync(l.Dir)
161 case err != nil && !errors.Is(err, os.ErrNotExist):
162 return err
163 }
164 if l.Beneath {
165 // WriteAtomic fsyncs the file before its rename; the directory
166 // sync makes the rename durable.
167 if err := instancefs.WriteAtomic(l.Dir, name, record, 0o600); err != nil {
168 return err
169 }
170 return adoptionRecordSync(l.Dir)
171 }
172 // safefile.WriteFile fsyncs the file, renames it into place and fsyncs
173 // the directory.
174 return safefile.WriteFile(filepath.Join(l.Dir, name), record, 0o600)
175}
176