carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
20package console
21
22import (
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.
43func (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.
64func (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
92func (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.
169func (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`.
203func (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.
218func (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