package host import ( "bytes" "context" "database/sql" "encoding/json" "errors" "io" "net" "net/http" "time" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" ) func rootJournalClient(socket string) hostbox.JournalCall { return func(ctx context.Context, q hostbox.JournalRequest) (hostbox.JournalResponse, error) { var out hostbox.JournalResponse body, err := json.Marshal(q) if err != nil { return out, err } transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", socket) }} defer transport.CloseIdleConnections() client := &http.Client{Transport: transport, Timeout: 30 * time.Second} // Retries carry the same ID and byte-identical body. No request regenerates // its identity after an uncertain response. uncertain := false for attempt := 0; attempt < 2; attempt++ { req, e := http.NewRequestWithContext(ctx, http.MethodPost, "http://instance-control/claim/journal", bytes.NewReader(body)) if e != nil { return out, e } response, e := client.Do(req) if e != nil { err = e uncertain = true continue } if response.StatusCode != http.StatusOK { response.Body.Close() switch response.StatusCode { case http.StatusBadRequest, http.StatusForbidden, http.StatusConflict: // After a lost reply the first attempt may have committed; a // later refusal (Authenticate, authority or claim checks run // before replay) says nothing about it. if uncertain { return out, errors.Join(err, hostbox.ErrJournalUncertain) } return hostbox.JournalResponse{V: 1, ID: q.ID, Error: "refused"}, nil default: // A 5xx or an unrecognized response says nothing about commit outcome. err = hostbox.ErrJournalUncertain uncertain = true continue } } decoder := json.NewDecoder(io.LimitReader(response.Body, 4<<20)) decoder.DisallowUnknownFields() err = decoder.Decode(&out) if err == nil && decoder.Decode(new(any)) != io.EOF { err = hostbox.ErrJournalUncertain } response.Body.Close() if err != nil { uncertain = true continue } if uncertain && out.Error != "" { return out, hostbox.ErrJournalUncertain } return out, nil } return out, err } } func (h ClaimRootHTTP) serveJournal(w http.ResponseWriter, r *http.Request, peer StopPeer) { var q hostbox.JournalRequest decoder := json.NewDecoder(io.LimitReader(r.Body, 4<<20)) decoder.DisallowUnknownFields() if decoder.Decode(&q) != nil || decoder.Decode(new(any)) != io.EOF || h.Journals == nil { w.WriteHeader(http.StatusBadRequest) return } if hostbox.ServingParkOperation(q.Op) || q.ServingPark != nil || q.V == 4 { h.serveServingPark(w, r, q) return } if q.Op == "fence-view" { h.serveFenceView(w, r, q) return } if q.Op == "inventory-write" && h.Stop.Coordinator.Guard.Root != nil { out, err := h.commitRootInventory(r.Context(), q) if err != nil { w.WriteHeader(http.StatusConflict) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(out) return } owner := h.Reconcile.Box + "/" + h.Reconcile.Enrollment if out, found, err := h.Journals.Replay(r.Context(), owner, q); err != nil { // Only a proven mismatch (other owner or bytes under a stored ID) is a // conflict, and an invalid request is a plain bad request. A failed // receipt lookup or an unreadable receipt says nothing about whether the // request committed, so it stays retryable. switch { case errors.Is(err, hostbox.ErrChanged): w.WriteHeader(http.StatusConflict) case errors.Is(err, hostbox.ErrTypedJournal): w.WriteHeader(http.StatusBadRequest) default: w.WriteHeader(http.StatusServiceUnavailable) } return } else if found { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(out) return } if h.RequireNewWork != nil && !hostbox.JournalReadOrSettle(q.Op) { if err := h.RequireNewWork(r.Context()); err != nil { w.WriteHeader(http.StatusForbidden) return } } target := q.Target switch q.Op { case "create", "prelaunch": target = q.Launch.Target case "intend", "reserve", "started", "settled": target = q.Reservation.Target case "inventory-write": target.Host = q.Inventory.Host target.Incarnation = q.Inventory.Incarnation target.Stamp = q.Inventory.InstallationStamp target.Box = q.Inventory.Box target.Enrollment = q.Inventory.Enrollment } read := q.Op == "read" || q.Op == "session" || q.Op == "has-host" || q.Op == "has-effects" || q.Op == "inventory-read" || q.Op == "inventory-exact" || q.Op == "inventory-version" if target.Host == "" { w.WriteHeader(http.StatusForbidden) return } if !read { if target.Box != h.Reconcile.Box || target.Enrollment != h.Reconcile.Enrollment { w.WriteHeader(http.StatusForbidden) return } if q.Op != "inventory-write" && (target.BootID != peer.BootID || target.ControllerID != peer.ControllerID) { w.WriteHeader(http.StatusForbidden) return } if (q.Op == "create" || q.Op == "prelaunch") && (q.Launch.ControllerPID != peer.PID || q.Launch.ProcessStart != peer.ProcessStart) { w.WriteHeader(http.StatusForbidden) return } // Completion can drain already issued work after admission or the claim // changes. It still needs this live controller and an exact stored item; // none of these operations creates a reservation or producer. settlement := q.Op == "close-writers" || q.Op == "complete" || q.Op == "settled" || q.Op == "started" if !settlement { current, err := hostclaim.Read(r.Context(), h.Stop.Coordinator.Evidence, target.Host) if err != nil { // An unreadable claim proves nothing about its state. w.WriteHeader(http.StatusServiceUnavailable) return } if current.Claim == nil || current.Claim.Holder == nil { w.WriteHeader(http.StatusConflict) return } holder := current.Claim.Holder if (q.Op != "inventory-write" && current.Claim.Op.ID != target.Op) || holder.Incarnation != target.Incarnation || holder.Stamp != target.Stamp || (q.Op != "inventory-write" && holder.WriterBox != target.Box) || (q.Op != "inventory-write" && holder.Session != target.Session) { w.WriteHeader(http.StatusConflict) return } } } if err := validateClaimJournalContainment(r.Context(), q, h.Journals.Launches); err != nil { w.WriteHeader(http.StatusForbidden) return } if q.Op == "inventory-write" { if h.Reconcile.Registry == nil { w.WriteHeader(http.StatusForbidden) return } row, err := h.Reconcile.Registry.Get(q.Inventory.Host) if err != nil { w.WriteHeader(http.StatusServiceUnavailable) return } if hostbox.ValidateAgentInventory(q.Inventory, row, h.Roots["data"]) != nil { w.WriteHeader(http.StatusForbidden) return } } var out hostbox.JournalResponse var err error execute := func() error { var e error; out, e = h.Journals.Execute(r.Context(), owner, q); return e } if q.Op == "close-writers" { err = h.Stop.Coordinator.Guard.WithLocal(r.Context(), target.Host, execute) } else { err = execute() } if err != nil { switch { case errors.Is(err, sql.ErrNoRows): out.Error = "missing" case errors.Is(err, hostbox.ErrChanged): out.Error = "changed" case errors.Is(err, hostbox.ErrUnsupported): out.Error = "unsupported" case errors.Is(err, hostbox.ErrTypedInventory), errors.Is(err, hostbox.ErrTypedJournal): out.Error = "refused" default: out.Error = "uncertain" } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(out) }