| 1 | package 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 | |
| 18 | import ( |
| 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. |
| 35 | const 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. |
| 44 | type 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. |
| 77 | func (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. |
| 141 | var 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. |
| 147 | func 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 | |