package console import ( "encoding/json" "io" "net/http" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostfence" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/hostresolve" "github.com/carlosframework/platform/internal/release" ) const claimHostResolveUnsupportedMsg = "host-loss resolution is unavailable until its lifecycle authority is connected" const hostClaimUnavailable = "host claim authority is unavailable" func (s *Server) hostClaimOperator(w http.ResponseWriter, r *http.Request) (string, bool) { addr, ok := s.cliUser(w, r) if !ok { return "", false } yes, err := s.store.IsOperator(addr) if err != nil || !yes || !s.cfg.HostClaim { http.NotFound(w, r) return "", false } if s.cfg.Bucket == "" || len(s.cfg.Key) == 0 || s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, hostClaimUnavailable) return "", false } if _, err := hostfloor.Read(r.Context(), s.cfg.Releases, release.RootSigner(s.cfg.Key).RootPublicKey(), s.cfg.Bucket); err != nil { jsonError(w, http.StatusServiceUnavailable, hostClaimUnavailable) return "", false } return addr, true } func (s *Server) hostResolver() hostresolve.Service { signer := release.RootSigner(s.cfg.Key) return hostresolve.Service{Directory: hostactors.Directory{Store: s.cfg.Releases, PublicKey: signer.RootPublicKey(), Bucket: s.cfg.Bucket}, Signer: signer, Now: time.Now} } func (s *Server) handleAPIHostResolveInspect(w http.ResponseWriter, r *http.Request) { actor, ok := s.hostClaimOperator(w, r) if !ok { return } var in struct { Action string `json:"action"` Box string `json:"box"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&in) != nil { jsonError(w, 400, "invalid resolution request") return } if in.Action == "declare-lost" { if account, app, ok := s.claimMoveLossHolder(r, r.PathValue("host")); ok { inspected, err := s.cfg.ClaimAuthority.InspectMoveLoss(r.Context(), actor, account, app, r.PathValue("host"), in.Box) if err != nil { writeClaimResponseError(w, err) return } json.NewEncoder(w).Encode(inspected) return } } svc := s.hostResolver() if in.Action == "readmit" { ref, b, err := svc.Directory.Resolve(r.Context(), in.Box) if err != nil { writeClaimResponseError(w, err) return } json.NewEncoder(w).Encode(map[string]any{"box": ref, "enrollment": b.Enrollment, "revision": b.QuarantineRevision, "loss_ids": b.LossIDs}) return } if in.Action != "declare-lost" { jsonError(w, 400, "invalid resolution action") return } inspected, err := svc.Inspect(r.Context(), actor, r.PathValue("host"), in.Box) if err != nil { writeClaimResponseError(w, err) return } json.NewEncoder(w).Encode(inspected) } func (s *Server) handleAPIHostResolve(w http.ResponseWriter, r *http.Request) { actor, ok := s.hostClaimOperator(w, r) if !ok { return } var in struct { Action string `json:"action"` Submission hostfence.Submission `json:"submission"` Box string `json:"box"` Enrollment string `json:"enrollment"` Revision uint64 `json:"revision"` LossIDs []string `json:"loss_ids"` } var raw json.RawMessage decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) if decoder.Decode(&raw) != nil || json.Unmarshal(raw, &in) != nil { jsonError(w, 400, "invalid resolution request") return } if in.Action == "reset-wake-retry" { if decoder.Decode(new(any)) != io.EOF { w.WriteHeader(http.StatusBadRequest) return } s.handleHostWakeRetryReset(w, r, actor, raw) return } // A move's target loss is the moves owner's, under the typed // ControlWriter's scoped declaration (claim Plan 10, J4 and J17). The box // is the signed challenge's. if in.Action == "declare-lost" { if account, app, ok := s.claimMoveLossHolder(r, r.PathValue("host")); ok { if err := s.declareMoveLossLogged(r.Context(), account, app, r.PathValue("host"), in.Submission, func() error { return s.cfg.ClaimAuthority.DeclareMoveLoss(r.Context(), actor, account, app, r.PathValue("host"), s.claimChallengeBox(in.Submission), in.Submission) }); err != nil { writeClaimResponseError(w, err) return } s.bumpSerial(r.Context()) json.NewEncoder(w).Encode(map[string]bool{"ok": true}) return } } // This service changes claim roots; an ordinary app permit is not its // authority. Every other resolution is refused before acceptance or // quarantine until its owning lifecycle protocol supplies a scoped writer. if s.cfg.HostClaim { jsonError(w, http.StatusConflict, claimHostResolveUnsupportedMsg) return } svc := s.hostResolver() if in.Action == "readmit" { if err := svc.Readmit(r.Context(), in.Box, in.Enrollment, in.Revision, in.LossIDs); err != nil { jsonError(w, 409, err.Error()) return } } else if in.Action == "declare-lost" { if _, err := svc.Declare(r.Context(), actor, r.PathValue("host"), in.Submission); err != nil { jsonError(w, 409, err.Error()) return } } else { jsonError(w, 400, "invalid resolution action") return } json.NewEncoder(w).Encode(map[string]bool{"ok": true}) } // claimMoveLossHolder answers the holder's account and app when host's claim // is under a move kind the moves owner resolves, on a console with the moves // switch and its claim runtime. Every other host keeps the existing path. func (s *Server) claimMoveLossHolder(r *http.Request, host string) (string, string, bool) { a := s.cfg.ClaimAuthority if !s.cfg.HostClaim || !s.cfg.MovesEnabled || a == nil || a.Control == nil { return "", "", false } o, err := hostclaim.Read(r.Context(), s.cfg.Releases, host) if err != nil || o.Absent || o.Claim == nil || o.Claim.Holder == nil || !hostops.MoveLossKind(o.Claim.Op.Kind) { return "", "", false } return o.Claim.Holder.Account, o.Claim.Holder.App, true } // claimChallengeBox is the box reference the submission's signed challenge // names, or "" when it does not verify. DeclareMoveLoss verifies and // compares it again. func (s *Server) claimChallengeBox(in hostfence.Submission) string { var c hostfence.Challenge if release.VerifyRootEnvelope(s.cfg.ClaimAuthority.Directory.PublicKey, in.Challenge, &c) != nil { return "" } return c.Box }