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/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..76c519fcd63 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" @@ -140,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) } @@ -164,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) } @@ -216,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, ",")), @@ -344,6 +354,76 @@ Disable given information push to the central API.`, return cmd } +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 (*cliConsole) fetchConsoleStatus(ctx context.Context, cfg *csconfig.Config) liveConsoleStatus { + st := liveConsoleStatus{} + + online := cfg.API.Server.OnlineClient + + // load credz here to gracefully handle missing/invalid file. + if online == nil || online.CredentialsFilePath == "" { + return st + } + + if err := online.Load(); err != nil { + log.Warnf("could not load CAPI credentials: %s", err) + return st + } + + 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 +431,47 @@ 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. + // 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()) + }, + 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 +493,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) @@ -432,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 f957e1ec81e..83cc7a648a5 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,56 @@ import ( "github.com/crowdsecurity/crowdsec/pkg/emoji" ) +// cmdConsoleConnectionTable renders the live link to the console: enrollment, plan and the +// real decision-management state. +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 + } + + 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. func cmdConsoleStatusTable(out io.Writer, wantColor string, consoleCfg csconfig.ConsoleConfig) { t := cstable.New(out, wantColor) t.SetRowLines(false) @@ -37,10 +88,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..88a8087f055 --- /dev/null +++ b/cmd/crowdsec-cli/core/consolestatus/consolestatus.go @@ -0,0 +1,116 @@ +// Package consolestatus fetches the live state of an engine's link to the CrowdSec +// console (CAPI/PAPI): enrollment, plan, and decision-management state. +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 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 +// 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 { + 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 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 { + 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/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) 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.go b/pkg/csconfig/api.go index cb3ef8066b1..9e25e93aa50 100644 --- a/pkg/csconfig/api.go +++ b/pkg/csconfig/api.go @@ -118,6 +118,10 @@ 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. + 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/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 ee12efe1658..00f488be3bf 100644 --- a/pkg/csconfig/console.go +++ b/pkg/csconfig/console.go @@ -12,17 +12,20 @@ const ( SEND_CUSTOM_SCENARIOS = "custom" SEND_TAINTED_SCENARIOS = "tainted" SEND_MANUAL_SCENARIOS = "manual" - CONSOLE_MANAGEMENT = "console_management" SEND_CONTEXT = "context" + + // 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" ) -var CONSOLE_CONFIGS = []string{SEND_CUSTOM_SCENARIOS, SEND_MANUAL_SCENARIOS, SEND_TAINTED_SCENARIOS, SEND_CONTEXT, CONSOLE_MANAGEMENT} +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") @@ -31,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"` } @@ -53,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) } @@ -64,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) { @@ -80,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 @@ -111,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 72217dda618..d36bfe7beef 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 @@ -72,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 d203c933e0e..98a4510bced 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" @@ -71,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" @@ -92,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"