package activator // The instance key's adoption record (instance-key plan rev 3, rulings 19 // to 22). A key in .env or .config.env proves only that one // was delivered: an app with a key of its own keeps it (rastrillo's step // 1), and its data is sealed under that key. So relocation accepts a // delivered key only when the app's helper, having resolved // $CARLOS_INSTANCE_KEY (step 2), told the agent so, and the agent recorded // it for the writer the process was admitted as. The record is a // trusted-helper assertion: it stops accidental loss, and a tenant that // forges one can lose only its own data (ruling 19). // // The verb is mounted only where a launch has a writer session: claim // launches, once they bind a control socket (obligation O6). A legacy // launch has no session to bind a record to, so nothing mounts it there and // the helper's call is a 404 it ignores. import ( "crypto/sha256" "crypto/subtle" "encoding/json" "errors" "io" "net/http" "os" "path/filepath" "github.com/carlosframework/platform/internal/instancefs" "github.com/carlosframework/platform/internal/instancekey" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/safefile" ) // instanceKeyAdoptionPath is the verb's path on the control socket. const instanceKeyAdoptionPath = "/instance-key/adoption" // AdoptionLaunch is one process's launch state as the agent holds it: the // writer it was admitted as, the key its env delivered (by digest) and the // file that supplied it, where its .env is, and the session's files // publisher. O6 builds one per claim launch from the admission (holder // incarnation, session, generation) and ClaimLaunchSnapshot's // InstanceKeySHA256 and InstanceKeySource. Nothing in it comes from a // request. type AdoptionLaunch struct { Writer instancekey.Binding KeySHA256 string Source string // instancekey.SourcePlatform or SourceConfig Dir string // the directory that holds .env Beneath bool // Dir is tenant-writable: write through instancefs // Publish hands the record to the session's files publisher as the // platform item instancekey.AdoptionItem (hostfiles.PlatformRelay). It // must not block: publication follows the publisher's continuous bound. Publish func(record []byte) } // MountInstanceKeyAdoption adds PUT /instance-key/adoption to an // instance's control socket. host is the listener's, never the request's, // and the bearer is the host's own CARLOS_ADMIN_TOKEN, as for the schedule // verbs. launch is the calling process's launch state; false (a launch with // no writer session) answers 404, as an agent without the verb does. // // The body is {"sha256": "", "source": "platform"|"config"}: the // SHA-256 of the key the helper resolved at step 2 and where the helper // says it came from. The answer: // - 204 once the record for the launch's writer is on disk, 0600, file // and directory fsynced, and handed to the session's publisher; // - 409 when the digest or the source is not the launch's (the app holds // some other key), or for a replica row (ruling 26); nothing recorded; // - 404 with no launch state, 400 for a malformed body (a session or // generation in it included), 401 without the bearer, 500 when the // durable write fails, or when the launch state is not a complete // writer binding for this host with a publisher (O6's programming // error); nothing written or published either way. // // Bodies are http.StatusText only: the helper treats every failure as // non-fatal, and the status is all it reads. func (b *ExecBackend) MountInstanceKeyAdoption(mux *http.ServeMux, rt registry.Route, token func() (string, error), launch func() (AdoptionLaunch, bool)) { fail := func(w http.ResponseWriter, code int) { http.Error(w, http.StatusText(code), code) } mux.HandleFunc("PUT "+instanceKeyAdoptionPath, func(w http.ResponseWriter, r *http.Request) { want, err := token() if err != nil || want == "" { fail(w, http.StatusUnauthorized) return } got := sha256.Sum256([]byte(r.Header.Get("Authorization"))) exp := sha256.Sum256([]byte("Bearer " + want)) if subtle.ConstantTimeCompare(got[:], exp[:]) != 1 { fail(w, http.StatusUnauthorized) return } var body struct { SHA256 string `json:"sha256"` Source string `json:"source"` } dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)) dec.DisallowUnknownFields() // One object and nothing after it: the second Decode must find // the body's end. Decoder.More is not that test: it is false // before a trailing } or ] as well, and hides a read error, so a // record followed by either was a 204. if err := dec.Decode(&body); err != nil || dec.Decode(new(json.RawMessage)) != io.EOF || !instancekey.ValidDigest(body.SHA256) || (body.Source != instancekey.SourcePlatform && body.Source != instancekey.SourceConfig) { fail(w, http.StatusBadRequest) return } l, ok := launch() switch { case !ok: fail(w, http.StatusNotFound) return case rt.Replica: fail(w, http.StatusConflict) return case l.Writer.Host != rt.Host || l.Publish == nil: fail(w, http.StatusInternalServerError) return case body.SHA256 != l.KeySHA256 || body.Source != l.Source: fail(w, http.StatusConflict) return } record := instancekey.AdoptionRecord(l.Writer, l.Source, l.KeySHA256) // The record must be one the pinner can read back: a launch state // with an incomplete writer (a zero session, say) is O6's // programming error, answered as the other guard is, not a 204 // for a record nobody will ever accept. if _, err := instancekey.ParseAdoption(record); err != nil { fail(w, http.StatusInternalServerError) return } if err := writeAdoptionRecord(l, rt.Host, record); err != nil { fail(w, http.StatusInternalServerError) return } l.Publish(record) w.WriteHeader(http.StatusNoContent) }) } // adoptionRecordSync is the nested record's directory sync, a seam for // tests. Read and replaced only under adminTokenMu. var adoptionRecordSync = syncDir // writeAdoptionRecord writes record beside .env: atomically, 0600, // the file and its directory fsynced before it returns. An identical // record already there is made durable and not rewritten. It holds // adminTokenMu, the lock every write beside .env takes. func writeAdoptionRecord(l AdoptionLaunch, host string, record []byte) error { name := host + instancekey.AdoptionSuffix adminTokenMu.Lock() defer adminTokenMu.Unlock() var cur []byte var err error if l.Beneath { cur, err = instancefs.ReadBeneathLimit(l.Dir, name, instancekey.AdoptionReadLimit) } else { cur, err = os.ReadFile(filepath.Join(l.Dir, name)) } switch { case err == nil && string(cur) == string(record): return adoptionRecordSync(l.Dir) case err != nil && !errors.Is(err, os.ErrNotExist): return err } if l.Beneath { // WriteAtomic fsyncs the file before its rename; the directory // sync makes the rename durable. if err := instancefs.WriteAtomic(l.Dir, name, record, 0o600); err != nil { return err } return adoptionRecordSync(l.Dir) } // safefile.WriteFile fsyncs the file, renames it into place and fsyncs // the directory. return safefile.WriteFile(filepath.Join(l.Dir, name), record, 0o600) }