// The deployment-wide `carlos system` doors. Today that is antivirus; the // shape is meant to carry the next one. // // THESE HAVE NO {account} SEGMENT, which is the thing that makes them // different from every other CLI door. apiPrincipalAccount resolves a caller // against an account named in the path, and there is no account here to name: // the question is about the deployment. So they resolve a PERSON only, the way // GET /api/cli/apps already does, and they are registered with an empty // svcrole.Set — no service credential reaches them at all. That registration // is pinned by closedDoors in svcroutes_test.go, so a later hand cannot open // them by accident. // // AUTHORITY IS AN ADDRESS, not a role. Operators are the addresses on // CARLOS_STORE_OPERATORS (server.go), the same list that governs `store grant` // and every scan operator verb. A service actor renders as "service:"/"app:" // (apicli.go) and can never match one, so the empty role set and the operator // check are belt and braces pointing the same way: enabling this starts // reading tenant bytes across the whole deployment, and that is the // deployment owner's decision rather than any admin's. package console import ( "context" "crypto/ecdh" "errors" "log" "net/http" "os" "strings" "time" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/release" ) // apiSystemPerson resolves the caller as a person and checks the operator // list. It is the whole gate for these doors. // // A non-person gets the same 404 a stranger gets, not a 403: these doors do // not confirm the shape of the caller any more than the store doors confirm // which apps exist. func (s *Server) apiSystemOperator(w http.ResponseWriter, r *http.Request) (string, bool) { p, ok := principalFrom(r.Context()) if !ok || p.Kind != PrincipalUser { http.NotFound(w, r) return "", false } if !s.storeOperator(p.Address) { jsonError(w, http.StatusForbidden, "changing deployment-wide scanning needs a deployment operator (CARLOS_STORE_OPERATORS)") return "", false } if s.cfg.Releases == nil { storeNoBucket(w) return "", false } return p.Address, true } // handleAPISystemWebhookRotate spends one sweep-sized request budget sealing // the current master for scanning-enabled apps. Progress needs no cursor: the // non-secret key id is the cursor, so a retry skips everything already done. func (s *Server) handleAPISystemWebhookRotate(w http.ResponseWriter, r *http.Request) { if _, ok := s.apiSystemOperator(w, r); !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } pub, ok := s.storeSealKey(w) if !ok { return } if s.cfg.CallbackSigner == nil { scanUnavailable(w, http.StatusServiceUnavailable, "callbacks_not_configured", "scan callbacks are not configured for this deployment (CARLOS_SCAN_WEBHOOK_KEY)") return } ctx, cancel := context.WithTimeout(r.Context(), scanSweepTimeout) defer cancel() remaining, changed, err := s.rotateWebhookBudget(ctx, pub) if err != nil { log.Printf("console: rotate scan webhook key: %v", err) jsonError(w, http.StatusBadGateway, "callback-key rotation stopped — re-run to resume") return } writeJSON(w, http.StatusOK, map[string]int{"rotated": changed, "remaining": remaining}) } func (s *Server) rotateWebhookBudget(ctx context.Context, pub *ecdh.PublicKey) (remaining, changed int, err error) { accounts, err := s.cfg.Releases.ListDirs(ctx, "apps/") if err != nil { return 0, 0, err } type target struct{ acct, app string } var targets []target for _, accountPrefix := range accounts { acct := strings.TrimSuffix(strings.TrimPrefix(accountPrefix, "apps/"), "/") apps, lerr := s.cfg.Releases.ListDirs(ctx, accountPrefix) if lerr != nil { return 0, changed, lerr } for _, appPrefix := range apps { app := strings.TrimSuffix(strings.TrimPrefix(appPrefix, accountPrefix), "/") tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct, app) if err != nil { return 0, changed, err } if tomb != nil { continue } rec, _, gerr := objstore.Get(ctx, s.cfg.Releases, acct, app) if gerr != nil { return 0, changed, gerr } if rec != nil && rec.Scanning != nil && rec.Scanning.Enabled != nil { current, err := s.callbackKeyCurrent(ctx, acct, app, rec) if err != nil { return 0, changed, err } if !current { targets = append(targets, target{acct, app}) } } } } remaining = len(targets) for _, target := range targets { if ctx.Err() != nil { return remaining, changed, nil } account := &Account{Sqid: target.acct} var delivered bool err := s.appMutation(ctx, account, target.app, "scan-webhook-rotate", func(ctx context.Context) error { var err error delivered, _, err = s.convergeCallbackKey(ctx, pub, account, target.app) if errors.Is(err, objstore.ErrQueueNotPermitted) { delivered = false return nil } return err }) if err != nil { if !delivered && errors.Is(err, hostops.ErrPending) { continue } if !delivered && ctx.Err() != nil { return remaining, changed, nil } return remaining, changed, err } remaining-- if delivered { changed++ } } return remaining, changed, nil } // systemAntivirusReply is the one shape all three doors answer with, so // status and the mutating doors cannot describe the deployment differently. // // It reports WIRING and POLICY as separate facts, because they fail // separately: an engine can be wired with scanning off, and scanning can be on // with the engine unreachable. Collapsing them into one "healthy" boolean is // how an operator ends up debugging the wrong half. func (s *Server) systemAntivirusReply(ctx context.Context) map[string]any { out := map[string]any{ "wired": s.cfg.StoreScanner != nil, "enabled": false, "queue_apps_with_work": s.ScanQueueDepth(), } av, _, err := release.GetAntivirus(ctx, s.cfg.Releases) if err != nil { // A record we cannot read is its own state. Reporting it as disabled // would be a guess, and this is the door an operator uses to find out // what is actually true. out["record_error"] = err.Error() } else if av != nil { out["enabled"] = av.Enabled out["engine"] = av.Engine out["at"] = av.At.UTC().Format(time.RFC3339) out["by"] = av.By } if s.cfg.StoreScanner == nil { return out } engine, version, perr := s.cfg.StoreScanner.Provenance(ctx) out["engine_wired"] = engine if perr != nil { out["engine_error"] = perr.Error() return out } out["signature"] = version out["max_signature_age_seconds"] = int64(s.scanMaxSigAge() / time.Second) return out } // handleAPISystemAntivirusGet answers `carlos system antivirus status`. func (s *Server) handleAPISystemAntivirusGet(w http.ResponseWriter, r *http.Request) { if _, ok := s.apiSystemOperator(w, r); !ok { return } ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) defer cancel() writeJSON(w, http.StatusOK, s.systemAntivirusReply(ctx)) } // handleAPISystemAntivirusSet answers `enable` and `disable`. // // `enable` refuses when no engine is wired: a record claiming enabled with // nothing behind it would make `scan status` report health for a deployment // where no sweep can run, which is the class of lie this whole feature is // careful about. func (s *Server) handleAPISystemAntivirusSet(w http.ResponseWriter, r *http.Request) { addr, ok := s.apiSystemOperator(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } var req struct { Enabled *bool `json:"enabled"` } if !readJSON(w, r, &req) { return } if req.Enabled == nil { jsonError(w, http.StatusBadRequest, "say which: `carlos system antivirus enable` or `... disable`") return } if *req.Enabled && s.cfg.StoreScanner == nil { jsonError(w, http.StatusServiceUnavailable, "no scanning engine is wired on this deployment (CARLOS_CLAMD_ADDR) — "+ "enabling would record a control that cannot run") return } ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) defer cancel() av, etag, err := release.GetAntivirus(ctx, s.cfg.Releases) if err != nil { if *req.Enabled { log.Printf("console: api system antivirus: read: %v", err) jsonError(w, http.StatusBadGateway, "could not read the scanning state — try again") return } // DISABLE REPAIRS, and this is the same deadlock argument apiscan.go // makes for revoke and enforce-off. A record this build cannot parse // already stops every sweep — GetAntivirus errors, so the tick returns // early and both doors refuse — but it also leaves the operator with // no door that can put the deployment back into a state it can read. // The one verb whose whole job is "stop" must not be the verb that // refuses for the very reason it is needed. // // The CAS is still honest: the etag comes from a raw read, so a // concurrent writer still wins. Only the CONTENT is being replaced. _, raw, gerr := s.cfg.Releases.Get(ctx, release.SystemKey(release.AntivirusName)) if gerr != nil && !errors.Is(gerr, os.ErrNotExist) { log.Printf("console: api system antivirus: raw read: %v", gerr) jsonError(w, http.StatusBadGateway, "could not read the scanning state — try again") return } log.Printf("console: api system antivirus: replacing an unreadable record on disable: %v", err) av, etag = &release.AntivirusSettings{}, raw } if av == nil { av = &release.AntivirusSettings{} } av.Enabled = *req.Enabled av.Engine = "clamav" av.At = time.Now().UTC() av.By = addr if _, err := release.PutAntivirus(ctx, s.cfg.Releases, av, etag); err != nil { log.Printf("console: api system antivirus: write: %v", err) jsonError(w, http.StatusBadGateway, "could not record the change — try again") return } what := "disabled" if *req.Enabled { what = "enabled" } // Logged, not audited into the events table: RecordEvent is keyed on an // account and this action has none — it is about the deployment. The // durable trail is the record itself, which carries who did it and when, // and which `carlos system antivirus status` reads back. That is a better // answer than an event row anyway: an audit asks what the state IS and who // set it, not what was attempted. log.Printf("console: object scanning %s deployment-wide by %s", what, addr) writeJSON(w, http.StatusOK, s.systemAntivirusReply(ctx)) }