package schedule import ( "crypto/sha256" "crypto/subtle" "encoding/json" "errors" "net/http" "os" "time" "github.com/carlosframework/platform/internal/registry" ) // TimerStore is narrowed to the five methods Mount actually calls, not the // whole *registry.Registry: *registry.Registry satisfies it directly // (internal/host/agent.go), and internal/activator — the other side of the // package boundary this crosses (Backend.Timers) — stubs it with an // in-memory fake so its wiring tests never touch a real database. type TimerStore interface { UpsertTimer(registry.Timer) error DeleteTimer(host, name string) error GetTimer(host, name string) (*registry.Timer, error) TimersFor(host string) ([]registry.Timer, error) CountTimers(host, kind string) (int, error) } // RetiredWriteMessage is the 409 body a retiring generation's schedule // writes get once the switch has happened (zero-downtime spec §7.10). App // developers read it in their logs; it is copy-reviewed. const RetiredWriteMessage = "this process has been replaced; the new process owns the schedule" // WriteFence serializes one generation's schedule writes against the // switch that retires it (zero-downtime spec §7.10, final review F8). It // runs write, the final retirement decision and the mutation together, // unless the generation has been retired, and reports whether it ran. // While write runs, the switch that would retire this generation waits; // once it has retired it, every later call answers false, including one // for a request that began before the switch. A request's body is parsed // and validated before the fence is asked, so a slow client never holds // the switch. type WriteFence func(write func()) bool // Mount is MountGated with no gate. func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (string, error), now func() time.Time) { MountGated(mux, host, store, token, now, nil) } // MountGated adds the once-timer verbs to an instance's control socket (spec // §7.2). host is fixed here and never read from a request — the // confused-deputy guarantee edge.ListenControl gives — and every verb needs // the host's own CARLOS_ADMIN_TOKEN as a bearer. Be precise about what that // bearer buys (internal/edge/purge.go has the long form): it closes the // channel against OTHER UNIX users on the box and against any // unauthenticated dialer — reaching the socket is no longer enough. It does // NOT close the same-uid sibling gap: every exec child shares the agent's // uid, the token file is 0600 owner-only, so a sibling can read another // host's token and present it here. That residual is bounded — a sibling // with host B's token could already dial B's own socket with it; these // verbs add reach (a platform wake plus a POST at a chosen time and path), // not privilege — and per-instance uids are the real fix. // // fence gates the two writing verbs: every store access a PUT or DELETE // makes runs inside it, and once it refuses, the verb answers 409 and // changes nothing. A generation the activator has switched away from must // not rewrite timers the successor now owns, which is what its // reassert-at-shutdown would do (zero-downtime spec §7.10). Reads stay // open, the bearer is checked first, and the request is validated before // the fence is asked. A nil fence is Mount exactly. func MountGated(mux *http.ServeMux, host string, store TimerStore, token func() (string, error), now func() time.Time, fence WriteFence) { // fenced runs write inside the fence, and reports false (the caller // answers 409) when the generation is retired. fenced := func(write func()) bool { if fence == nil { write() return true } return fence(write) } authed := func(fn http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { want, err := token() if err != nil || want == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } got := sha256.Sum256([]byte(r.Header.Get("Authorization"))) exp := sha256.Sum256([]byte("Bearer " + want)) if subtle.ConstantTimeCompare(got[:], exp[:]) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return } fn(w, r) } } mux.HandleFunc("GET /schedule-capabilities", authed(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(struct { RegistrationFence bool `json:"registration_fence"` }{RegistrationFence: true}) })) mux.HandleFunc("PUT /schedule/{name}", authed(func(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if !ValidName(name) { http.Error(w, "name: want [a-z0-9][a-z0-9-]{0,31}", http.StatusBadRequest) return } var body struct { At string `json:"at"` Path string `json:"path"` } dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)) if err := dec.Decode(&body); err != nil { http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) return } // Decode stops at the first JSON value and silently ignores // whatever follows it; a client that appended a second object (or // stray bytes) meant something by that, so refuse rather than act // on the first value alone. if dec.More() { http.Error(w, "bad request: trailing data after the JSON body", http.StatusBadRequest) return } atT, err := time.Parse(time.RFC3339, body.At) if err != nil { http.Error(w, "at: want RFC3339, e.g. 2026-09-01T08:00:00Z", http.StatusBadRequest) return } if atT.After(now().Add(MaxOnceAhead)) { http.Error(w, "at: more than 400 days ahead", http.StatusBadRequest) return } if err := ValidPath(body.Path); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // The store's checks and the write run inside the fence, so the // retirement decision and the mutation are one step against the // switch. The response is written after it. var ( fail string code int row *registry.Timer ) ran := fenced(func() { existing, err := store.GetTimer(host, name) switch { case err == nil && existing.Kind != KindOnce: fail, code = "name is a declared schedule", http.StatusConflict return case err != nil && !errors.Is(err, os.ErrNotExist): fail, code = "registry error", http.StatusInternalServerError return case err != nil: n, cerr := store.CountTimers(host, KindOnce) if cerr != nil { fail, code = "registry error", http.StatusInternalServerError return } if n >= MaxOncePerHost { fail, code = "too many one-shot timers (limit 1000) — delete finished ones or use a schedule", http.StatusTooManyRequests return } } sp := Spec{Kind: KindOnce, At: atT.UTC()} next := atT.Unix() if next <= 0 { next = max(int64(1), now().Unix()) } t := registry.Timer{Host: host, Name: name, Kind: KindOnce, Spec: sp.String(), Path: body.Path, NextAt: next, DueAt: atT.Unix(), CreatedAt: now().Unix()} if err := store.UpsertTimer(t); err != nil { fail, code = "registry error", http.StatusInternalServerError return } if row, err = store.GetTimer(host, name); err != nil { fail, code = "registry error", http.StatusInternalServerError } }) switch { case !ran: http.Error(w, RetiredWriteMessage, http.StatusConflict) case fail != "": http.Error(w, fail, code) default: writeTimer(w, *row, now()) } })) mux.HandleFunc("DELETE /schedule/{name}", authed(func(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") if !ValidName(name) { http.Error(w, "bad name", http.StatusBadRequest) return } var ( fail string code int ) ran := fenced(func() { switch row, err := store.GetTimer(host, name); { case err == nil && row.Kind != KindOnce: fail, code = "name is a declared schedule — remove it with carlos schedule rm", http.StatusConflict return case err != nil && !errors.Is(err, os.ErrNotExist): fail, code = "registry error", http.StatusInternalServerError return } if err := store.DeleteTimer(host, name); err != nil { fail, code = "registry error", http.StatusInternalServerError } }) switch { case !ran: http.Error(w, RetiredWriteMessage, http.StatusConflict) case fail != "": http.Error(w, fail, code) default: w.WriteHeader(http.StatusNoContent) } })) mux.HandleFunc("GET /schedule", authed(func(w http.ResponseWriter, r *http.Request) { rows, err := store.TimersFor(host) if err != nil { http.Error(w, "registry error", http.StatusInternalServerError) return } out := struct { Timers []TimerStatus `json:"timers"` }{Timers: []TimerStatus{}} for _, row := range rows { // The control socket has no view of the activator's idle window // (it is reached from inside the exec child, not the box's // hibernation wiring), so it makes no holds_awake claim — same // as any box that predates the field. out.Timers = append(out.Timers, toStatus(row, 0, now())) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(out) })) } // writeTimer writes one registry row as its wire TimerStatus (toStatus is // converge.go's — one row-to-status mapping for the whole package). idle is // 0 here too, for the reason the /schedule GET handler above documents: the // control socket cannot see the activator's window. func writeTimer(w http.ResponseWriter, r registry.Timer, now time.Time) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(toStatus(r, 0, now)) }