From fea94ae8ac4f0d851ff2fccd94da6e178520a829 Mon Sep 17 00:00:00 2001 From: Thibault Koechlin Date: Tue, 4 Aug 2026 10:47:55 +0200 Subject: [PATCH 1/7] revamp 'cscli console status' to be all-in-one --- cmd/crowdsec-cli/clicapi/capi.go | 65 +--------- cmd/crowdsec-cli/cliconsole/console.go | 118 +++++++++++++++-- cmd/crowdsec-cli/cliconsole/console_table.go | 60 ++++++++- cmd/crowdsec-cli/clipapi/papi.go | 32 +---- .../core/consolestatus/consolestatus.go | 119 ++++++++++++++++++ pkg/csconfig/api.go | 6 + pkg/csconfig/console.go | 6 + test/bats/04_capi.bats | 5 +- test/bats/09_console.bats | 43 ++++++- 9 files changed, 351 insertions(+), 103 deletions(-) create mode 100644 cmd/crowdsec-cli/core/consolestatus/consolestatus.go diff --git a/cmd/crowdsec-cli/clicapi/capi.go b/cmd/crowdsec-cli/clicapi/capi.go index e24c4cfa664..9594c9e85a8 100644 --- a/cmd/crowdsec-cli/clicapi/capi.go +++ b/cmd/crowdsec-cli/clicapi/capi.go @@ -14,6 +14,7 @@ import ( "gopkg.in/yaml.v3" "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/args" + "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/consolestatus" "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/idgen" "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/reload" "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/require" @@ -21,7 +22,6 @@ import ( "github.com/crowdsecurity/crowdsec/pkg/csconfig" "github.com/crowdsecurity/crowdsec/pkg/cwhub" "github.com/crowdsecurity/crowdsec/pkg/database" - "github.com/crowdsecurity/crowdsec/pkg/models" ) var CAPIBaseURL = "https://api.crowdsec.net/" @@ -158,61 +158,6 @@ func (cli *cliCapi) newRegisterCmd() *cobra.Command { return cmd } -type capiStatus struct { - authenticated bool - enrolled bool - subscriptionType string -} - -// queryCAPIStatus checks if the Central API is reachable, and if the credentials are correct. It then checks if the instance is enrolled in the console. -func queryCAPIStatus(ctx context.Context, db *database.Client, hub *cwhub.Hub, credURL string, login string, password string) (capiStatus, error) { - apiURL, err := url.Parse(credURL) - if err != nil { - return capiStatus{}, err - } - - itemsForAPI := hub.GetInstalledListForAPI() - - passwd := strfmt.Password(password) - - client := apiclient.NewClient(&apiclient.Config{ - MachineID: login, - Password: passwd, - URL: apiURL, - // I don't believe papi is needed to check enrollement - // PapiURL: papiURL, - VersionPrefix: "v3", - UpdateScenario: func(_ context.Context) ([]string, error) { - return itemsForAPI, nil - }, - }) - - pw := strfmt.Password(password) - - t := models.WatcherAuthRequest{ - MachineID: &login, - Password: &pw, - Scenarios: itemsForAPI, - } - - authResp, _, err := client.Auth.AuthenticateWatcher(ctx, t) - if err != nil { - return capiStatus{}, err - } - - if err := db.SaveAPICToken(ctx, authResp.Token); err != nil { - return capiStatus{}, err - } - - client.GetClient().Transport.(*apiclient.JWTTransport).Token = authResp.Token - - if client.IsEnrolled() { - return capiStatus{true, true, client.GetSubscriptionType()}, nil - } - - return capiStatus{true, false, ""}, nil -} - func (cli *cliCapi) Status(ctx context.Context, db *database.Client, out io.Writer, hub *cwhub.Hub) error { cfg := cli.cfg() @@ -225,18 +170,18 @@ func (cli *cliCapi) Status(ctx context.Context, db *database.Client, out io.Writ fmt.Fprintf(out, "Loaded credentials from %s\n", cfg.API.Server.OnlineClient.CredentialsFilePath) fmt.Fprintf(out, "Trying to authenticate with username %s on %s\n", cred.Login, cred.URL) - status, err := queryCAPIStatus(ctx, db, hub, cred.URL, cred.Login, cred.Password) + status, err := consolestatus.QueryCAPIStatus(ctx, db, hub, cred.URL, cred.Login, cred.Password) if err != nil { return fmt.Errorf("failed to authenticate to Central API (CAPI): %w", err) } - if status.authenticated { + if status.Authenticated { fmt.Fprint(out, "You can successfully interact with Central API (CAPI)\n") } - if status.enrolled { + if status.Enrolled { fmt.Fprint(out, "Your instance is enrolled in the console\n") - fmt.Fprintf(out, "Subscription type: %s\n", status.subscriptionType) + fmt.Fprintf(out, "Subscription type: %s\n", status.SubscriptionType) } switch *cfg.API.Server.OnlineClient.Sharing { diff --git a/cmd/crowdsec-cli/cliconsole/console.go b/cmd/crowdsec-cli/cliconsole/console.go index 70c4e783d12..ca2d830da5d 100644 --- a/cmd/crowdsec-cli/cliconsole/console.go +++ b/cmd/crowdsec-cli/cliconsole/console.go @@ -23,6 +23,7 @@ import ( "github.com/crowdsecurity/go-cs-lib/slicetools" "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/args" + "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/consolestatus" "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/reload" "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/require" "github.com/crowdsecurity/crowdsec/pkg/apiclient" @@ -344,6 +345,80 @@ Disable given information push to the central API.`, return cmd } +// liveConsoleStatus is the current link between this engine and the console, fetched from +// CAPI/PAPI. +type liveConsoleStatus struct { + capi consolestatus.CAPIStatus + registered bool + reachable bool + decisionManagement bool + papi *consolestatus.PAPIInfo +} + +// fetchConsoleStatus queries CAPI (and PAPI when enrolled) for the live console link. +func (cli *cliConsole) fetchConsoleStatus(ctx context.Context, cfg *csconfig.Config) liveConsoleStatus { + st := liveConsoleStatus{} + + online := cfg.API.Server.OnlineClient + + // Credentials are loaded here (not at config time) so that a missing or invalid + // credentials file degrades to a "not registered" status instead of aborting the command. + if online == nil || online.CredentialsFilePath == "" { + return st + } + + if err := online.Load(); err != nil { + log.Warnf("could not load CAPI credentials: %s", err) + return st + } + + // blank or incomplete credentials: the engine was never registered against CAPI + if online.Credentials == nil { + return st + } + + st.registered = true + + hub, err := require.Hub(cfg, nil) + if err != nil { + log.Warnf("could not load hub, skipping live console status: %s", err) + return st + } + + db, err := require.DBClient(ctx, cfg.DbConfig) + if err != nil { + log.Warnf("could not connect to database, skipping live console status: %s", err) + return st + } + + cred := online.Credentials + + capi, err := consolestatus.QueryCAPIStatus(ctx, db, hub, cred.URL, cred.Login, cred.Password) + if err != nil { + log.Warnf("could not reach Central API (CAPI): %s", err) + return st + } + + st.capi = capi + st.reachable = true + + if !capi.Enrolled { + return st + } + + st.decisionManagement = consolestatus.DecisionManagementActive(capi.SubscriptionType) + + papi, err := consolestatus.QueryPAPIInfo(ctx, cfg.API.Server, db) + if err != nil { + log.Debugf("could not reach Polling API (PAPI): %s", err) + return st + } + + st.papi = &papi + + return st +} + func (cli *cliConsole) newStatusCmd() *cobra.Command { cmd := &cobra.Command{ Use: "status", @@ -351,20 +426,48 @@ func (cli *cliConsole) newStatusCmd() *cobra.Command { Example: `sudo cscli console status`, Args: args.NoArgs, DisableAutoGenTag: true, - RunE: func(_ *cobra.Command, _ []string) error { + // Unlike the other console subcommands, status must run even when the engine is not + // registered against CAPI or can't reach it, so it can report that state instead of + // erroring out. We skip loading online credentials here (they're loaded best-effort in + // fetchConsoleStatus) so a missing/invalid credentials file doesn't abort the command. + // This overrides the parent's stricter PersistentPreRunE. + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + return require.LAPINoOnlineCreds(cli.cfg()) + }, + RunE: func(cmd *cobra.Command, _ []string) error { cfg := cli.cfg() + ctx := cmd.Context() consoleCfg := cfg.API.Server.ConsoleConfig switch cfg.Cscli.Output { case "human": + st := cli.fetchConsoleStatus(ctx, cfg) + cmdConsoleConnectionTable(color.Output, cfg.Cscli.Color, st) cmdConsoleStatusTable(color.Output, cfg.Cscli.Color, *consoleCfg) case "json": - out := map[string]*bool{ - csconfig.SEND_MANUAL_SCENARIOS: consoleCfg.ShareManualDecisions, - csconfig.SEND_CUSTOM_SCENARIOS: consoleCfg.ShareCustomScenarios, - csconfig.SEND_TAINTED_SCENARIOS: consoleCfg.ShareTaintedScenarios, - csconfig.SEND_CONTEXT: consoleCfg.ShareContext, - csconfig.CONSOLE_MANAGEMENT: consoleCfg.ConsoleManagement, + st := cli.fetchConsoleStatus(ctx, cfg) + + console := map[string]any{ + "registered": st.registered, + "authenticated": st.reachable, + "enrolled": st.capi.Enrolled, + "plan": st.capi.SubscriptionType, + "decision_management": st.decisionManagement, + } + + if st.papi != nil { + console["last_order_received"] = st.papi.LastOrder + console["papi_categories"] = st.papi.Categories + } + + out := map[string]any{ + "sharing_options": map[string]*bool{ + csconfig.SEND_MANUAL_SCENARIOS: consoleCfg.ShareManualDecisions, + csconfig.SEND_CUSTOM_SCENARIOS: consoleCfg.ShareCustomScenarios, + csconfig.SEND_TAINTED_SCENARIOS: consoleCfg.ShareTaintedScenarios, + csconfig.SEND_CONTEXT: consoleCfg.ShareContext, + }, + "console": console, } data, err := json.MarshalIndent(out, "", " ") @@ -386,7 +489,6 @@ func (cli *cliConsole) newStatusCmd() *cobra.Command { {csconfig.SEND_CUSTOM_SCENARIOS, strconv.FormatBool(*consoleCfg.ShareCustomScenarios)}, {csconfig.SEND_TAINTED_SCENARIOS, strconv.FormatBool(*consoleCfg.ShareTaintedScenarios)}, {csconfig.SEND_CONTEXT, strconv.FormatBool(*consoleCfg.ShareContext)}, - {csconfig.CONSOLE_MANAGEMENT, strconv.FormatBool(*consoleCfg.ConsoleManagement)}, } for _, row := range rows { err = csvwriter.Write(row) diff --git a/cmd/crowdsec-cli/cliconsole/console_table.go b/cmd/crowdsec-cli/cliconsole/console_table.go index f957e1ec81e..419d62825cf 100644 --- a/cmd/crowdsec-cli/cliconsole/console_table.go +++ b/cmd/crowdsec-cli/cliconsole/console_table.go @@ -2,6 +2,7 @@ package cliconsole import ( "io" + "strings" "github.com/jedib0t/go-pretty/v6/text" @@ -10,6 +11,59 @@ import ( "github.com/crowdsecurity/crowdsec/pkg/emoji" ) +// cmdConsoleConnectionTable renders the live link to the console: enrollment, plan and the +// real decision-management state (derived from the plan, not the console_management flag). +func cmdConsoleConnectionTable(out io.Writer, wantColor string, st liveConsoleStatus) { + t := cstable.New(out, wantColor) + t.SetRowLines(false) + t.SetHeaders("Console connection", "") + t.SetHeaderAlignment(text.AlignLeft, text.AlignLeft) + + if !st.registered { + t.AddRow("Central API (CAPI)", emoji.CrossMark+" not registered, see 'cscli capi register'") + t.Render() + + return + } + + if !st.reachable { + t.AddRow("Central API (CAPI)", emoji.CrossMark+" unreachable - showing local options only") + t.Render() + + return + } + + t.AddRow("Central API (CAPI)", emoji.CheckMarkButton+" authenticated") + + if !st.capi.Enrolled { + t.AddRow("Enrolled", emoji.CrossMark+" not enrolled") + t.Render() + return + } else { + t.AddRow("Enrolled", emoji.CheckMarkButton+" enrolled") + } + + t.AddRow("Plan", st.capi.SubscriptionType) + + if st.decisionManagement { + t.AddRow("Decision management", emoji.CheckMarkButton+" active") + } else { + t.AddRow("Decision management", emoji.CrossMark+" inactive (requires SECOPS or ENTERPRISE plan)") + } + + if st.papi != nil { + t.AddRow("Last order received", st.papi.LastOrder) + if len(st.papi.Categories) > 0 { + t.AddRow("PAPI subscriptions", strings.Join(st.papi.Categories, ", ")) + } + } + + t.Render() +} + +// cmdConsoleStatusTable renders the sharing options (what the engine forwards to the +// console). console_management is intentionally excluded here — its real state is shown +// by cmdConsoleConnectionTable as "Decision management". func cmdConsoleStatusTable(out io.Writer, wantColor string, consoleCfg csconfig.ConsoleConfig) { t := cstable.New(out, wantColor) t.SetRowLines(false) @@ -17,7 +71,7 @@ func cmdConsoleStatusTable(out io.Writer, wantColor string, consoleCfg csconfig. t.SetHeaders("Option Name", "Activated", "Description") t.SetHeaderAlignment(text.AlignLeft, text.AlignLeft, text.AlignLeft) - for _, option := range csconfig.CONSOLE_CONFIGS { + for _, option := range csconfig.CONSOLE_SHARE_CONFIGS { activated := emoji.CrossMark switch option { @@ -37,10 +91,6 @@ func cmdConsoleStatusTable(out io.Writer, wantColor string, consoleCfg csconfig. if *consoleCfg.ShareContext { activated = emoji.CheckMarkButton } - case csconfig.CONSOLE_MANAGEMENT: - if *consoleCfg.ConsoleManagement { - activated = emoji.CheckMarkButton - } } t.AddRow(option, activated, csconfig.CONSOLE_CONFIGS_HELP[option]) diff --git a/cmd/crowdsec-cli/clipapi/papi.go b/cmd/crowdsec-cli/clipapi/papi.go index bbd8c122744..3ad9951acef 100644 --- a/cmd/crowdsec-cli/clipapi/papi.go +++ b/cmd/crowdsec-cli/clipapi/papi.go @@ -12,6 +12,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/args" + "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/consolestatus" "github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/core/require" "github.com/crowdsecurity/crowdsec/pkg/apiserver" "github.com/crowdsecurity/crowdsec/pkg/csconfig" @@ -59,38 +60,17 @@ func (cli *cliPapi) NewCommand() *cobra.Command { func (cli *cliPapi) Status(ctx context.Context, out io.Writer, db *database.Client) error { cfg := cli.cfg() - apic, err := apiserver.NewAPIC(ctx, cfg.API.Server.OnlineClient, db, cfg.API.Server.ConsoleConfig, cfg.API.Server.CapiWhitelists) - if err != nil { - return fmt.Errorf("unable to initialize API client: %w", err) - } - - papiLogger := cfg.API.Server.NewPAPILogger() - papi, err := apiserver.NewPAPI(apic, db, cfg.API.Server.ConsoleConfig, papiLogger) - if err != nil { - return fmt.Errorf("unable to initialize PAPI client: %w", err) - } - - perms, err := papi.GetPermissions(ctx) - if err != nil { - return fmt.Errorf("unable to get PAPI permissions: %w", err) - } - - lastTimestampStr, err := db.GetConfigItem(ctx, apiserver.PapiPullKey) + info, err := consolestatus.QueryPAPIInfo(ctx, cfg.API.Server, db) if err != nil { - lastTimestampStr = "never" - } - - // both can and did happen - if lastTimestampStr == "" || lastTimestampStr == "0001-01-01T00:00:00Z" { - lastTimestampStr = "never" + return err } fmt.Fprint(out, "You can successfully interact with Polling API (PAPI)\n") - fmt.Fprintf(out, "Console plan: %s\n", perms.Plan) - fmt.Fprintf(out, "Last order received: %s\n", lastTimestampStr) + fmt.Fprintf(out, "Console plan: %s\n", info.Plan) + fmt.Fprintf(out, "Last order received: %s\n", info.LastOrder) fmt.Fprint(out, "PAPI subscriptions:\n") - for _, sub := range perms.Categories { + for _, sub := range info.Categories { fmt.Fprintf(out, " - %s\n", sub) } diff --git a/cmd/crowdsec-cli/core/consolestatus/consolestatus.go b/cmd/crowdsec-cli/core/consolestatus/consolestatus.go new file mode 100644 index 00000000000..083aec4fc67 --- /dev/null +++ b/cmd/crowdsec-cli/core/consolestatus/consolestatus.go @@ -0,0 +1,119 @@ +// Package consolestatus fetches the live state of an engine's link to the CrowdSec +// console (CAPI/PAPI): enrollment, plan, and decision-management state. It is shared +// by the capi/papi/console status commands so they report the same thing. +package consolestatus + +import ( + "context" + "fmt" + "net/url" + + "github.com/go-openapi/strfmt" + + "github.com/crowdsecurity/crowdsec/pkg/apiclient" + "github.com/crowdsecurity/crowdsec/pkg/apiserver" + "github.com/crowdsecurity/crowdsec/pkg/csconfig" + "github.com/crowdsecurity/crowdsec/pkg/cwhub" + "github.com/crowdsecurity/crowdsec/pkg/database" + "github.com/crowdsecurity/crowdsec/pkg/models" +) + +// CAPIStatus is what an authentication round-trip to CAPI tells us about the engine. +type CAPIStatus struct { + Authenticated bool + Enrolled bool + SubscriptionType string +} + +// PAPIInfo is the plan detail exposed by the PAPI /permissions endpoint plus the +// timestamp of the last order the engine pulled. +type PAPIInfo struct { + Plan string + Categories []string + LastOrder string +} + +// DecisionManagementActive reports whether the console actually pushes decisions to this +// engine. This is driven by the plan (from the CAPI JWT), not by the console_management +// config flag, which no longer gates PAPI — see the subscription gating in pkg/apiserver/papi.go. +func DecisionManagementActive(subscriptionType string) bool { + return subscriptionType == apiclient.SubscriptionTypeEnterprise || + subscriptionType == apiclient.SubscriptionTypeSecOps +} + +// QueryCAPIStatus authenticates against the Central API to check the credentials are +// valid, then reads enrollment and subscription type from the returned JWT. +func QueryCAPIStatus(ctx context.Context, db *database.Client, hub *cwhub.Hub, credURL string, login string, password string) (CAPIStatus, error) { + apiURL, err := url.Parse(credURL) + if err != nil { + return CAPIStatus{}, err + } + + itemsForAPI := hub.GetInstalledListForAPI() + + passwd := strfmt.Password(password) + + client := apiclient.NewClient(&apiclient.Config{ + MachineID: login, + Password: passwd, + URL: apiURL, + VersionPrefix: "v3", + UpdateScenario: func(_ context.Context) ([]string, error) { + return itemsForAPI, nil + }, + }) + + t := models.WatcherAuthRequest{ + MachineID: &login, + Password: &passwd, + Scenarios: itemsForAPI, + } + + authResp, _, err := client.Auth.AuthenticateWatcher(ctx, t) + if err != nil { + return CAPIStatus{}, err + } + + if err := db.SaveAPICToken(ctx, authResp.Token); err != nil { + return CAPIStatus{}, err + } + + client.GetClient().Transport.(*apiclient.JWTTransport).Token = authResp.Token + + if client.IsEnrolled() { + return CAPIStatus{Authenticated: true, Enrolled: true, SubscriptionType: client.GetSubscriptionType()}, nil + } + + return CAPIStatus{Authenticated: true}, nil +} + +// QueryPAPIInfo asks the Polling API for the plan/categories the engine is entitled to +// and looks up when it last pulled an order. +func QueryPAPIInfo(ctx context.Context, serverCfg *csconfig.LocalApiServerCfg, db *database.Client) (PAPIInfo, error) { + apic, err := apiserver.NewAPIC(ctx, serverCfg.OnlineClient, db, serverCfg.ConsoleConfig, serverCfg.CapiWhitelists) + if err != nil { + return PAPIInfo{}, fmt.Errorf("unable to initialize API client: %w", err) + } + + papi, err := apiserver.NewPAPI(apic, db, serverCfg.ConsoleConfig, serverCfg.NewPAPILogger()) + if err != nil { + return PAPIInfo{}, fmt.Errorf("unable to initialize PAPI client: %w", err) + } + + perms, err := papi.GetPermissions(ctx) + if err != nil { + return PAPIInfo{}, fmt.Errorf("unable to get PAPI permissions: %w", err) + } + + lastOrder, err := db.GetConfigItem(ctx, apiserver.PapiPullKey) + if err != nil { + lastOrder = "never" + } + + // both can and did happen + if lastOrder == "" || lastOrder == "0001-01-01T00:00:00Z" { + lastOrder = "never" + } + + return PAPIInfo{Plan: perms.Plan, Categories: perms.Categories, LastOrder: lastOrder}, nil +} diff --git a/pkg/csconfig/api.go b/pkg/csconfig/api.go index cb3ef8066b1..01ac66ee9c7 100644 --- a/pkg/csconfig/api.go +++ b/pkg/csconfig/api.go @@ -118,6 +118,12 @@ func (o *OnlineApiClientCfg) Load() error { } switch { + case o.Credentials.Login == "" && o.Credentials.Password == "" && o.Credentials.URL == "": + // An empty credentials file just means the engine was never registered against CAPI. + // That is a normal state (cscli console/capi status report it and point to 'capi + // register'), so it should not look like a misconfiguration. + log.Debugf("no CAPI credentials found in '%s', engine is not registered", o.CredentialsFilePath) + o.Credentials = nil case o.Credentials.Login == "": log.Warningf("can't load CAPI credentials from '%s' (missing login field)", o.CredentialsFilePath) o.Credentials = nil diff --git a/pkg/csconfig/console.go b/pkg/csconfig/console.go index ee12efe1658..4afeb188139 100644 --- a/pkg/csconfig/console.go +++ b/pkg/csconfig/console.go @@ -17,6 +17,12 @@ const ( ) var CONSOLE_CONFIGS = []string{SEND_CUSTOM_SCENARIOS, SEND_MANUAL_SCENARIOS, SEND_TAINTED_SCENARIOS, SEND_CONTEXT, CONSOLE_MANAGEMENT} + +// CONSOLE_SHARE_CONFIGS is the subset of options that control what the engine forwards to +// the console. It excludes console_management (decision management), whose real state is +// derived from the plan and reported separately by `cscli console status`. +var CONSOLE_SHARE_CONFIGS = []string{SEND_CUSTOM_SCENARIOS, SEND_MANUAL_SCENARIOS, SEND_TAINTED_SCENARIOS, SEND_CONTEXT} + var CONSOLE_CONFIGS_HELP = map[string]string{ SEND_CUSTOM_SCENARIOS: "Forward alerts from custom scenarios to the console", SEND_MANUAL_SCENARIOS: "Forward manual decisions to the console", diff --git a/test/bats/04_capi.bats b/test/bats/04_capi.bats index 72217dda618..548f364cdaf 100644 --- a/test/bats/04_capi.bats +++ b/test/bats/04_capi.bats @@ -44,9 +44,12 @@ setup() { rune -1 cscli capi status assert_stderr --partial "can't load CAPI credentials from '$ONLINE_API_CREDENTIALS_YAML' (missing password field)" + # with every field removed, the engine is simply not registered: this is a normal + # state, so no scary warning - just a clear message pointing to 'capi register' config_set "$ONLINE_API_CREDENTIALS_YAML" 'del(.login)' rune -1 cscli capi status - assert_stderr --partial "can't load CAPI credentials from '$ONLINE_API_CREDENTIALS_YAML' (missing login field)" + refute_stderr --partial "missing login field" + assert_stderr --partial "the Central API (CAPI) must be configured with 'cscli capi register'" rm "${ONLINE_API_CREDENTIALS_YAML}" rune -1 cscli capi status diff --git a/test/bats/09_console.bats b/test/bats/09_console.bats index d203c933e0e..ae815ce1a44 100644 --- a/test/bats/09_console.bats +++ b/test/bats/09_console.bats @@ -26,7 +26,11 @@ setup() { #---------- @test "cscli console status" { + # credentials point to CAPI with dummy login/password, so the live console section + # falls back gracefully (unreachable) while the sharing options are always shown. rune -0 cscli console status + assert_output --partial "Console connection" + assert_output --partial "Central API (CAPI)" assert_output --partial "Option Name" assert_output --partial "Activated" assert_output --partial "Description" @@ -34,16 +38,25 @@ setup() { assert_output --partial "manual" assert_output --partial "tainted" assert_output --partial "context" - assert_output --partial "console_management" + # decision management is no longer a sharing option + refute_output --partial "console_management" rune -0 cscli console status -o json assert_json - <<- EOT { - "console_management": false, + "console": { + "registered": true, + "authenticated": false, + "decision_management": false, + "enrolled": false, + "plan": "" + }, + "sharing_options": { "context": false, "custom": true, "manual": false, "tainted": true } + } EOT rune -0 cscli console status -o raw assert_output - <<-EOT @@ -52,10 +65,34 @@ setup() { custom,true tainted,true context,false - console_management,false EOT } +@test "cscli console status: not registered" { + # blank credentials -> the engine is not registered against CAPI; status must still + # succeed, report the state, and show the sharing options instead of erroring out. + creds=$(config_get '.api.server.online_client.credentials_path') + echo "" > "$creds" + rune -0 cscli console status + assert_output --partial "cscli capi register" + assert_output --partial "custom" + rune -0 cscli console status -o json + rune -0 jq -r '.console.registered' <(output) + assert_output "false" +} + +@test "cscli console status: missing credentials file does not error" { + # a missing credentials file must degrade to the same table, not a hard error + creds=$(config_get '.api.server.online_client.credentials_path') + rm -f "$creds" + rune -0 cscli console status + assert_output --partial "cscli capi register" + assert_output --partial "custom" + rune -0 cscli console status -o json + rune -0 jq -r '.console.registered' <(output) + assert_output "false" +} + @test "cscli console enable" { rune -0 cscli console enable manual --debug assert_stderr --partial "manual set to true" From 6f5637c1da99e8e9d9be7bc27cf438bda8ff1917 Mon Sep 17 00:00:00 2001 From: Thibault Koechlin Date: Tue, 4 Aug 2026 10:59:59 +0200 Subject: [PATCH 2/7] reduce comments verb --- cmd/crowdsec-cli/cliconsole/console.go | 13 ++++--------- cmd/crowdsec-cli/cliconsole/console_table.go | 6 ++---- .../core/consolestatus/consolestatus.go | 15 ++++++--------- 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/cmd/crowdsec-cli/cliconsole/console.go b/cmd/crowdsec-cli/cliconsole/console.go index ca2d830da5d..94438550c3c 100644 --- a/cmd/crowdsec-cli/cliconsole/console.go +++ b/cmd/crowdsec-cli/cliconsole/console.go @@ -345,8 +345,6 @@ Disable given information push to the central API.`, return cmd } -// liveConsoleStatus is the current link between this engine and the console, fetched from -// CAPI/PAPI. type liveConsoleStatus struct { capi consolestatus.CAPIStatus registered bool @@ -361,8 +359,7 @@ func (cli *cliConsole) fetchConsoleStatus(ctx context.Context, cfg *csconfig.Con online := cfg.API.Server.OnlineClient - // Credentials are loaded here (not at config time) so that a missing or invalid - // credentials file degrades to a "not registered" status instead of aborting the command. + // load credz here to gracefully handle missing/invalid file. if online == nil || online.CredentialsFilePath == "" { return st } @@ -372,7 +369,6 @@ func (cli *cliConsole) fetchConsoleStatus(ctx context.Context, cfg *csconfig.Con return st } - // blank or incomplete credentials: the engine was never registered against CAPI if online.Credentials == nil { return st } @@ -427,10 +423,9 @@ func (cli *cliConsole) newStatusCmd() *cobra.Command { Args: args.NoArgs, DisableAutoGenTag: true, // Unlike the other console subcommands, status must run even when the engine is not - // registered against CAPI or can't reach it, so it can report that state instead of - // erroring out. We skip loading online credentials here (they're loaded best-effort in - // fetchConsoleStatus) so a missing/invalid credentials file doesn't abort the command. - // This overrides the parent's stricter PersistentPreRunE. + // registered against CAPI or can't reach it. + // We skip loading online credentials here (they're loaded best-effort in + // fetchConsoleStatus). This overrides the parent's stricter PersistentPreRunE. PersistentPreRunE: func(_ *cobra.Command, _ []string) error { return require.LAPINoOnlineCreds(cli.cfg()) }, diff --git a/cmd/crowdsec-cli/cliconsole/console_table.go b/cmd/crowdsec-cli/cliconsole/console_table.go index 419d62825cf..bf4d832e8c9 100644 --- a/cmd/crowdsec-cli/cliconsole/console_table.go +++ b/cmd/crowdsec-cli/cliconsole/console_table.go @@ -12,7 +12,7 @@ import ( ) // cmdConsoleConnectionTable renders the live link to the console: enrollment, plan and the -// real decision-management state (derived from the plan, not the console_management flag). +// real decision-management state. func cmdConsoleConnectionTable(out io.Writer, wantColor string, st liveConsoleStatus) { t := cstable.New(out, wantColor) t.SetRowLines(false) @@ -61,9 +61,7 @@ func cmdConsoleConnectionTable(out io.Writer, wantColor string, st liveConsoleSt t.Render() } -// cmdConsoleStatusTable renders the sharing options (what the engine forwards to the -// console). console_management is intentionally excluded here — its real state is shown -// by cmdConsoleConnectionTable as "Decision management". +// cmdConsoleStatusTable renders the sharing options. func cmdConsoleStatusTable(out io.Writer, wantColor string, consoleCfg csconfig.ConsoleConfig) { t := cstable.New(out, wantColor) t.SetRowLines(false) diff --git a/cmd/crowdsec-cli/core/consolestatus/consolestatus.go b/cmd/crowdsec-cli/core/consolestatus/consolestatus.go index 083aec4fc67..88a8087f055 100644 --- a/cmd/crowdsec-cli/core/consolestatus/consolestatus.go +++ b/cmd/crowdsec-cli/core/consolestatus/consolestatus.go @@ -1,6 +1,5 @@ // Package consolestatus fetches the live state of an engine's link to the CrowdSec -// console (CAPI/PAPI): enrollment, plan, and decision-management state. It is shared -// by the capi/papi/console status commands so they report the same thing. +// console (CAPI/PAPI): enrollment, plan, and decision-management state. package consolestatus import ( @@ -33,16 +32,15 @@ type PAPIInfo struct { LastOrder string } -// DecisionManagementActive reports whether the console actually pushes decisions to this -// engine. This is driven by the plan (from the CAPI JWT), not by the console_management -// config flag, which no longer gates PAPI — see the subscription gating in pkg/apiserver/papi.go. +// DecisionManagementActive reports whether the console pushes decisions to this +// engine, from PAPI. func DecisionManagementActive(subscriptionType string) bool { return subscriptionType == apiclient.SubscriptionTypeEnterprise || subscriptionType == apiclient.SubscriptionTypeSecOps } -// QueryCAPIStatus authenticates against the Central API to check the credentials are -// valid, then reads enrollment and subscription type from the returned JWT. +// QueryCAPIStatus authenticates against the Central API +// and reads enrollment and subscription type from the returned JWT. func QueryCAPIStatus(ctx context.Context, db *database.Client, hub *cwhub.Hub, credURL string, login string, password string) (CAPIStatus, error) { apiURL, err := url.Parse(credURL) if err != nil { @@ -87,8 +85,7 @@ func QueryCAPIStatus(ctx context.Context, db *database.Client, hub *cwhub.Hub, c return CAPIStatus{Authenticated: true}, nil } -// QueryPAPIInfo asks the Polling API for the plan/categories the engine is entitled to -// and looks up when it last pulled an order. +// QueryPAPIInfo asks PAPI for the plan/categories the engine is entitled to func QueryPAPIInfo(ctx context.Context, serverCfg *csconfig.LocalApiServerCfg, db *database.Client) (PAPIInfo, error) { apic, err := apiserver.NewAPIC(ctx, serverCfg.OnlineClient, db, serverCfg.ConsoleConfig, serverCfg.CapiWhitelists) if err != nil { From e9ddd0af29c89c2c568732d2a288341f0c55043d Mon Sep 17 00:00:00 2001 From: Thibault Koechlin Date: Tue, 4 Aug 2026 11:00:42 +0200 Subject: [PATCH 3/7] reduce comments verb --- pkg/csconfig/api.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/csconfig/api.go b/pkg/csconfig/api.go index 01ac66ee9c7..9e25e93aa50 100644 --- a/pkg/csconfig/api.go +++ b/pkg/csconfig/api.go @@ -120,8 +120,6 @@ func (o *OnlineApiClientCfg) Load() error { switch { case o.Credentials.Login == "" && o.Credentials.Password == "" && o.Credentials.URL == "": // An empty credentials file just means the engine was never registered against CAPI. - // That is a normal state (cscli console/capi status report it and point to 'capi - // register'), so it should not look like a misconfiguration. log.Debugf("no CAPI credentials found in '%s', engine is not registered", o.CredentialsFilePath) o.Credentials = nil case o.Credentials.Login == "": From 98b77233cbe6915e5fcf0a54e6d4a342e534a5b7 Mon Sep 17 00:00:00 2001 From: Thibault Koechlin Date: Tue, 4 Aug 2026 11:02:02 +0200 Subject: [PATCH 4/7] comments --- pkg/csconfig/console.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/csconfig/console.go b/pkg/csconfig/console.go index 4afeb188139..d047c48d01f 100644 --- a/pkg/csconfig/console.go +++ b/pkg/csconfig/console.go @@ -19,7 +19,7 @@ const ( var CONSOLE_CONFIGS = []string{SEND_CUSTOM_SCENARIOS, SEND_MANUAL_SCENARIOS, SEND_TAINTED_SCENARIOS, SEND_CONTEXT, CONSOLE_MANAGEMENT} // CONSOLE_SHARE_CONFIGS is the subset of options that control what the engine forwards to -// the console. It excludes console_management (decision management), whose real state is +// the console. It excludes console_management (decision management), whose state is // derived from the plan and reported separately by `cscli console status`. var CONSOLE_SHARE_CONFIGS = []string{SEND_CUSTOM_SCENARIOS, SEND_MANUAL_SCENARIOS, SEND_TAINTED_SCENARIOS, SEND_CONTEXT} From e23c18f84ef846e89f1378e5bf4532874b658edf Mon Sep 17 00:00:00 2001 From: Thibault Koechlin Date: Tue, 4 Aug 2026 11:34:08 +0200 Subject: [PATCH 5/7] kill decision management flag --- build/docker/README.md | 2 +- build/docker/docker_start.sh | 2 +- cmd/crowdsec-cli/cliconsole/console.go | 45 ++++++-------------- cmd/crowdsec-cli/cliconsole/console_table.go | 2 +- pkg/apiserver/apiserver.go | 4 +- pkg/apiserver/papi.go | 2 +- pkg/csconfig/api_test.go | 1 - pkg/csconfig/console.go | 32 +++----------- test/bats/04_capi.bats | 7 +-- test/bats/09_console.bats | 10 ++++- 10 files changed, 32 insertions(+), 75 deletions(-) diff --git a/build/docker/README.md b/build/docker/README.md index e8a55bce7b7..e9f15e7060e 100644 --- a/build/docker/README.md +++ b/build/docker/README.md @@ -304,7 +304,7 @@ config.yaml) each time the container is run. | `ENROLL_INSTANCE_NAME` | | To set an instance name and see it on [the console](https://app.crowdsec.net/) | | `ENROLL_TAGS` | | Tags of the enrolled instance, for search and filter | | `ENABLE_CONSOLE_ALL` | | Enable all console options | -| `ENABLE_CONSOLE_MANAGEMENT` | | Enable console management (this option is ignored since 1.6.9) | +| `ENABLE_CONSOLE_MANAGEMENT` | | Deprecated and ignored: decision management is enabled automatically based on your console plan | | `ENABLE_CONSOLE_CONTEXT` | | Send alert context to the console (automatically enabled for enrolled instances) | | `ENABLE_CONSOLE_TAINTED` | | Send tainted alerts (from modified scenarios) to the console (automatically enabled for enrolled instances) | | `ENABLE_CONSOLE_MANUAL` | | Send manual alerts (`cscli decisions add`) to the console (automatically enabled for enrolled instances) | diff --git a/build/docker/docker_start.sh b/build/docker/docker_start.sh index 7566247c9c3..9939c6ad308 100755 --- a/build/docker/docker_start.sh +++ b/build/docker/docker_start.sh @@ -483,7 +483,7 @@ if [ "$ENABLE_CONSOLE_ALL" != "" ]; then else CONSOLE_FLAGS="" if [ "$ENABLE_CONSOLE_MANAGEMENT" != "" ]; then - CONSOLE_FLAGS="$CONSOLE_FLAGS console_management" + echo "WARNING: ENABLE_CONSOLE_MANAGEMENT is deprecated and ignored; decision management is enabled automatically based on your console plan." >&2 fi if [ "$ENABLE_CONSOLE_CONTEXT" != "" ]; then CONSOLE_FLAGS="$CONSOLE_FLAGS context" diff --git a/cmd/crowdsec-cli/cliconsole/console.go b/cmd/crowdsec-cli/cliconsole/console.go index 94438550c3c..67cf8fbf7f7 100644 --- a/cmd/crowdsec-cli/cliconsole/console.go +++ b/cmd/crowdsec-cli/cliconsole/console.go @@ -141,6 +141,11 @@ func optionFilterEnable(opts []string, enableOpts []string) ([]string, error) { continue } + if opt == csconfig.CONSOLE_MANAGEMENT { + log.Warnf("'%s' is deprecated and has no effect: decision management is enabled automatically based on your console plan", csconfig.CONSOLE_MANAGEMENT) + continue + } + if !slices.Contains(csconfig.CONSOLE_CONFIGS, opt) { return nil, fmt.Errorf("option %s doesn't exist", opt) } @@ -165,6 +170,11 @@ func optionFilterDisable(opts []string, disableOpts []string) ([]string, error) continue } + if opt == csconfig.CONSOLE_MANAGEMENT { + log.Warnf("'%s' is deprecated and has no effect: decision management is enabled automatically based on your console plan", csconfig.CONSOLE_MANAGEMENT) + continue + } + if !slices.Contains(csconfig.CONSOLE_CONFIGS, opt) { return nil, fmt.Errorf("option %s doesn't exist", opt) } @@ -217,7 +227,6 @@ cscli console enroll --quick cscli console enroll --quick --name [instance_name] cscli console enroll --name [instance_name] YOUR-ENROLL-KEY cscli console enroll --name [instance_name] --tags [tag_1] --tags [tag_2] YOUR-ENROLL-KEY -cscli console enroll --enable console_management YOUR-ENROLL-KEY cscli console enroll --disable context YOUR-ENROLL-KEY valid options are : %s,all (see 'cscli console status' for details)`, strings.Join(csconfig.CONSOLE_CONFIGS, ",")), @@ -529,38 +538,8 @@ func (cli *cliConsole) setConsoleOpts(args []string, wanted bool) error { for _, arg := range args { switch arg { case csconfig.CONSOLE_MANAGEMENT: - // for each flag check if it's already set before setting it - if consoleCfg.ConsoleManagement != nil && *consoleCfg.ConsoleManagement == wanted { - log.Debugf("%s already set to %t", csconfig.CONSOLE_MANAGEMENT, wanted) - } else { - log.Infof("%s set to %t", csconfig.CONSOLE_MANAGEMENT, wanted) - consoleCfg.ConsoleManagement = new(wanted) - } - - if cfg.API.Server.OnlineClient.Credentials != nil { - changed := false - if wanted && cfg.API.Server.OnlineClient.Credentials.PapiURL == "" { - changed = true - cfg.API.Server.OnlineClient.Credentials.PapiURL = csconfig.PAPIBaseURL - } else if !wanted && cfg.API.Server.OnlineClient.Credentials.PapiURL != "" { - changed = true - cfg.API.Server.OnlineClient.Credentials.PapiURL = "" - } - - if changed { - fileContent, err := yaml.Marshal(cfg.API.Server.OnlineClient.Credentials) - if err != nil { - return fmt.Errorf("cannot serialize credentials: %w", err) - } - - log.Infof("Updating credentials file: %s", cfg.API.Server.OnlineClient.CredentialsFilePath) - - err = os.WriteFile(cfg.API.Server.OnlineClient.CredentialsFilePath, fileContent, 0o600) - if err != nil { - return fmt.Errorf("cannot write credentials file: %w", err) - } - } - } + // deprecated no-op: decision management is now enabled automatically based on the plan + log.Warnf("'%s' is deprecated and has no effect: decision management is enabled automatically based on your console plan", csconfig.CONSOLE_MANAGEMENT) case csconfig.SEND_CUSTOM_SCENARIOS: // for each flag check if it's already set before setting it if consoleCfg.ShareCustomScenarios != nil && *consoleCfg.ShareCustomScenarios == wanted { diff --git a/cmd/crowdsec-cli/cliconsole/console_table.go b/cmd/crowdsec-cli/cliconsole/console_table.go index bf4d832e8c9..4a4f88c8d86 100644 --- a/cmd/crowdsec-cli/cliconsole/console_table.go +++ b/cmd/crowdsec-cli/cliconsole/console_table.go @@ -69,7 +69,7 @@ func cmdConsoleStatusTable(out io.Writer, wantColor string, consoleCfg csconfig. t.SetHeaders("Option Name", "Activated", "Description") t.SetHeaderAlignment(text.AlignLeft, text.AlignLeft, text.AlignLeft) - for _, option := range csconfig.CONSOLE_SHARE_CONFIGS { + for _, option := range csconfig.CONSOLE_CONFIGS { activated := emoji.CrossMark switch option { diff --git a/pkg/apiserver/apiserver.go b/pkg/apiserver/apiserver.go index 9d2760010ca..71d03b14513 100644 --- a/pkg/apiserver/apiserver.go +++ b/pkg/apiserver/apiserver.go @@ -307,10 +307,10 @@ func (s *APIServer) initAPIC(ctx context.Context) { return s.papiSync(ctx) }) } else { - log.Warnf("papi_url is not set in online_api_credentials.yaml, can't synchronize with the console. Run cscli console enable console_management to add it.") + log.Warnf("papi_url is not set in online_api_credentials.yaml, can't synchronize with the console") } } else { - log.Warningf("Machine is not allowed to synchronize decisions, you can enable it with `cscli console enable console_management`") + log.Warningf("Machine is not enrolled in the console, can't synchronize decisions") } } diff --git a/pkg/apiserver/papi.go b/pkg/apiserver/papi.go index 36e2c464e22..d9b75b5c0fb 100644 --- a/pkg/apiserver/papi.go +++ b/pkg/apiserver/papi.go @@ -366,7 +366,7 @@ func (p *Papi) SyncDecisions(ctx context.Context) error { go p.SendDeletedDecisions(ctx, &cacheCopy) } case deletedDecisions := <-p.Channels.DeleteDecisionChannel: - if (p.consoleConfig.ShareManualDecisions != nil && *p.consoleConfig.ShareManualDecisions) || (p.consoleConfig.ConsoleManagement != nil && *p.consoleConfig.ConsoleManagement) { + if p.consoleConfig.ShareManualDecisions != nil && *p.consoleConfig.ShareManualDecisions { var tmpDecisions []models.DecisionsDeleteRequestItem p.Logger.Debugf("%d decisions deletion to add in cache", len(deletedDecisions)) diff --git a/pkg/csconfig/api_test.go b/pkg/csconfig/api_test.go index 10617caa936..13801da16b0 100644 --- a/pkg/csconfig/api_test.go +++ b/pkg/csconfig/api_test.go @@ -203,7 +203,6 @@ func TestLoadAPIServer(t *testing.T) { ShareTaintedScenarios: new(true), ShareCustomScenarios: new(true), ShareContext: new(false), - ConsoleManagement: new(false), }, OnlineClient: &OnlineApiClientCfg{ CredentialsFilePath: "./testdata/online-api-secrets.yaml", diff --git a/pkg/csconfig/console.go b/pkg/csconfig/console.go index d047c48d01f..00f488be3bf 100644 --- a/pkg/csconfig/console.go +++ b/pkg/csconfig/console.go @@ -12,23 +12,20 @@ const ( SEND_CUSTOM_SCENARIOS = "custom" SEND_TAINTED_SCENARIOS = "tainted" SEND_MANUAL_SCENARIOS = "manual" - CONSOLE_MANAGEMENT = "console_management" SEND_CONTEXT = "context" -) -var CONSOLE_CONFIGS = []string{SEND_CUSTOM_SCENARIOS, SEND_MANUAL_SCENARIOS, SEND_TAINTED_SCENARIOS, SEND_CONTEXT, CONSOLE_MANAGEMENT} + // CONSOLE_MANAGEMENT is a deprecated option kept only so the CLI can recognize it and + // warn: decision management is now enabled automatically based on the console plan. + CONSOLE_MANAGEMENT = "console_management" +) -// CONSOLE_SHARE_CONFIGS is the subset of options that control what the engine forwards to -// the console. It excludes console_management (decision management), whose state is -// derived from the plan and reported separately by `cscli console status`. -var CONSOLE_SHARE_CONFIGS = []string{SEND_CUSTOM_SCENARIOS, SEND_MANUAL_SCENARIOS, SEND_TAINTED_SCENARIOS, SEND_CONTEXT} +var CONSOLE_CONFIGS = []string{SEND_CUSTOM_SCENARIOS, SEND_MANUAL_SCENARIOS, SEND_TAINTED_SCENARIOS, SEND_CONTEXT} var CONSOLE_CONFIGS_HELP = map[string]string{ SEND_CUSTOM_SCENARIOS: "Forward alerts from custom scenarios to the console", SEND_MANUAL_SCENARIOS: "Forward manual decisions to the console", SEND_TAINTED_SCENARIOS: "Forward alerts from tainted scenarios to the console", SEND_CONTEXT: "Forward context with alerts to the console", - CONSOLE_MANAGEMENT: "Receive decisions from console", } var DefaultConsoleConfigFilePath = DefaultConfigPath("console.yaml") @@ -37,7 +34,6 @@ type ConsoleConfig struct { ShareManualDecisions *bool `yaml:"share_manual_decisions"` ShareTaintedScenarios *bool `yaml:"share_tainted"` ShareCustomScenarios *bool `yaml:"share_custom"` - ConsoleManagement *bool `yaml:"console_management"` ShareContext *bool `yaml:"share_context"` } @@ -59,10 +55,6 @@ func (c *ConsoleConfig) EnabledOptions() []string { ret = append(ret, SEND_MANUAL_SCENARIOS) } - if c.ConsoleManagement != nil && *c.ConsoleManagement { - ret = append(ret, CONSOLE_MANAGEMENT) - } - if c.ShareContext != nil && *c.ShareContext { ret = append(ret, SEND_CONTEXT) } @@ -70,14 +62,6 @@ func (c *ConsoleConfig) EnabledOptions() []string { return ret } -func (c *ConsoleConfig) IsPAPIEnabled() bool { - if c == nil || c.ConsoleManagement == nil { - return false - } - - return *c.ConsoleManagement -} - func (c *LocalApiServerCfg) LoadConsoleConfig() error { c.ConsoleConfig = &ConsoleConfig{} if _, err := os.Stat(c.ConsoleConfigPath); err != nil && os.IsNotExist(err) { @@ -86,7 +70,6 @@ func (c *LocalApiServerCfg) LoadConsoleConfig() error { c.ConsoleConfig.ShareCustomScenarios = new(true) c.ConsoleConfig.ShareTaintedScenarios = new(true) c.ConsoleConfig.ShareManualDecisions = new(false) - c.ConsoleConfig.ConsoleManagement = new(false) c.ConsoleConfig.ShareContext = new(false) return nil @@ -117,11 +100,6 @@ func (c *LocalApiServerCfg) LoadConsoleConfig() error { c.ConsoleConfig.ShareManualDecisions = new(false) } - if c.ConsoleConfig.ConsoleManagement == nil { - log.Debugf("no console_management found, setting to false") - c.ConsoleConfig.ConsoleManagement = new(false) - } - if c.ConsoleConfig.ShareContext == nil { log.Debugf("no 'context' found, setting to false") c.ConsoleConfig.ShareContext = new(false) diff --git a/test/bats/04_capi.bats b/test/bats/04_capi.bats index 548f364cdaf..d36bfe7beef 100644 --- a/test/bats/04_capi.bats +++ b/test/bats/04_capi.bats @@ -75,12 +75,7 @@ setup() { assert_output --partial " on https://api.crowdsec.net/" assert_output --partial "You can successfully interact with Central API (CAPI)" - # For the time, PAPI is always enabled config-wise - rune -1 cscli papi status - assert_stderr --partial "unable to get PAPI permissions" - assert_stderr --partial "Forbidden for plan" - - rune -0 cscli console enable console_management + # papi_url is always set (auto-defaulted), so papi status reaches CAPI and is refused by plan rune -1 cscli papi status assert_stderr --partial "unable to get PAPI permissions" assert_stderr --partial "Forbidden for plan" diff --git a/test/bats/09_console.bats b/test/bats/09_console.bats index ae815ce1a44..98a4510bced 100644 --- a/test/bats/09_console.bats +++ b/test/bats/09_console.bats @@ -108,12 +108,19 @@ setup() { assert_stderr --partial "manual already set to true" assert_stderr --partial "tainted already set to true" assert_stderr --partial "context already set to true" - assert_stderr --partial "console_management set to true" assert_stderr --partial "All features have been enabled successfully" rune -1 cscli console enable tralala assert_stderr --partial "unknown flag tralala" } +@test "cscli console enable console_management: deprecated no-op" { + # console_management is gone but still accepted as a deprecated no-op (exit 0 + warning) + rune -0 cscli console enable console_management + assert_stderr --partial "'console_management' is deprecated" + rune -0 cscli console disable console_management + assert_stderr --partial "'console_management' is deprecated" +} + @test "cscli console disable" { rune -0 cscli console disable tainted --debug assert_stderr --partial "tainted set to false" @@ -129,7 +136,6 @@ setup() { assert_stderr --partial "manual already set to false" assert_stderr --partial "tainted already set to false" assert_stderr --partial "context already set to false" - assert_stderr --partial "console_management already set to false" assert_stderr --partial "All features have been disabled" rune -1 cscli console disable tralala assert_stderr --partial "unknown flag tralala" From 4012fbd3a7e69bf27c8766cb3421c0af63557a38 Mon Sep 17 00:00:00 2001 From: Thibault Koechlin Date: Tue, 4 Aug 2026 11:50:44 +0200 Subject: [PATCH 6/7] lint --- cmd/crowdsec-cli/cliconsole/console.go | 2 +- cmd/crowdsec-cli/cliconsole/console_table.go | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/crowdsec-cli/cliconsole/console.go b/cmd/crowdsec-cli/cliconsole/console.go index 67cf8fbf7f7..76c519fcd63 100644 --- a/cmd/crowdsec-cli/cliconsole/console.go +++ b/cmd/crowdsec-cli/cliconsole/console.go @@ -363,7 +363,7 @@ type liveConsoleStatus struct { } // fetchConsoleStatus queries CAPI (and PAPI when enrolled) for the live console link. -func (cli *cliConsole) fetchConsoleStatus(ctx context.Context, cfg *csconfig.Config) liveConsoleStatus { +func (*cliConsole) fetchConsoleStatus(ctx context.Context, cfg *csconfig.Config) liveConsoleStatus { st := liveConsoleStatus{} online := cfg.API.Server.OnlineClient diff --git a/cmd/crowdsec-cli/cliconsole/console_table.go b/cmd/crowdsec-cli/cliconsole/console_table.go index 4a4f88c8d86..83cc7a648a5 100644 --- a/cmd/crowdsec-cli/cliconsole/console_table.go +++ b/cmd/crowdsec-cli/cliconsole/console_table.go @@ -39,10 +39,9 @@ func cmdConsoleConnectionTable(out io.Writer, wantColor string, st liveConsoleSt t.AddRow("Enrolled", emoji.CrossMark+" not enrolled") t.Render() return - } else { - t.AddRow("Enrolled", emoji.CheckMarkButton+" enrolled") } + t.AddRow("Enrolled", emoji.CheckMarkButton+" enrolled") t.AddRow("Plan", st.capi.SubscriptionType) if st.decisionManagement { From 6b6e28d002e380a0c5b7c9baa072d46180976a39 Mon Sep 17 00:00:00 2001 From: Thibault Koechlin Date: Tue, 4 Aug 2026 14:48:54 +0200 Subject: [PATCH 7/7] fix windows tests --- pkg/acquisition/modules/appsec/appsec_test.go | 58 ++++++------------- 1 file changed, 17 insertions(+), 41 deletions(-) diff --git a/pkg/acquisition/modules/appsec/appsec_test.go b/pkg/acquisition/modules/appsec/appsec_test.go index 97991074493..7dfce8e7419 100644 --- a/pkg/acquisition/modules/appsec/appsec_test.go +++ b/pkg/acquisition/modules/appsec/appsec_test.go @@ -5,7 +5,6 @@ import ( "net/http/httptest" "net/url" "testing" - "time" "github.com/davecgh/go-spew/spew" "github.com/google/uuid" @@ -92,7 +91,8 @@ func testAppSecEngine(t *testing.T, test appsecRuleTest) { outofbandRules := []string{} nativeOutofbandRules := []string{} InChan := make(chan appsec.ParsedRequest) - OutChan := make(chan pipeline.Event) + // buffered so handleRequest's synchronous sends never block; the harness drains afterwards + OutChan := make(chan pipeline.Event, 128) logger := log.WithField("test", test.name) @@ -244,51 +244,27 @@ func testAppSecEngine(t *testing.T, test appsecRuleTest) { } input := test.input_request - input.ResponseChannel = make(chan appsec.AppsecTempResponse) - - // collect both responses and events until no activity for idleDuration - idleDuration := 200 * time.Millisecond - idle := time.NewTimer(idleDuration) - defer idle.Stop() - - // when we receive something, drain and restart the idle timer - reset := func() { - if !idle.Stop() { - select { - case <-idle.C: - default: - } - } - idle.Reset(idleDuration) - } + // buffered so handleRequest's synchronous sends never block; drained below + input.ResponseChannel = make(chan appsec.AppsecTempResponse, 128) responses := []appsec.AppsecTempResponse{} events := []pipeline.Event{} - done := make(chan struct{}) - - // collect in a goroutine so a receiver is ready - go func() { - for { - select { - case r := <-input.ResponseChannel: - responses = append(responses, r) - reset() - case e := <-OutChan: - events = append(events, e) - reset() - case <-idle.C: - close(done) - return - } - } - }() - + // handleRequest is synchronous: once it returns, every response and event it emits has + // already been buffered, so we can drain both channels deterministically without relying + // on a timer (which used to deadlock when a slow request outran the idle timeout). runner.handleRequest(t.Context(), &input) - time.Sleep(50 * time.Millisecond) - // wait for the idle duration - <-done + for draining := true; draining; { + select { + case r := <-input.ResponseChannel: + responses = append(responses, r) + case e := <-OutChan: + events = append(events, e) + default: + draining = false + } + } require.NotEmpty(t, responses) httpStatus, appsecResponse := AppsecRuntime.GenerateResponse(responses[0], logger)