| 1 | // The deployment-wide `carlos system` doors. Today that is antivirus; the |
| 2 | // shape is meant to carry the next one. |
| 3 | // |
| 4 | // THESE HAVE NO {account} SEGMENT, which is the thing that makes them |
| 5 | // different from every other CLI door. apiPrincipalAccount resolves a caller |
| 6 | // against an account named in the path, and there is no account here to name: |
| 7 | // the question is about the deployment. So they resolve a PERSON only, the way |
| 8 | // GET /api/cli/apps already does, and they are registered with an empty |
| 9 | // svcrole.Set — no service credential reaches them at all. That registration |
| 10 | // is pinned by closedDoors in svcroutes_test.go, so a later hand cannot open |
| 11 | // them by accident. |
| 12 | // |
| 13 | // AUTHORITY IS AN ADDRESS, not a role. Operators are the addresses on |
| 14 | // CARLOS_STORE_OPERATORS (server.go), the same list that governs `store grant` |
| 15 | // and every scan operator verb. A service actor renders as "service:"/"app:" |
| 16 | // (apicli.go) and can never match one, so the empty role set and the operator |
| 17 | // check are belt and braces pointing the same way: enabling this starts |
| 18 | // reading tenant bytes across the whole deployment, and that is the |
| 19 | // deployment owner's decision rather than any admin's. |
| 20 | package console |
| 21 | |
| 22 | import ( |
| 23 | "context" |
| 24 | "crypto/ecdh" |
| 25 | "errors" |
| 26 | "log" |
| 27 | "net/http" |
| 28 | "os" |
| 29 | "strings" |
| 30 | "time" |
| 31 | |
| 32 | "github.com/carlosframework/platform/internal/hostops" |
| 33 | "github.com/carlosframework/platform/internal/objstore" |
| 34 | "github.com/carlosframework/platform/internal/release" |
| 35 | ) |
| 36 | |
| 37 | // apiSystemPerson resolves the caller as a person and checks the operator |
| 38 | // list. It is the whole gate for these doors. |
| 39 | // |
| 40 | // A non-person gets the same 404 a stranger gets, not a 403: these doors do |
| 41 | // not confirm the shape of the caller any more than the store doors confirm |
| 42 | // which apps exist. |
| 43 | func (s *Server) apiSystemOperator(w http.ResponseWriter, r *http.Request) (string, bool) { |
| 44 | p, ok := principalFrom(r.Context()) |
| 45 | if !ok || p.Kind != PrincipalUser { |
| 46 | http.NotFound(w, r) |
| 47 | return "", false |
| 48 | } |
| 49 | if !s.storeOperator(p.Address) { |
| 50 | jsonError(w, http.StatusForbidden, |
| 51 | "changing deployment-wide scanning needs a deployment operator (CARLOS_STORE_OPERATORS)") |
| 52 | return "", false |
| 53 | } |
| 54 | if s.cfg.Releases == nil { |
| 55 | storeNoBucket(w) |
| 56 | return "", false |
| 57 | } |
| 58 | return p.Address, true |
| 59 | } |
| 60 | |
| 61 | // handleAPISystemWebhookRotate spends one sweep-sized request budget sealing |
| 62 | // the current master for scanning-enabled apps. Progress needs no cursor: the |
| 63 | // non-secret key id is the cursor, so a retry skips everything already done. |
| 64 | func (s *Server) handleAPISystemWebhookRotate(w http.ResponseWriter, r *http.Request) { |
| 65 | if _, ok := s.apiSystemOperator(w, r); !ok { |
| 66 | return |
| 67 | } |
| 68 | p, _ := principalFrom(r.Context()) |
| 69 | if !s.allowWrite(p, time.Now()) { |
| 70 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 71 | return |
| 72 | } |
| 73 | pub, ok := s.storeSealKey(w) |
| 74 | if !ok { |
| 75 | return |
| 76 | } |
| 77 | if s.cfg.CallbackSigner == nil { |
| 78 | scanUnavailable(w, http.StatusServiceUnavailable, "callbacks_not_configured", "scan callbacks are not configured for this deployment (CARLOS_SCAN_WEBHOOK_KEY)") |
| 79 | return |
| 80 | } |
| 81 | ctx, cancel := context.WithTimeout(r.Context(), scanSweepTimeout) |
| 82 | defer cancel() |
| 83 | remaining, changed, err := s.rotateWebhookBudget(ctx, pub) |
| 84 | if err != nil { |
| 85 | log.Printf("console: rotate scan webhook key: %v", err) |
| 86 | jsonError(w, http.StatusBadGateway, "callback-key rotation stopped — re-run to resume") |
| 87 | return |
| 88 | } |
| 89 | writeJSON(w, http.StatusOK, map[string]int{"rotated": changed, "remaining": remaining}) |
| 90 | } |
| 91 | |
| 92 | func (s *Server) rotateWebhookBudget(ctx context.Context, pub *ecdh.PublicKey) (remaining, changed int, err error) { |
| 93 | accounts, err := s.cfg.Releases.ListDirs(ctx, "apps/") |
| 94 | if err != nil { |
| 95 | return 0, 0, err |
| 96 | } |
| 97 | type target struct{ acct, app string } |
| 98 | var targets []target |
| 99 | for _, accountPrefix := range accounts { |
| 100 | acct := strings.TrimSuffix(strings.TrimPrefix(accountPrefix, "apps/"), "/") |
| 101 | apps, lerr := s.cfg.Releases.ListDirs(ctx, accountPrefix) |
| 102 | if lerr != nil { |
| 103 | return 0, changed, lerr |
| 104 | } |
| 105 | for _, appPrefix := range apps { |
| 106 | app := strings.TrimSuffix(strings.TrimPrefix(appPrefix, accountPrefix), "/") |
| 107 | tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct, app) |
| 108 | if err != nil { |
| 109 | return 0, changed, err |
| 110 | } |
| 111 | if tomb != nil { |
| 112 | continue |
| 113 | } |
| 114 | rec, _, gerr := objstore.Get(ctx, s.cfg.Releases, acct, app) |
| 115 | if gerr != nil { |
| 116 | return 0, changed, gerr |
| 117 | } |
| 118 | if rec != nil && rec.Scanning != nil && rec.Scanning.Enabled != nil { |
| 119 | current, err := s.callbackKeyCurrent(ctx, acct, app, rec) |
| 120 | if err != nil { |
| 121 | return 0, changed, err |
| 122 | } |
| 123 | if !current { |
| 124 | targets = append(targets, target{acct, app}) |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | remaining = len(targets) |
| 130 | for _, target := range targets { |
| 131 | if ctx.Err() != nil { |
| 132 | return remaining, changed, nil |
| 133 | } |
| 134 | account := &Account{Sqid: target.acct} |
| 135 | var delivered bool |
| 136 | err := s.appMutation(ctx, account, target.app, "scan-webhook-rotate", func(ctx context.Context) error { |
| 137 | var err error |
| 138 | delivered, _, err = s.convergeCallbackKey(ctx, pub, account, target.app) |
| 139 | if errors.Is(err, objstore.ErrQueueNotPermitted) { |
| 140 | delivered = false |
| 141 | return nil |
| 142 | } |
| 143 | return err |
| 144 | }) |
| 145 | if err != nil { |
| 146 | if !delivered && errors.Is(err, hostops.ErrPending) { |
| 147 | continue |
| 148 | } |
| 149 | if !delivered && ctx.Err() != nil { |
| 150 | return remaining, changed, nil |
| 151 | } |
| 152 | return remaining, changed, err |
| 153 | } |
| 154 | remaining-- |
| 155 | if delivered { |
| 156 | changed++ |
| 157 | } |
| 158 | } |
| 159 | return remaining, changed, nil |
| 160 | } |
| 161 | |
| 162 | // systemAntivirusReply is the one shape all three doors answer with, so |
| 163 | // status and the mutating doors cannot describe the deployment differently. |
| 164 | // |
| 165 | // It reports WIRING and POLICY as separate facts, because they fail |
| 166 | // separately: an engine can be wired with scanning off, and scanning can be on |
| 167 | // with the engine unreachable. Collapsing them into one "healthy" boolean is |
| 168 | // how an operator ends up debugging the wrong half. |
| 169 | func (s *Server) systemAntivirusReply(ctx context.Context) map[string]any { |
| 170 | out := map[string]any{ |
| 171 | "wired": s.cfg.StoreScanner != nil, |
| 172 | "enabled": false, |
| 173 | "queue_apps_with_work": s.ScanQueueDepth(), |
| 174 | } |
| 175 | av, _, err := release.GetAntivirus(ctx, s.cfg.Releases) |
| 176 | if err != nil { |
| 177 | // A record we cannot read is its own state. Reporting it as disabled |
| 178 | // would be a guess, and this is the door an operator uses to find out |
| 179 | // what is actually true. |
| 180 | out["record_error"] = err.Error() |
| 181 | } else if av != nil { |
| 182 | out["enabled"] = av.Enabled |
| 183 | out["engine"] = av.Engine |
| 184 | out["at"] = av.At.UTC().Format(time.RFC3339) |
| 185 | out["by"] = av.By |
| 186 | } |
| 187 | |
| 188 | if s.cfg.StoreScanner == nil { |
| 189 | return out |
| 190 | } |
| 191 | engine, version, perr := s.cfg.StoreScanner.Provenance(ctx) |
| 192 | out["engine_wired"] = engine |
| 193 | if perr != nil { |
| 194 | out["engine_error"] = perr.Error() |
| 195 | return out |
| 196 | } |
| 197 | out["signature"] = version |
| 198 | out["max_signature_age_seconds"] = int64(s.scanMaxSigAge() / time.Second) |
| 199 | return out |
| 200 | } |
| 201 | |
| 202 | // handleAPISystemAntivirusGet answers `carlos system antivirus status`. |
| 203 | func (s *Server) handleAPISystemAntivirusGet(w http.ResponseWriter, r *http.Request) { |
| 204 | if _, ok := s.apiSystemOperator(w, r); !ok { |
| 205 | return |
| 206 | } |
| 207 | ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| 208 | defer cancel() |
| 209 | writeJSON(w, http.StatusOK, s.systemAntivirusReply(ctx)) |
| 210 | } |
| 211 | |
| 212 | // handleAPISystemAntivirusSet answers `enable` and `disable`. |
| 213 | // |
| 214 | // `enable` refuses when no engine is wired: a record claiming enabled with |
| 215 | // nothing behind it would make `scan status` report health for a deployment |
| 216 | // where no sweep can run, which is the class of lie this whole feature is |
| 217 | // careful about. |
| 218 | func (s *Server) handleAPISystemAntivirusSet(w http.ResponseWriter, r *http.Request) { |
| 219 | addr, ok := s.apiSystemOperator(w, r) |
| 220 | if !ok { |
| 221 | return |
| 222 | } |
| 223 | p, _ := principalFrom(r.Context()) |
| 224 | if !s.allowWrite(p, time.Now()) { |
| 225 | jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| 226 | return |
| 227 | } |
| 228 | var req struct { |
| 229 | Enabled *bool `json:"enabled"` |
| 230 | } |
| 231 | if !readJSON(w, r, &req) { |
| 232 | return |
| 233 | } |
| 234 | if req.Enabled == nil { |
| 235 | jsonError(w, http.StatusBadRequest, |
| 236 | "say which: `carlos system antivirus enable` or `... disable`") |
| 237 | return |
| 238 | } |
| 239 | if *req.Enabled && s.cfg.StoreScanner == nil { |
| 240 | jsonError(w, http.StatusServiceUnavailable, |
| 241 | "no scanning engine is wired on this deployment (CARLOS_CLAMD_ADDR) — "+ |
| 242 | "enabling would record a control that cannot run") |
| 243 | return |
| 244 | } |
| 245 | |
| 246 | ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) |
| 247 | defer cancel() |
| 248 | |
| 249 | av, etag, err := release.GetAntivirus(ctx, s.cfg.Releases) |
| 250 | if err != nil { |
| 251 | if *req.Enabled { |
| 252 | log.Printf("console: api system antivirus: read: %v", err) |
| 253 | jsonError(w, http.StatusBadGateway, "could not read the scanning state — try again") |
| 254 | return |
| 255 | } |
| 256 | // DISABLE REPAIRS, and this is the same deadlock argument apiscan.go |
| 257 | // makes for revoke and enforce-off. A record this build cannot parse |
| 258 | // already stops every sweep — GetAntivirus errors, so the tick returns |
| 259 | // early and both doors refuse — but it also leaves the operator with |
| 260 | // no door that can put the deployment back into a state it can read. |
| 261 | // The one verb whose whole job is "stop" must not be the verb that |
| 262 | // refuses for the very reason it is needed. |
| 263 | // |
| 264 | // The CAS is still honest: the etag comes from a raw read, so a |
| 265 | // concurrent writer still wins. Only the CONTENT is being replaced. |
| 266 | _, raw, gerr := s.cfg.Releases.Get(ctx, release.SystemKey(release.AntivirusName)) |
| 267 | if gerr != nil && !errors.Is(gerr, os.ErrNotExist) { |
| 268 | log.Printf("console: api system antivirus: raw read: %v", gerr) |
| 269 | jsonError(w, http.StatusBadGateway, "could not read the scanning state — try again") |
| 270 | return |
| 271 | } |
| 272 | log.Printf("console: api system antivirus: replacing an unreadable record on disable: %v", err) |
| 273 | av, etag = &release.AntivirusSettings{}, raw |
| 274 | } |
| 275 | if av == nil { |
| 276 | av = &release.AntivirusSettings{} |
| 277 | } |
| 278 | av.Enabled = *req.Enabled |
| 279 | av.Engine = "clamav" |
| 280 | av.At = time.Now().UTC() |
| 281 | av.By = addr |
| 282 | if _, err := release.PutAntivirus(ctx, s.cfg.Releases, av, etag); err != nil { |
| 283 | log.Printf("console: api system antivirus: write: %v", err) |
| 284 | jsonError(w, http.StatusBadGateway, "could not record the change — try again") |
| 285 | return |
| 286 | } |
| 287 | |
| 288 | what := "disabled" |
| 289 | if *req.Enabled { |
| 290 | what = "enabled" |
| 291 | } |
| 292 | // Logged, not audited into the events table: RecordEvent is keyed on an |
| 293 | // account and this action has none — it is about the deployment. The |
| 294 | // durable trail is the record itself, which carries who did it and when, |
| 295 | // and which `carlos system antivirus status` reads back. That is a better |
| 296 | // answer than an event row anyway: an audit asks what the state IS and who |
| 297 | // set it, not what was attempted. |
| 298 | log.Printf("console: object scanning %s deployment-wide by %s", what, addr) |
| 299 | writeJSON(w, http.StatusOK, s.systemAntivirusReply(ctx)) |
| 300 | } |
| 301 | |