console+cli: carlos system antivirus
The first deployment-wide doors in the CLI API, and the shape is meant
to carry the next `carlos system <thing>`.
They have no {account} segment, which is what makes them different from
every other door here: apiPrincipalAccount resolves a caller against an
account named in the path, and the question these answer is about the
deployment. So they resolve a PERSON only, the way GET /api/cli/apps
already does, and register with an empty svcrole.Set.
That registration is pinned. The closed-door test hard-codes how many
doors are shut to service credentials, so adding two forced a deliberate
edit rather than a silent widening — and it proves these are unreachable
by any credential including Admin, which reaches nearly everything else.
Authority is then an ADDRESS on CARLOS_STORE_OPERATORS: enabling this
starts reading tenant bytes across every store on the deployment, and
that is the deployment owner's decision rather than any admin's.
Wiring and policy are reported as two facts because they fail
separately. An engine can be wired with scanning off; scanning can be on
with the engine unreachable; and a record can be unreadable, which is
its own state and emphatically not "off" — reporting a guess on the one
command an operator runs to find out what is true would be the worst
possible place for one. Collapsing all that into a single "healthy" line
is how someone ends up debugging the wrong half.
enable refuses when no engine is wired, or the record would claim a
control that cannot run and `scan status` would report health for a
deployment where no sweep can happen. disable is always allowed: it is
the way out of a bad state, and a door that refused would strand whoever
needed it.
No audit row: RecordEvent is keyed on an account and this action has
none. The record itself carries who and when, and status reads them
back, which is the better answer — an audit asks what the state IS and
who set it, not what was attempted.
The docsite gate requires a heading per top-level verb, so cli.md gains
a `carlos system` section now rather than at task 17 as the plan had it.
It is accurate; it gets finalised with the rest of the docs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+636
−2
apimode.go+58 −0docs/site/cli.md+28 −0internal/console/apisystem.go+172 −0internal/console/apisystem_test.go+134 −0internal/console/authcli.go+6 −0internal/console/svcroutes_test.go+8 −2main.go+2 −0system.go+128 −0system_test.go+100 −0
diff --git a/apimode.go b/apimode.go| index 8d9c7365e..6b859fa4f 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -1560,6 +1560,64 @@ type apiStoreReply struct { |
| Scanning *apiScanBlock `json:"scanning,omitempty"` |
| } |
| +// ---- deployment-wide system settings (system.go) ---------------------- |
| + |
| +// apiSystemAntivirusReply reports WIRING and POLICY as separate fields on |
| +// purpose: an engine can be wired with scanning off, and scanning can be on |
| +// with the engine unreachable, and an operator debugging "nothing is scanning" |
| +// needs to know which half is missing. |
| +type apiSystemAntivirusReply struct { |
| + Wired bool `json:"wired"` |
| + Enabled bool `json:"enabled"` |
| + Engine string `json:"engine,omitempty"` // the engine the RECORD names |
| + At string `json:"at,omitempty"` |
| + By string `json:"by,omitempty"` |
| + // EngineWired is what the wired engine calls itself, which can differ from |
| + // the record's name — that difference is engine drift, and it is why the |
| + // two are separate fields rather than one. |
| + EngineWired string `json:"engine_wired,omitempty"` |
| + EngineError string `json:"engine_error,omitempty"` |
| + RecordError string `json:"record_error,omitempty"` |
| + Signature string `json:"signature,omitempty"` |
| + MaxSignatureAgeSeconds int64 `json:"max_signature_age_seconds,omitempty"` |
| +} |
| + |
| +func (s *apiSession) systemAntivirusStatus(ctx context.Context) (apiSystemAntivirusReply, error) { |
| + status, body, err := s.getJSON(ctx, "/api/cli/system/antivirus") |
| + if err != nil { |
| + return apiSystemAntivirusReply{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiSystemAntivirusReply{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiSystemAntivirusReply |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiSystemAntivirusReply{}, fmt.Errorf("system antivirus status: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +// systemAntivirusSet sends enabled explicitly — never omitted. The door |
| +// refuses a body that says neither, because there is no safe default for a |
| +// switch that decides whether tenant bytes get read. |
| +func (s *apiSession) systemAntivirusSet(ctx context.Context, on bool) (apiSystemAntivirusReply, error) { |
| + in := struct { |
| + Enabled bool `json:"enabled"` |
| + }{on} |
| + status, body, err := s.postJSON(ctx, "/api/cli/system/antivirus", in) |
| + if err != nil { |
| + return apiSystemAntivirusReply{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiSystemAntivirusReply{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiSystemAntivirusReply |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiSystemAntivirusReply{}, fmt.Errorf("system antivirus: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| // ---- store scanning (scan.go) ---------------------------------------- |
| // The scan reply's blocks, mirrored here rather than imported from |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index e3d9db41b..74b7b78c5 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -588,6 +588,34 @@ Setting `CARLOS_STORE_SCANNER` without `CARLOS_STORE_BACKEND` is refused at |
| boot — a scanner with no object stores to scan is a misconfiguration, and one |
| loud failure beats a pass that silently finds nothing forever. |
| +### carlos system |
| + |
| +Deployment-wide settings an operator owns, as against `carlos store` and |
| +`carlos apps`, which are about one app. |
| + |
| +```sh |
| +carlos system antivirus status |
| +carlos system antivirus enable |
| +carlos system antivirus disable |
| +``` |
| + |
| +`antivirus status` answers "is object scanning actually working here" without |
| +logging into a box. It reports **wiring** and **policy** separately, because |
| +they fail separately: an engine can be wired with scanning switched off, and |
| +scanning can be switched on with the engine unreachable. It also prints the |
| +signature-database version and warns when it is stale. |
| + |
| +`enable` and `disable` are the switch. It lives in a record rather than in the |
| +unit file, so turning scanning on or off takes effect on the next tick with no |
| +restart — and `disable` stops every path, including `carlos store scan sweep`, |
| +not just the hourly pass. `enable` is refused when no engine is wired: a record |
| +claiming enabled with nothing behind it would report health for a deployment |
| +where no scan can run. |
| + |
| +Deployment operators only (`CARLOS_STORE_OPERATORS`). Enabling scans nothing by |
| +itself — a member still requests scanning for their app and an operator still |
| +grants it per store. |
| + |
| ### carlos email |
| Tenant email sending. `carlos email enable` is the whole path in one |
diff --git a/internal/console/apisystem.go b/internal/console/apisystem.go| new file mode 100644 |
| index 000000000..28f738519 |
| --- /dev/null |
| +++ b/internal/console/apisystem.go |
| @@ -0,0 +1,172 @@ |
| +// 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" |
| + "log" |
| + "net/http" |
| + "time" |
| + |
| + "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 |
| +} |
| + |
| +// 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, |
| + } |
| + 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 { |
| + log.Printf("console: api system antivirus: read: %v", err) |
| + jsonError(w, http.StatusBadGateway, "could not read the scanning state — try again") |
| + return |
| + } |
| + 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)) |
| +} |
diff --git a/internal/console/apisystem_test.go b/internal/console/apisystem_test.go| new file mode 100644 |
| index 000000000..6168417c6 |
| --- /dev/null |
| +++ b/internal/console/apisystem_test.go |
| @@ -0,0 +1,134 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/objstore" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const systemAntivirusURL = "/api/cli/system/antivirus" |
| + |
| +// systemArmed is a console with an engine wired and an operator who may act. |
| +func systemArmed(t *testing.T, code string) (*consoleFixture, string) { |
| + t.Helper() |
| + fx, _, token, _ := storeArmed(t, code) |
| + fx.srv.cfg.StoreScanner = objstore.NewMemScanner() |
| + return fx, token |
| +} |
| + |
| +// Authority here is an ADDRESS, not a role. Admin reaches almost everything; |
| +// enabling deployment-wide scanning starts reading tenant bytes across every |
| +// store, and that is the deployment owner's call. |
| +func TestSystemAntivirusNeedsOperator(t *testing.T) { |
| + fx, token := systemArmed(t, "code-sys-operator") |
| + fx.srv.cfg.StoreOperators = []string{"someone-else@keymail.dev"} |
| + |
| + status, out := postJSON(t, fx.ts.URL+systemAntivirusURL, map[string]any{"enabled": true}, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("enable as a non-operator = %d %v, want 403", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_STORE_OPERATORS") { |
| + t.Fatalf("error = %q, want the operator list named", msg) |
| + } |
| + |
| + status, out = getJSON(t, fx.ts.URL+systemAntivirusURL, token) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("status as a non-operator = %d %v, want 403", status, out) |
| + } |
| +} |
| + |
| +// Enabling with no engine wired would record a control that cannot run, and |
| +// `scan status` would then report health for a deployment where no sweep can |
| +// happen — the class of lie this whole feature is careful about. |
| +func TestSystemAntivirusEnableRefusesWithoutScanner(t *testing.T) { |
| + fx, token := systemArmed(t, "code-sys-nowire") |
| + fx.srv.cfg.StoreScanner = nil |
| + |
| + status, out := postJSON(t, fx.ts.URL+systemAntivirusURL, map[string]any{"enabled": true}, token) |
| + if status != http.StatusServiceUnavailable { |
| + t.Fatalf("enable with no engine = %d %v, want 503", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "CARLOS_CLAMD_ADDR") { |
| + t.Fatalf("error = %q, want the wiring variable named", msg) |
| + } |
| + if av, _, err := release.GetAntivirus(context.Background(), fx.rel); err != nil || av != nil { |
| + t.Fatalf("a refused enable still wrote a record: %+v, %v", av, err) |
| + } |
| + |
| + // Disabling is always allowed, engine or not: it is the way out of a bad |
| + // state, and a door that refused would strand whoever needed it. |
| + if s, o := postJSON(t, fx.ts.URL+systemAntivirusURL, map[string]any{"enabled": false}, token); s != http.StatusOK { |
| + t.Fatalf("disable with no engine = %d %v, want 200", s, o) |
| + } |
| +} |
| + |
| +// Wiring and policy are reported as separate facts, because they fail |
| +// separately. Collapsing them into one "healthy" is how an operator ends up |
| +// debugging the wrong half. |
| +func TestSystemAntivirusStatusSeparatesWiringFromPolicy(t *testing.T) { |
| + fx, token := systemArmed(t, "code-sys-status") |
| + |
| + // Wired, no record: not enabled, and the reply says which is which. |
| + status, out := getJSON(t, fx.ts.URL+systemAntivirusURL, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("status = %d %v", status, out) |
| + } |
| + if wired, _ := out["wired"].(bool); !wired { |
| + t.Fatalf("wired = %v, want true", out["wired"]) |
| + } |
| + if enabled, _ := out["enabled"].(bool); enabled { |
| + t.Fatalf("enabled = %v with no record, want false", out["enabled"]) |
| + } |
| + if str(t, out, "signature") == "" { |
| + t.Fatal("a wired engine reported no signature database") |
| + } |
| + |
| + // Enabled, engine gone: still enabled, but the engine error is visible. |
| + if s, o := postJSON(t, fx.ts.URL+systemAntivirusURL, map[string]any{"enabled": true}, token); s != http.StatusOK { |
| + t.Fatalf("enable = %d %v", s, o) |
| + } |
| + fx.srv.cfg.StoreScanner = nil |
| + _, out = getJSON(t, fx.ts.URL+systemAntivirusURL, token) |
| + if enabled, _ := out["enabled"].(bool); !enabled { |
| + t.Fatal("unwiring the engine changed the recorded policy") |
| + } |
| + if wired, _ := out["wired"].(bool); wired { |
| + t.Fatal("wired stayed true with no scanner") |
| + } |
| +} |
| + |
| +// The record is the audit trail: who turned it on, and when. |
| +func TestSystemAntivirusRecordsWhoAndWhen(t *testing.T) { |
| + fx, token := systemArmed(t, "code-sys-audit") |
| + before := time.Now().Add(-time.Second) |
| + |
| + if s, o := postJSON(t, fx.ts.URL+systemAntivirusURL, map[string]any{"enabled": true}, token); s != http.StatusOK { |
| + t.Fatalf("enable = %d %v", s, o) |
| + } |
| + av, _, err := release.GetAntivirus(context.Background(), fx.rel) |
| + if err != nil || av == nil { |
| + t.Fatalf("record = %+v, %v", av, err) |
| + } |
| + if av.By != "alice@keymail.dev" { |
| + t.Fatalf("by = %q, want the operator who did it", av.By) |
| + } |
| + if av.At.Before(before) { |
| + t.Fatalf("at = %v, not stamped", av.At) |
| + } |
| +} |
| + |
| +// A body that says neither is a 400, not a guess. There is no safe default |
| +// for a switch that decides whether tenant bytes get read. |
| +func TestSystemAntivirusNeedsAnExplicitState(t *testing.T) { |
| + fx, token := systemArmed(t, "code-sys-explicit") |
| + |
| + status, out := postJSON(t, fx.ts.URL+systemAntivirusURL, map[string]any{}, token) |
| + if status != http.StatusBadRequest { |
| + t.Fatalf("empty body = %d %v, want 400", status, out) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 3ceb50928..c3aaf3b48 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -736,6 +736,12 @@ func (s *Server) registerCLIRoutes() { |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/revoke", svcrole.Allow(svcrole.Admin), s.handleAPIScanRevoke) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/sweep", svcrole.Allow(svcrole.Admin), s.handleAPIScanSweep) |
| s.cliRoute("POST /api/cli/apps/{account}/{app}/store/scan/enforce", svcrole.Allow(svcrole.Admin), s.handleAPIScanEnforce) |
| + // The deployment-wide doors (internal/console/apisystem.go). No {account} |
| + // segment, so no service credential can be scoped to them at all: an empty |
| + // Set shuts them to every credential, and closedDoors pins that shut. |
| + // Authority is the CARLOS_STORE_OPERATORS address list, checked inside. |
| + s.cliRoute("GET /api/cli/system/antivirus", svcrole.Set{}, s.handleAPISystemAntivirusGet) |
| + s.cliRoute("POST /api/cli/system/antivirus", svcrole.Set{}, s.handleAPISystemAntivirusSet) |
| } |
| // clientIP is who the rate limiter counts against. |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 2c3fb99a4..8bc9b1fbe 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -81,6 +81,12 @@ var ( |
| // Only an equality catches both; "no empty Sets" catches neither, and "these |
| // are empty" catches only the first. |
| var closedDoors = []string{ |
| + // Deployment-wide scanning policy. There is no {account} to scope a |
| + // service credential to, and enabling it starts reading tenant bytes |
| + // across every store on the deployment — an owner's decision, not a |
| + // credential's. |
| + "GET /api/cli/system/antivirus", |
| + "POST /api/cli/system/antivirus", |
| // A credential may never mint a credential, including itself. This is |
| // the escalation cut of spec §3. |
| "POST /api/cli/apps/{account}/{app}/instance-token", |
| @@ -686,8 +692,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 17 { |
| - t.Errorf("walked %d closed doors, want all %d (and 17 today: instance-token, the routes door, instances-manifest, the route-channel move, six fleets, four services, two email credentials, email pause)", |
| + if seen != len(closedDoors) || seen != 19 { |
| + t.Errorf("walked %d closed doors, want all %d (and 19 today: instance-token, the routes door, instances-manifest, the route-channel move, six fleets, four services, two email credentials, email pause, two deployment-wide antivirus)", |
| seen, len(closedDoors)) |
| } |
| } |
diff --git a/main.go b/main.go| index 4ec5ccee6..2f4bfd446 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -75,6 +75,8 @@ func main() { |
| runEconomics(args) |
| case "store": |
| runStore(args) |
| + case "system": |
| + runSystem(args) |
| case "email": |
| runEmail(args) |
| case "logs": |
diff --git a/system.go b/system.go| new file mode 100644 |
| index 000000000..6b7e567d3 |
| --- /dev/null |
| +++ b/system.go |
| @@ -0,0 +1,128 @@ |
| +// system.go is `carlos system`: deployment-wide settings an operator owns, |
| +// as against `carlos store` and `carlos apps`, which are about one app. |
| +// |
| +// Today that is antivirus. The verb exists as its own family because the |
| +// question it answers is a different question: not "what is true of my app" |
| +// but "what is this deployment doing at all", and the answer to that should |
| +// not require logging into a box to read a unit file. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "time" |
| +) |
| + |
| +const systemUsage = `usage: carlos system antivirus <status|enable|disable> [flags] |
| + |
| + antivirus status is object scanning wired, switched on, and answering? |
| + Reports wiring and policy separately, because they fail |
| + separately. |
| + antivirus enable turn object scanning on for this deployment. Refused if |
| + no engine is wired: a record claiming enabled with |
| + nothing behind it would report health where no scan can |
| + run. |
| + antivirus disable turn it off. Takes effect on the next tick — no restart, |
| + which is the point of the switch living in a record |
| + rather than in the unit file. |
| + |
| +Deployment operators only (CARLOS_STORE_OPERATORS). This does not scan |
| +anything by itself: a member still requests scanning for their app, and an |
| +operator still grants it per store. |
| +` |
| + |
| +func runSystem(args []string) { |
| + if len(args) == 0 || args[0] != "antivirus" { |
| + fmt.Fprint(os.Stderr, systemUsage) |
| + os.Exit(1) |
| + } |
| + rest := args[1:] |
| + if len(rest) == 0 { |
| + fmt.Fprint(os.Stderr, systemUsage) |
| + os.Exit(1) |
| + } |
| + switch rest[0] { |
| + case "status": |
| + runSystemAntivirus(rest[1:], "status") |
| + case "enable": |
| + runSystemAntivirus(rest[1:], "enable") |
| + case "disable": |
| + runSystemAntivirus(rest[1:], "disable") |
| + default: |
| + fmt.Fprint(os.Stderr, systemUsage) |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +func runSystemAntivirus(args []string, verb string) { |
| + fs := flag.NewFlagSet("system antivirus "+verb, flag.ExitOnError) |
| + consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + fs.Usage = flagUsage(fs, "") |
| + fs.Parse(args) |
| + |
| + sess := requireStoreAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + |
| + ctx := context.Background() |
| + var res apiSystemAntivirusReply |
| + var err error |
| + switch verb { |
| + case "status": |
| + res, err = sess.systemAntivirusStatus(ctx) |
| + case "enable": |
| + res, err = sess.systemAntivirusSet(ctx, true) |
| + case "disable": |
| + res, err = sess.systemAntivirusSet(ctx, false) |
| + } |
| + if err != nil { |
| + log.Fatalf("system antivirus %s: %v", verb, err) |
| + } |
| + printSystemAntivirus(stdout, res) |
| +} |
| + |
| +// printSystemAntivirus reports wiring and policy as the two separate facts |
| +// they are. An operator debugging "why is nothing scanning" needs to know |
| +// which half is missing, and a single "healthy" line would send half of them |
| +// to the wrong place. |
| +func printSystemAntivirus(w io.Writer, res apiSystemAntivirusReply) { |
| + c := colorFor(w) |
| + fmt.Fprintf(w, "%s\n", c.head("object scanning, deployment-wide")) |
| + |
| + if !res.Wired { |
| + warnf(w, "no engine is wired on this deployment — an operator sets CARLOS_CLAMD_ADDR") |
| + } else if res.EngineError != "" { |
| + warnf(w, "the engine is wired but not answering: %s", res.EngineError) |
| + } else { |
| + fmt.Fprintf(w, " engine: %s\n", res.EngineWired) |
| + if res.Signature != "" { |
| + fmt.Fprintf(w, " engine database now: %s\n", res.Signature) |
| + if age, ok := signatureAge(res.Signature, time.Now()); ok { |
| + max := time.Duration(res.MaxSignatureAgeSeconds) * time.Second |
| + if max > 0 && age > max { |
| + warnf(w, "those definitions are %d days old — update the engine's database", |
| + int(age.Hours()/24)) |
| + } |
| + } |
| + } |
| + } |
| + |
| + if res.RecordError != "" { |
| + warnf(w, "the scanning record could not be read: %s", res.RecordError) |
| + return |
| + } |
| + |
| + if !res.Enabled { |
| + mutedf(w, " scanning is OFF for this deployment — carlos system antivirus enable") |
| + if res.By != "" { |
| + mutedf(w, " turned off by %s at %s", res.By, res.At) |
| + } |
| + return |
| + } |
| + fmt.Fprintf(w, " scanning is ON, enabled by %s at %s\n", res.By, res.At) |
| + mutedf(w, " this alone scans nothing: a member requests scanning for their app,") |
| + mutedf(w, " and an operator grants it per store (carlos store scan grant)") |
| +} |
diff --git a/system_test.go b/system_test.go| new file mode 100644 |
| index 000000000..ca6ce5ef6 |
| --- /dev/null |
| +++ b/system_test.go |
| @@ -0,0 +1,100 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// Wiring and policy are two facts, and the output keeps them apart. An |
| +// operator debugging "nothing is scanning" needs to know which half is |
| +// missing; one "healthy" line would send half of them to the wrong place. |
| +func TestPrintSystemAntivirusSeparatesWiringFromPolicy(t *testing.T) { |
| + fresh := "ClamAV 1.4.1/27500/" + time.Now().Format(time.ANSIC) |
| + |
| + // Not wired at all. |
| + var b bytes.Buffer |
| + printSystemAntivirus(&b, apiSystemAntivirusReply{}) |
| + if out := b.String(); !strings.Contains(out, "no engine is wired") || |
| + !strings.Contains(out, "CARLOS_CLAMD_ADDR") { |
| + t.Fatalf("unwired output does not name the wiring:\n%s", out) |
| + } |
| + |
| + // Wired, switched off. Names the COMMAND, not the variable — this half is |
| + // restartless and an operator sent to a unit file would be sent wrong. |
| + var b2 bytes.Buffer |
| + printSystemAntivirus(&b2, apiSystemAntivirusReply{ |
| + Wired: true, EngineWired: "clamav", Signature: fresh, Enabled: false, |
| + }) |
| + out2 := b2.String() |
| + if !strings.Contains(out2, "scanning is OFF") || |
| + !strings.Contains(out2, "carlos system antivirus enable") { |
| + t.Fatalf("disabled output does not name the command:\n%s", out2) |
| + } |
| + if strings.Contains(out2, "CARLOS_CLAMD_ADDR") { |
| + t.Fatalf("a wired-but-off deployment was told to fix its wiring:\n%s", out2) |
| + } |
| + |
| + // Wired and on. |
| + var b3 bytes.Buffer |
| + printSystemAntivirus(&b3, apiSystemAntivirusReply{ |
| + Wired: true, EngineWired: "clamav", Signature: fresh, |
| + Enabled: true, By: "op@example.com", At: "2026-09-02T08:00:00Z", |
| + }) |
| + out3 := b3.String() |
| + if !strings.Contains(out3, "scanning is ON") || !strings.Contains(out3, "op@example.com") { |
| + t.Fatalf("enabled output:\n%s", out3) |
| + } |
| + // And it must not imply anything is being scanned yet. |
| + if !strings.Contains(out3, "scans nothing") { |
| + t.Fatalf("enabled output implies coverage it does not have:\n%s", out3) |
| + } |
| +} |
| + |
| +// An engine that is wired but not answering is its own state, distinct from |
| +// both "no engine" and "switched off". |
| +func TestPrintSystemAntivirusReportsUnreachableEngine(t *testing.T) { |
| + var b bytes.Buffer |
| + printSystemAntivirus(&b, apiSystemAntivirusReply{ |
| + Wired: true, EngineError: "dial tcp scanner:3310: connection refused", Enabled: true, |
| + }) |
| + out := b.String() |
| + if !strings.Contains(out, "not answering") { |
| + t.Fatalf("an unreachable engine was not reported:\n%s", out) |
| + } |
| + // The policy half still reports, because it is still true. |
| + if !strings.Contains(out, "scanning is ON") { |
| + t.Fatalf("an engine failure hid the recorded policy:\n%s", out) |
| + } |
| +} |
| + |
| +// A record the console could not read is not "off". Reporting it as off would |
| +// be a guess, on the one command an operator runs to find out what is true. |
| +func TestPrintSystemAntivirusReportsUnreadableRecord(t *testing.T) { |
| + var b bytes.Buffer |
| + printSystemAntivirus(&b, apiSystemAntivirusReply{ |
| + Wired: true, EngineWired: "clamav", RecordError: "schema 99 is not 1", |
| + }) |
| + out := b.String() |
| + if !strings.Contains(out, "could not be read") { |
| + t.Fatalf("an unreadable record was not reported:\n%s", out) |
| + } |
| + if strings.Contains(out, "scanning is OFF") { |
| + t.Fatalf("an unreadable record was reported as off:\n%s", out) |
| + } |
| +} |
| + |
| +// Stale definitions are called out here too, not only in store scan status: a |
| +// deployment-wide view is where an operator would notice freshclam had died. |
| +func TestPrintSystemAntivirusWarnsOnStaleSignatures(t *testing.T) { |
| + old := "ClamAV 1.4.1/27000/" + time.Now().Add(-90*24*time.Hour).Format(time.ANSIC) |
| + var b bytes.Buffer |
| + printSystemAntivirus(&b, apiSystemAntivirusReply{ |
| + Wired: true, EngineWired: "clamav", Signature: old, Enabled: true, |
| + MaxSignatureAgeSeconds: int64((168 * time.Hour) / time.Second), |
| + }) |
| + if !strings.Contains(b.String(), "days old") { |
| + t.Fatalf("stale definitions were not called out:\n%s", b.String()) |
| + } |
| +} |