Browse Source

lapi to capi : allow push of tainted/custom/manual decisions (#1154)

* add console command to control signal sharing
* modify metrics endpoint to add lastpush

Co-authored-by: alteredCoder <kevin@crowdsec.net>
Thibault "bui" Koechlin 3 years ago
parent
commit
6e92da76ad

+ 205 - 14
cmd/crowdsec-cli/console.go

@@ -2,13 +2,22 @@ package main
 
 
 import (
 import (
 	"context"
 	"context"
+	"encoding/csv"
+	"encoding/json"
+	"errors"
 	"fmt"
 	"fmt"
+	"io/fs"
 	"net/url"
 	"net/url"
+	"os"
 
 
 	"github.com/crowdsecurity/crowdsec/pkg/apiclient"
 	"github.com/crowdsecurity/crowdsec/pkg/apiclient"
+	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
 	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
 	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
+	"github.com/crowdsecurity/crowdsec/pkg/types"
+	"github.com/enescakir/emoji"
 	"github.com/go-openapi/strfmt"
 	"github.com/go-openapi/strfmt"
+	"github.com/olekukonko/tablewriter"
 	log "github.com/sirupsen/logrus"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"github.com/spf13/cobra"
 )
 )
@@ -21,12 +30,24 @@ func NewConsoleCmd() *cobra.Command {
 		DisableAutoGenTag: true,
 		DisableAutoGenTag: true,
 		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
 		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
 			if err := csConfig.LoadAPIServer(); err != nil || csConfig.DisableAPI {
 			if err := csConfig.LoadAPIServer(); err != nil || csConfig.DisableAPI {
+				var fdErr *fs.PathError
+				if errors.As(err, &fdErr) {
+					log.Fatalf("Unable to load Local API : %s", fdErr)
+				} else if err != nil {
+					log.Fatalf("Unable to load required Local API Configuration : %s", err)
+				} else {
+					log.Fatal("Local API is disabled, please run this command on the local API machine")
+				}
+			}
+			if csConfig.DisableAPI {
 				log.Fatal("Local API is disabled, please run this command on the local API machine")
 				log.Fatal("Local API is disabled, please run this command on the local API machine")
 			}
 			}
 			if csConfig.API.Server.OnlineClient == nil {
 			if csConfig.API.Server.OnlineClient == nil {
-				log.Fatalf("no configuration for Central API (CAPI) in '%s'", *csConfig.FilePath)
+				log.Fatalf("No configuration for Central API (CAPI) in '%s'", *csConfig.FilePath)
+			}
+			if csConfig.API.Server.OnlineClient.Credentials == nil {
+				log.Fatal("You must configure Central API (CAPI) with `cscli capi register` before enrolling your instance")
 			}
 			}
-
 			return nil
 			return nil
 		},
 		},
 	}
 	}
@@ -48,18 +69,6 @@ After running this command your will need to validate the enrollment in the weba
 `,
 `,
 		Args:              cobra.ExactArgs(1),
 		Args:              cobra.ExactArgs(1),
 		DisableAutoGenTag: true,
 		DisableAutoGenTag: true,
-		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
-			if err := csConfig.LoadAPIServer(); err != nil || csConfig.DisableAPI {
-				log.Fatal("Local API is disabled, please run this command on the local API machine")
-			}
-			if csConfig.API.Server.OnlineClient == nil {
-				log.Fatalf("no configuration for Central API (CAPI) in '%s'", *csConfig.FilePath)
-			}
-			if csConfig.API.Server.OnlineClient.Credentials == nil {
-				log.Fatal("You must configure Central API (CAPI) with `cscli capi register` before enrolling your instance")
-			}
-			return nil
-		},
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
 			password := strfmt.Password(csConfig.API.Server.OnlineClient.Credentials.Password)
 			password := strfmt.Password(csConfig.API.Server.OnlineClient.Credentials.Password)
 			apiURL, err := url.Parse(csConfig.API.Server.OnlineClient.Credentials.URL)
 			apiURL, err := url.Parse(csConfig.API.Server.OnlineClient.Credentials.URL)
@@ -97,11 +106,193 @@ After running this command your will need to validate the enrollment in the weba
 			if err != nil {
 			if err != nil {
 				log.Fatalf("Could not enroll instance: %s", err)
 				log.Fatalf("Could not enroll instance: %s", err)
 			}
 			}
+
+			SetConsoleOpts(csconfig.CONSOLE_CONFIGS, true)
+			log.Infof("Enabled tainted&manual alerts sharing, see 'cscli console status'.")
 			log.Infof("Watcher successfully enrolled. Visit https://app.crowdsec.net to accept it.")
 			log.Infof("Watcher successfully enrolled. Visit https://app.crowdsec.net to accept it.")
+			log.Infof("Please restart crowdsec after accepting the enrollment.")
 		},
 		},
 	}
 	}
 	cmdEnroll.Flags().StringVarP(&name, "name", "n", "", "Name to display in the console")
 	cmdEnroll.Flags().StringVarP(&name, "name", "n", "", "Name to display in the console")
 	cmdEnroll.Flags().StringSliceVarP(&tags, "tags", "t", tags, "Tags to display in the console")
 	cmdEnroll.Flags().StringSliceVarP(&tags, "tags", "t", tags, "Tags to display in the console")
 	cmdConsole.AddCommand(cmdEnroll)
 	cmdConsole.AddCommand(cmdEnroll)
+
+	var enableAll, disableAll bool
+
+	cmdEnable := &cobra.Command{
+		Use:     "enable [feature-flag]",
+		Short:   "Enable a feature flag",
+		Example: "enable alerts-tainted",
+		Long: `
+Enable given information push to the central API. Allows to empower the console`,
+		ValidArgs:         csconfig.CONSOLE_CONFIGS,
+		Args:              cobra.MinimumNArgs(1),
+		DisableAutoGenTag: true,
+		Run: func(cmd *cobra.Command, args []string) {
+			if enableAll {
+				SetConsoleOpts(csconfig.CONSOLE_CONFIGS, true)
+			} else {
+				SetConsoleOpts(args, true)
+			}
+
+			if err := csConfig.API.Server.DumpConsoleConfig(); err != nil {
+				log.Fatalf("failed writing console config : %s", err)
+			}
+			if enableAll {
+				log.Infof("All features have been enabled successfully")
+			} else {
+				log.Infof("%v have been enabled", args)
+			}
+			log.Infof(ReloadMessage())
+		},
+	}
+	cmdEnable.Flags().BoolVarP(&enableAll, "all", "a", false, "Enable all feature flags")
+	cmdConsole.AddCommand(cmdEnable)
+
+	cmdDisable := &cobra.Command{
+		Use:     "disable [feature-flag]",
+		Short:   "Disable a feature flag",
+		Example: "disable alerts-tainted",
+		Long: `
+Disable given information push to the central API.`,
+		ValidArgs:         csconfig.CONSOLE_CONFIGS,
+		Args:              cobra.MinimumNArgs(1),
+		DisableAutoGenTag: true,
+		Run: func(cmd *cobra.Command, args []string) {
+			if disableAll {
+				SetConsoleOpts(csconfig.CONSOLE_CONFIGS, false)
+			} else {
+				SetConsoleOpts(args, false)
+			}
+
+			if err := csConfig.API.Server.DumpConsoleConfig(); err != nil {
+				log.Fatalf("failed writing console config : %s", err)
+			}
+			if disableAll {
+				log.Infof("All features have been disabled")
+			} else {
+				log.Infof("%v have been disabled", args)
+			}
+			log.Infof(ReloadMessage())
+		},
+	}
+	cmdDisable.Flags().BoolVarP(&disableAll, "all", "a", false, "Enable all feature flags")
+	cmdConsole.AddCommand(cmdDisable)
+
+	cmdConsoleStatus := &cobra.Command{
+		Use:               "status [feature-flag]",
+		Short:             "Shows status of one or all feature flags",
+		Example:           "status alerts-tainted",
+		DisableAutoGenTag: true,
+		Run: func(cmd *cobra.Command, args []string) {
+			switch csConfig.Cscli.Output {
+			case "human":
+				table := tablewriter.NewWriter(os.Stdout)
+
+				table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
+				table.SetAlignment(tablewriter.ALIGN_LEFT)
+				table.SetHeader([]string{"Option Name", "Activated", "Description"})
+				for _, option := range csconfig.CONSOLE_CONFIGS {
+					switch option {
+					case csconfig.SEND_CUSTOM_SCENARIOS:
+						activated := string(emoji.CrossMark)
+						if *csConfig.API.Server.ConsoleConfig.ShareCustomScenarios {
+							activated = string(emoji.CheckMarkButton)
+						}
+						table.Append([]string{option, activated, "Send alerts from custom scenarios to the console"})
+					case csconfig.SEND_MANUAL_SCENARIOS:
+						activated := string(emoji.CrossMark)
+						if *csConfig.API.Server.ConsoleConfig.ShareManualDecisions {
+							activated = string(emoji.CheckMarkButton)
+						}
+						table.Append([]string{option, activated, "Send manual decisions to the console"})
+					case csconfig.SEND_TAINTED_SCENARIOS:
+						activated := string(emoji.CrossMark)
+						if *csConfig.API.Server.ConsoleConfig.ShareTaintedScenarios {
+							activated = string(emoji.CheckMarkButton)
+						}
+						table.Append([]string{option, activated, "Send alerts from tainted scenarios to the console"})
+					}
+				}
+				table.Render()
+			case "json":
+				data, err := json.MarshalIndent(csConfig.API.Server.ConsoleConfig, "", "  ")
+				if err != nil {
+					log.Fatalf("failed to marshal configuration: %s", err)
+				}
+				fmt.Printf("%s\n", string(data))
+			case "raw":
+				csvwriter := csv.NewWriter(os.Stdout)
+				err := csvwriter.Write([]string{"option", "enabled"})
+				if err != nil {
+					log.Fatal(err)
+				}
+
+				rows := [][]string{
+					{"share_manual_decisions", fmt.Sprintf("%t", *csConfig.API.Server.ConsoleConfig.ShareManualDecisions)},
+					{"share_custom", fmt.Sprintf("%t", *csConfig.API.Server.ConsoleConfig.ShareCustomScenarios)},
+					{"share_tainted", fmt.Sprintf("%t", *csConfig.API.Server.ConsoleConfig.ShareTaintedScenarios)},
+				}
+				for _, row := range rows {
+					err = csvwriter.Write(row)
+					if err != nil {
+						log.Fatal(err)
+					}
+				}
+				csvwriter.Flush()
+			}
+		},
+	}
+
+	cmdConsole.AddCommand(cmdConsoleStatus)
 	return cmdConsole
 	return cmdConsole
 }
 }
+
+func SetConsoleOpts(args []string, wanted bool) {
+	for _, arg := range args {
+		switch arg {
+		case csconfig.SEND_CUSTOM_SCENARIOS:
+			/*for each flag check if it's already set before setting it*/
+			if csConfig.API.Server.ConsoleConfig.ShareCustomScenarios != nil {
+				if *csConfig.API.Server.ConsoleConfig.ShareCustomScenarios == wanted {
+					log.Infof("%s already set to %t", csconfig.SEND_CUSTOM_SCENARIOS, wanted)
+				} else {
+					log.Infof("%s set to %t", csconfig.SEND_CUSTOM_SCENARIOS, wanted)
+					*csConfig.API.Server.ConsoleConfig.ShareCustomScenarios = wanted
+				}
+			} else {
+				log.Infof("%s set to %t", csconfig.SEND_CUSTOM_SCENARIOS, wanted)
+				csConfig.API.Server.ConsoleConfig.ShareCustomScenarios = types.BoolPtr(wanted)
+			}
+		case csconfig.SEND_TAINTED_SCENARIOS:
+			/*for each flag check if it's already set before setting it*/
+			if csConfig.API.Server.ConsoleConfig.ShareTaintedScenarios != nil {
+				if *csConfig.API.Server.ConsoleConfig.ShareTaintedScenarios == wanted {
+					log.Infof("%s already set to %t", csconfig.SEND_TAINTED_SCENARIOS, wanted)
+				} else {
+					log.Infof("%s set to %t", csconfig.SEND_TAINTED_SCENARIOS, wanted)
+					*csConfig.API.Server.ConsoleConfig.ShareTaintedScenarios = wanted
+				}
+			} else {
+				log.Infof("%s set to %t", csconfig.SEND_TAINTED_SCENARIOS, wanted)
+				csConfig.API.Server.ConsoleConfig.ShareTaintedScenarios = types.BoolPtr(wanted)
+			}
+		case csconfig.SEND_MANUAL_SCENARIOS:
+			/*for each flag check if it's already set before setting it*/
+			if csConfig.API.Server.ConsoleConfig.ShareManualDecisions != nil {
+				if *csConfig.API.Server.ConsoleConfig.ShareManualDecisions == wanted {
+					log.Infof("%s already set to %t", csconfig.SEND_MANUAL_SCENARIOS, wanted)
+				} else {
+					log.Infof("%s set to %t", csconfig.SEND_MANUAL_SCENARIOS, wanted)
+					*csConfig.API.Server.ConsoleConfig.ShareManualDecisions = wanted
+				}
+			} else {
+				log.Infof("%s set to %t", csconfig.SEND_MANUAL_SCENARIOS, wanted)
+				csConfig.API.Server.ConsoleConfig.ShareManualDecisions = types.BoolPtr(wanted)
+			}
+		default:
+			log.Fatalf("unknown flag %s", arg)
+		}
+	}
+
+}

+ 3 - 4
cmd/crowdsec-cli/decisions.go

@@ -37,8 +37,7 @@ func DecisionsToTable(alerts *models.GetAlertsResponse) error {
 	var spamLimit map[string]bool = make(map[string]bool)
 	var spamLimit map[string]bool = make(map[string]bool)
 	var skipped = 0
 	var skipped = 0
 
 
-	/*process in reverse order to keep the latest item only*/
-	for aIdx := len(*alerts) - 1; aIdx >= 0; aIdx-- {
+	for aIdx := 0; aIdx < len(*alerts); aIdx++ {
 		alertItem := (*alerts)[aIdx]
 		alertItem := (*alerts)[aIdx]
 		newDecisions := make([]*models.Decision, 0)
 		newDecisions := make([]*models.Decision, 0)
 		for _, decisionItem := range alertItem.Decisions {
 		for _, decisionItem := range alertItem.Decisions {
@@ -303,7 +302,7 @@ cscli decisions add --scope username --value foobar
 		DisableAutoGenTag: true,
 		DisableAutoGenTag: true,
 		Run: func(cmd *cobra.Command, args []string) {
 		Run: func(cmd *cobra.Command, args []string) {
 			var err error
 			var err error
-			var ip, ipRange string
+			var ipRange string
 			alerts := models.AddAlertsRequest{}
 			alerts := models.AddAlertsRequest{}
 			origin := "cscli"
 			origin := "cscli"
 			capacity := int32(0)
 			capacity := int32(0)
@@ -358,7 +357,7 @@ cscli decisions add --scope username --value foobar
 					AsName:   empty,
 					AsName:   empty,
 					AsNumber: empty,
 					AsNumber: empty,
 					Cn:       empty,
 					Cn:       empty,
-					IP:       ip,
+					IP:       addValue,
 					Range:    ipRange,
 					Range:    ipRange,
 					Scope:    &addScope,
 					Scope:    &addScope,
 					Value:    &addValue,
 					Value:    &addValue,

+ 1 - 0
config/config.yaml

@@ -41,6 +41,7 @@ api:
     log_level: info
     log_level: info
     listen_uri: 127.0.0.1:8080
     listen_uri: 127.0.0.1:8080
     profiles_path: /etc/crowdsec/profiles.yaml
     profiles_path: /etc/crowdsec/profiles.yaml
+    console_path: /etc/crowdsec/console_config.yaml
     online_client: # Central API credentials (to push signals and receive bad IPs)
     online_client: # Central API credentials (to push signals and receive bad IPs)
       credentials_path: /etc/crowdsec/online_api_credentials.yaml
       credentials_path: /etc/crowdsec/online_api_credentials.yaml
 #    tls:
 #    tls:

+ 3 - 0
config/console_config.yaml

@@ -0,0 +1,3 @@
+share_manual_decisions: false
+share_custom: true
+share_tainted: true

+ 1 - 0
debian/rules

@@ -47,4 +47,5 @@ override_dh_auto_install:
 	cp config/config.yaml debian/crowdsec/etc/crowdsec/config.yaml
 	cp config/config.yaml debian/crowdsec/etc/crowdsec/config.yaml
 	cp config/simulation.yaml debian/crowdsec/etc/crowdsec/simulation.yaml
 	cp config/simulation.yaml debian/crowdsec/etc/crowdsec/simulation.yaml
 	cp config/profiles.yaml debian/crowdsec/etc/crowdsec/profiles.yaml
 	cp config/profiles.yaml debian/crowdsec/etc/crowdsec/profiles.yaml
+	cp config/console_config.yaml debian/crowdsec/etc/crowdsec/console_config.yaml
 	cp -a config/patterns debian/crowdsec/etc/crowdsec
 	cp -a config/patterns debian/crowdsec/etc/crowdsec

+ 2 - 0
go.sum

@@ -204,6 +204,7 @@ github.com/go-openapi/errors v0.19.9 h1:9SnKdGhiPZHF3ttwFMiCBEb8jQ4IDdrK+5+a0oTy
 github.com/go-openapi/errors v0.19.9/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M=
 github.com/go-openapi/errors v0.19.9/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M=
 github.com/go-openapi/errors v0.20.1 h1:j23mMDtRxMwIobkpId7sWh7Ddcx4ivaoqUbfXx5P+a8=
 github.com/go-openapi/errors v0.20.1 h1:j23mMDtRxMwIobkpId7sWh7Ddcx4ivaoqUbfXx5P+a8=
 github.com/go-openapi/errors v0.20.1/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M=
 github.com/go-openapi/errors v0.20.1/go.mod h1:cM//ZKUKyO06HSwqAelJ5NsEMMcpa6VpXe8DOa1Mi1M=
+github.com/go-openapi/inflect v0.19.0 h1:9jCH9scKIbHeV9m12SmPilScz6krDxKRasNNSNPXu/4=
 github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
 github.com/go-openapi/inflect v0.19.0/go.mod h1:lHpZVlpIQqLyKwJ4N+YSc9hchQy/i12fJykb83CRBH4=
 github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
 github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
 github.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
 github.com/go-openapi/jsonpointer v0.18.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M=
@@ -939,6 +940,7 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
 golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
 golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.1.5 h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

+ 7 - 2
pkg/apiclient/signal.go

@@ -3,7 +3,8 @@ package apiclient
 import (
 import (
 	"context"
 	"context"
 	"fmt"
 	"fmt"
-	"log"
+
+	log "github.com/sirupsen/logrus"
 
 
 	"github.com/crowdsecurity/crowdsec/pkg/models"
 	"github.com/crowdsecurity/crowdsec/pkg/models"
 	"github.com/pkg/errors"
 	"github.com/pkg/errors"
@@ -24,6 +25,10 @@ func (s *SignalService) Add(ctx context.Context, signals *models.AddSignalsReque
 	if err != nil {
 	if err != nil {
 		return nil, resp, errors.Wrap(err, "while performing request")
 		return nil, resp, errors.Wrap(err, "while performing request")
 	}
 	}
-	log.Printf("Signal push response : http %s", resp.Response.Status)
+	if resp.Response.StatusCode != 200 {
+		log.Warnf("Signal push response : http %s", resp.Response.Status)
+	} else {
+		log.Debugf("Signal push response : http %s", resp.Response.Status)
+	}
 	return &response, resp, nil
 	return &response, resp, nil
 }
 }

+ 55 - 29
pkg/apiserver/apic.go

@@ -44,6 +44,7 @@ type apic struct {
 	startup         bool
 	startup         bool
 	credentials     *csconfig.ApiCredentialsCfg
 	credentials     *csconfig.ApiCredentialsCfg
 	scenarioList    []string
 	scenarioList    []string
+	consoleConfig   *csconfig.ConsoleConfig
 }
 }
 
 
 func IsInSlice(a string, b []string) bool {
 func IsInSlice(a string, b []string) bool {
@@ -75,7 +76,7 @@ func (a *apic) FetchScenariosListFromDB() ([]string, error) {
 	return scenarios, nil
 	return scenarios, nil
 }
 }
 
 
-func AlertToSignal(alert *models.Alert) *models.AddSignalsRequestItem {
+func AlertToSignal(alert *models.Alert, scenarioTrust string) *models.AddSignalsRequestItem {
 	return &models.AddSignalsRequestItem{
 	return &models.AddSignalsRequestItem{
 		Message:         alert.Message,
 		Message:         alert.Message,
 		Scenario:        alert.Scenario,
 		Scenario:        alert.Scenario,
@@ -86,21 +87,23 @@ func AlertToSignal(alert *models.Alert) *models.AddSignalsRequestItem {
 		StopAt:          alert.StopAt,
 		StopAt:          alert.StopAt,
 		CreatedAt:       alert.CreatedAt,
 		CreatedAt:       alert.CreatedAt,
 		MachineID:       alert.MachineID,
 		MachineID:       alert.MachineID,
+		ScenarioTrust:   &scenarioTrust,
 	}
 	}
 }
 }
 
 
-func NewAPIC(config *csconfig.OnlineApiClientCfg, dbClient *database.Client) (*apic, error) {
+func NewAPIC(config *csconfig.OnlineApiClientCfg, dbClient *database.Client, consoleConfig *csconfig.ConsoleConfig) (*apic, error) {
 	var err error
 	var err error
 	ret := &apic{
 	ret := &apic{
-		alertToPush:  make(chan []*models.Alert),
-		dbClient:     dbClient,
-		mu:           sync.Mutex{},
-		startup:      true,
-		credentials:  config.Credentials,
-		pullTomb:     tomb.Tomb{},
-		pushTomb:     tomb.Tomb{},
-		metricsTomb:  tomb.Tomb{},
-		scenarioList: make([]string, 0),
+		alertToPush:   make(chan []*models.Alert),
+		dbClient:      dbClient,
+		mu:            sync.Mutex{},
+		startup:       true,
+		credentials:   config.Credentials,
+		pullTomb:      tomb.Tomb{},
+		pushTomb:      tomb.Tomb{},
+		metricsTomb:   tomb.Tomb{},
+		scenarioList:  make([]string, 0),
+		consoleConfig: consoleConfig,
 	}
 	}
 
 
 	ret.pullInterval, err = time.ParseDuration(PullInterval)
 	ret.pullInterval, err = time.ParseDuration(PullInterval)
@@ -167,20 +170,39 @@ func (a *apic) Push() error {
 		case alerts := <-a.alertToPush:
 		case alerts := <-a.alertToPush:
 			var signals []*models.AddSignalsRequestItem
 			var signals []*models.AddSignalsRequestItem
 			for _, alert := range alerts {
 			for _, alert := range alerts {
-				/*we're only interested into decisions coming from scenarios of the hub*/
-				if alert.ScenarioHash == nil || *alert.ScenarioHash == "" {
-					continue
-				}
-				/*and we're not interested into tainted scenarios neither*/
-				if alert.ScenarioVersion == nil || *alert.ScenarioVersion == "" || *alert.ScenarioVersion == "?" {
-					continue
-				}
-				/*we also ignore alerts in simulated mode*/
 				if *alert.Simulated {
 				if *alert.Simulated {
 					log.Debugf("simulation enabled for alert (id:%d), will not be sent to CAPI", alert.ID)
 					log.Debugf("simulation enabled for alert (id:%d), will not be sent to CAPI", alert.ID)
 					continue
 					continue
 				}
 				}
-				signals = append(signals, AlertToSignal(alert))
+				scenarioTrust := "certified"
+				if alert.ScenarioHash == nil || *alert.ScenarioHash == "" {
+					scenarioTrust = "custom"
+				} else if alert.ScenarioVersion == nil || *alert.ScenarioVersion == "" || *alert.ScenarioVersion == "?" {
+					scenarioTrust = "tainted"
+				}
+				if len(alert.Decisions) > 0 {
+					if *alert.Decisions[0].Origin == "cscli" {
+						scenarioTrust = "manual"
+					}
+				}
+				switch scenarioTrust {
+				case "manual":
+					if !*a.consoleConfig.ShareManualDecisions {
+						log.Debugf("manual decision generated an alert, doesn't send it to CAPI because options is disabled")
+						continue
+					}
+				case "tainted":
+					if !*a.consoleConfig.ShareTaintedScenarios {
+						log.Debugf("tainted scenario generated an alert, doesn't send it to CAPI because options is disabled")
+						continue
+					}
+				case "custom":
+					if !*a.consoleConfig.ShareCustomScenarios {
+						log.Debugf("custom scenario generated an alert, doesn't send it to CAPI because options is disabled")
+						continue
+					}
+				}
+				signals = append(signals, AlertToSignal(alert, scenarioTrust))
 			}
 			}
 			a.mu.Lock()
 			a.mu.Lock()
 			cache = append(cache, signals...)
 			cache = append(cache, signals...)
@@ -477,8 +499,8 @@ func (a *apic) GetMetrics() (*models.Metrics, error) {
 	version := cwversion.VersionStr()
 	version := cwversion.VersionStr()
 	metric := &models.Metrics{
 	metric := &models.Metrics{
 		ApilVersion: &version,
 		ApilVersion: &version,
-		Machines:    make([]*models.MetricsSoftInfo, 0),
-		Bouncers:    make([]*models.MetricsSoftInfo, 0),
+		Machines:    make([]*models.MetricsAgentInfo, 0),
+		Bouncers:    make([]*models.MetricsBouncerInfo, 0),
 	}
 	}
 	machines, err := a.dbClient.ListMachines()
 	machines, err := a.dbClient.ListMachines()
 	if err != nil {
 	if err != nil {
@@ -489,17 +511,21 @@ func (a *apic) GetMetrics() (*models.Metrics, error) {
 		return metric, err
 		return metric, err
 	}
 	}
 	for _, machine := range machines {
 	for _, machine := range machines {
-		m := &models.MetricsSoftInfo{
-			Version: machine.Version,
-			Name:    machine.MachineId,
+		m := &models.MetricsAgentInfo{
+			Version:    machine.Version,
+			Name:       machine.MachineId,
+			LastUpdate: machine.UpdatedAt.String(),
+			LastPush:   machine.LastPush.String(),
 		}
 		}
 		metric.Machines = append(metric.Machines, m)
 		metric.Machines = append(metric.Machines, m)
 	}
 	}
 
 
 	for _, bouncer := range bouncers {
 	for _, bouncer := range bouncers {
-		m := &models.MetricsSoftInfo{
-			Version: bouncer.Version,
-			Name:    bouncer.Type,
+		m := &models.MetricsBouncerInfo{
+			Version:    bouncer.Version,
+			CustomName: bouncer.Name,
+			Name:       bouncer.Type,
+			LastPull:   bouncer.LastPull.String(),
 		}
 		}
 		metric.Bouncers = append(metric.Bouncers, m)
 		metric.Bouncers = append(metric.Bouncers, m)
 	}
 	}

+ 10 - 6
pkg/apiserver/apiserver.go

@@ -38,6 +38,7 @@ type APIServer struct {
 	httpServer     *http.Server
 	httpServer     *http.Server
 	apic           *apic
 	apic           *apic
 	httpServerTomb tomb.Tomb
 	httpServerTomb tomb.Tomb
+	consoleConfig  *csconfig.ConsoleConfig
 }
 }
 
 
 // RecoveryWithWriter returns a middleware for a given writer that recovers from any panics and writes a 500 if there was one.
 // RecoveryWithWriter returns a middleware for a given writer that recovers from any panics and writes a 500 if there was one.
@@ -165,19 +166,21 @@ func NewServer(config *csconfig.LocalApiServerCfg) (*APIServer, error) {
 		return
 		return
 	})
 	})
 	router.Use(CustomRecoveryWithWriter())
 	router.Use(CustomRecoveryWithWriter())
+
 	controller := &controllers.Controller{
 	controller := &controllers.Controller{
-		DBClient: dbClient,
-		Ectx:     context.Background(),
-		Router:   router,
-		Profiles: config.Profiles,
-		Log:      clog,
+		DBClient:      dbClient,
+		Ectx:          context.Background(),
+		Router:        router,
+		Profiles:      config.Profiles,
+		Log:           clog,
+		ConsoleConfig: config.ConsoleConfig,
 	}
 	}
 
 
 	var apiClient *apic
 	var apiClient *apic
 
 
 	if config.OnlineClient != nil && config.OnlineClient.Credentials != nil {
 	if config.OnlineClient != nil && config.OnlineClient.Credentials != nil {
 		log.Printf("Loading CAPI pusher")
 		log.Printf("Loading CAPI pusher")
-		apiClient, err = NewAPIC(config.OnlineClient, dbClient)
+		apiClient, err = NewAPIC(config.OnlineClient, dbClient, config.ConsoleConfig)
 		if err != nil {
 		if err != nil {
 			return &APIServer{}, err
 			return &APIServer{}, err
 		}
 		}
@@ -197,6 +200,7 @@ func NewServer(config *csconfig.LocalApiServerCfg) (*APIServer, error) {
 		router:         router,
 		router:         router,
 		apic:           apiClient,
 		apic:           apiClient,
 		httpServerTomb: tomb.Tomb{},
 		httpServerTomb: tomb.Tomb{},
+		consoleConfig:  config.ConsoleConfig,
 	}, nil
 	}, nil
 
 
 }
 }

+ 10 - 0
pkg/apiserver/apiserver_test.go

@@ -49,6 +49,11 @@ func LoadTestConfig() csconfig.Config {
 		ListenURI:    "http://127.0.0.1:8080",
 		ListenURI:    "http://127.0.0.1:8080",
 		DbConfig:     &dbconfig,
 		DbConfig:     &dbconfig,
 		ProfilesPath: "./tests/profiles.yaml",
 		ProfilesPath: "./tests/profiles.yaml",
+		ConsoleConfig: &csconfig.ConsoleConfig{
+			ShareManualDecisions:  new(bool),
+			ShareTaintedScenarios: new(bool),
+			ShareCustomScenarios:  new(bool),
+		},
 	}
 	}
 	apiConfig := csconfig.APICfg{
 	apiConfig := csconfig.APICfg{
 		Server: &apiServerConfig,
 		Server: &apiServerConfig,
@@ -76,6 +81,11 @@ func LoadTestConfigForwardedFor() csconfig.Config {
 		DbConfig:               &dbconfig,
 		DbConfig:               &dbconfig,
 		ProfilesPath:           "./tests/profiles.yaml",
 		ProfilesPath:           "./tests/profiles.yaml",
 		UseForwardedForHeaders: true,
 		UseForwardedForHeaders: true,
+		ConsoleConfig: &csconfig.ConsoleConfig{
+			ShareManualDecisions:  new(bool),
+			ShareTaintedScenarios: new(bool),
+			ShareCustomScenarios:  new(bool),
+		},
 	}
 	}
 	apiConfig := csconfig.APICfg{
 	apiConfig := csconfig.APICfg{
 		Server: &apiServerConfig,
 		Server: &apiServerConfig,

+ 4 - 2
pkg/apiserver/controllers/controller.go

@@ -2,6 +2,8 @@ package controllers
 
 
 import (
 import (
 	"context"
 	"context"
+	"net/http"
+
 	"github.com/alexliesenfeld/health"
 	"github.com/alexliesenfeld/health"
 	v1 "github.com/crowdsecurity/crowdsec/pkg/apiserver/controllers/v1"
 	v1 "github.com/crowdsecurity/crowdsec/pkg/apiserver/controllers/v1"
 	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
 	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
@@ -10,7 +12,6 @@ import (
 	"github.com/crowdsecurity/crowdsec/pkg/models"
 	"github.com/crowdsecurity/crowdsec/pkg/models"
 	"github.com/gin-gonic/gin"
 	"github.com/gin-gonic/gin"
 	log "github.com/sirupsen/logrus"
 	log "github.com/sirupsen/logrus"
-	"net/http"
 )
 )
 
 
 type Controller struct {
 type Controller struct {
@@ -21,6 +22,7 @@ type Controller struct {
 	CAPIChan      chan []*models.Alert
 	CAPIChan      chan []*models.Alert
 	PluginChannel chan csplugin.ProfileAlert
 	PluginChannel chan csplugin.ProfileAlert
 	Log           *log.Logger
 	Log           *log.Logger
+	ConsoleConfig *csconfig.ConsoleConfig
 }
 }
 
 
 func (c *Controller) Init() error {
 func (c *Controller) Init() error {
@@ -51,7 +53,7 @@ func serveHealth() http.HandlerFunc {
 }
 }
 
 
 func (c *Controller) NewV1() error {
 func (c *Controller) NewV1() error {
-	handlerV1, err := v1.New(c.DBClient, c.Ectx, c.Profiles, c.CAPIChan, c.PluginChannel)
+	handlerV1, err := v1.New(c.DBClient, c.Ectx, c.Profiles, c.CAPIChan, c.PluginChannel, *c.ConsoleConfig)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}

+ 5 - 1
pkg/apiserver/controllers/v1/alerts.go

@@ -156,10 +156,14 @@ func (c *Controller) CreateAlert(gctx *gin.Context) {
 				gctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
 				gctx.JSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
 				return
 				return
 			}
 			}
+
 			if !matched {
 			if !matched {
 				continue
 				continue
 			}
 			}
-			alert.Decisions = append(alert.Decisions, profileDecisions...)
+
+			if len(alert.Decisions) == 0 { // non manual decision
+				alert.Decisions = append(alert.Decisions, profileDecisions...)
+			}
 			profileAlert := *alert
 			profileAlert := *alert
 			c.sendAlertToPluginChannel(&profileAlert, uint(pIdx))
 			c.sendAlertToPluginChannel(&profileAlert, uint(pIdx))
 			if profile.OnSuccess == "break" {
 			if profile.OnSuccess == "break" {

+ 3 - 1
pkg/apiserver/controllers/v1/controller.go

@@ -18,9 +18,10 @@ type Controller struct {
 	Profiles      []*csconfig.ProfileCfg
 	Profiles      []*csconfig.ProfileCfg
 	CAPIChan      chan []*models.Alert
 	CAPIChan      chan []*models.Alert
 	PluginChannel chan csplugin.ProfileAlert
 	PluginChannel chan csplugin.ProfileAlert
+	ConsoleConfig csconfig.ConsoleConfig
 }
 }
 
 
-func New(dbClient *database.Client, ctx context.Context, profiles []*csconfig.ProfileCfg, capiChan chan []*models.Alert, pluginChannel chan csplugin.ProfileAlert) (*Controller, error) {
+func New(dbClient *database.Client, ctx context.Context, profiles []*csconfig.ProfileCfg, capiChan chan []*models.Alert, pluginChannel chan csplugin.ProfileAlert, consoleConfig csconfig.ConsoleConfig) (*Controller, error) {
 	var err error
 	var err error
 	v1 := &Controller{
 	v1 := &Controller{
 		Ectx:          ctx,
 		Ectx:          ctx,
@@ -29,6 +30,7 @@ func New(dbClient *database.Client, ctx context.Context, profiles []*csconfig.Pr
 		Profiles:      profiles,
 		Profiles:      profiles,
 		CAPIChan:      capiChan,
 		CAPIChan:      capiChan,
 		PluginChannel: pluginChannel,
 		PluginChannel: pluginChannel,
+		ConsoleConfig: consoleConfig,
 	}
 	}
 	v1.Middlewares, err = middlewares.NewMiddlewares(dbClient)
 	v1.Middlewares, err = middlewares.NewMiddlewares(dbClient)
 	if err != nil {
 	if err != nil {

+ 9 - 0
pkg/csconfig/api.go

@@ -85,6 +85,8 @@ type LocalApiServerCfg struct {
 	LogMedia               string              `yaml:"-"`
 	LogMedia               string              `yaml:"-"`
 	OnlineClient           *OnlineApiClientCfg `yaml:"online_client"`
 	OnlineClient           *OnlineApiClientCfg `yaml:"online_client"`
 	ProfilesPath           string              `yaml:"profiles_path,omitempty"`
 	ProfilesPath           string              `yaml:"profiles_path,omitempty"`
+	ConsoleConfigPath      string              `yaml:"console_path,omitempty"`
+	ConsoleConfig          *ConsoleConfig      `yaml:"-"`
 	Profiles               []*ProfileCfg       `yaml:"-"`
 	Profiles               []*ProfileCfg       `yaml:"-"`
 	LogLevel               *log.Level          `yaml:"log_level"`
 	LogLevel               *log.Level          `yaml:"log_level"`
 	UseForwardedForHeaders bool                `yaml:"use_forwarded_for_headers,omitempty"`
 	UseForwardedForHeaders bool                `yaml:"use_forwarded_for_headers,omitempty"`
@@ -114,6 +116,13 @@ func (c *Config) LoadAPIServer() error {
 		if err := c.API.Server.LoadProfiles(); err != nil {
 		if err := c.API.Server.LoadProfiles(); err != nil {
 			return errors.Wrap(err, "while loading profiles for LAPI")
 			return errors.Wrap(err, "while loading profiles for LAPI")
 		}
 		}
+		if c.API.Server.ConsoleConfigPath == "" {
+			c.API.Server.ConsoleConfigPath = DefaultConsoleConfgFilePath
+		}
+		if err := c.API.Server.LoadConsoleConfig(); err != nil {
+			return errors.Wrap(err, "while loading console options")
+		}
+
 		if c.API.Server.OnlineClient != nil && c.API.Server.OnlineClient.CredentialsFilePath != "" {
 		if c.API.Server.OnlineClient != nil && c.API.Server.OnlineClient.CredentialsFilePath != "" {
 			if err := c.API.Server.OnlineClient.Load(); err != nil {
 			if err := c.API.Server.OnlineClient.Load(); err != nil {
 				return errors.Wrap(err, "loading online client credentials")
 				return errors.Wrap(err, "loading online client credentials")

+ 7 - 0
pkg/csconfig/api_test.go

@@ -8,6 +8,7 @@ import (
 	"strings"
 	"strings"
 	"testing"
 	"testing"
 
 
+	"github.com/crowdsecurity/crowdsec/pkg/types"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
 	"gopkg.in/yaml.v2"
 	"gopkg.in/yaml.v2"
 )
 )
@@ -206,6 +207,12 @@ func TestLoadAPIServer(t *testing.T) {
 					DbPath: "./tests/test.db",
 					DbPath: "./tests/test.db",
 					Type:   "sqlite",
 					Type:   "sqlite",
 				},
 				},
+				ConsoleConfigPath: "/etc/crowdsec/console_config.yaml",
+				ConsoleConfig: &ConsoleConfig{
+					ShareManualDecisions:  types.BoolPtr(false),
+					ShareTaintedScenarios: types.BoolPtr(true),
+					ShareCustomScenarios:  types.BoolPtr(true),
+				},
 				LogDir:   LogDirFullPath,
 				LogDir:   LogDirFullPath,
 				LogMedia: "stdout",
 				LogMedia: "stdout",
 				OnlineClient: &OnlineApiClientCfg{
 				OnlineClient: &OnlineApiClientCfg{

+ 83 - 0
pkg/csconfig/console.go

@@ -0,0 +1,83 @@
+package csconfig
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+
+	"github.com/crowdsecurity/crowdsec/pkg/types"
+	"github.com/pkg/errors"
+	log "github.com/sirupsen/logrus"
+	"gopkg.in/yaml.v2"
+)
+
+const (
+	SEND_CUSTOM_SCENARIOS  = "custom"
+	SEND_TAINTED_SCENARIOS = "tainted"
+	SEND_MANUAL_SCENARIOS  = "manual"
+)
+
+var DefaultConsoleConfgFilePath = "/etc/crowdsec/console_config.yaml"
+
+var CONSOLE_CONFIGS = []string{SEND_CUSTOM_SCENARIOS, SEND_MANUAL_SCENARIOS, SEND_TAINTED_SCENARIOS}
+
+type ConsoleConfig struct {
+	ShareManualDecisions  *bool `yaml:"share_manual_decisions"`
+	ShareTaintedScenarios *bool `yaml:"share_tainted"`
+	ShareCustomScenarios  *bool `yaml:"share_custom"`
+}
+
+func (c *LocalApiServerCfg) LoadConsoleConfig() error {
+	c.ConsoleConfig = &ConsoleConfig{}
+	if _, err := os.Stat(c.ConsoleConfigPath); err != nil && os.IsNotExist(err) {
+		log.Debugf("no console configuration to load")
+		c.ConsoleConfig.ShareCustomScenarios = types.BoolPtr(true)
+		c.ConsoleConfig.ShareTaintedScenarios = types.BoolPtr(true)
+		c.ConsoleConfig.ShareManualDecisions = types.BoolPtr(false)
+		return nil
+	}
+
+	yamlFile, err := ioutil.ReadFile(c.ConsoleConfigPath)
+	if err != nil {
+		return fmt.Errorf("reading console config file '%s': %s", c.ConsoleConfigPath, err)
+	}
+	err = yaml.Unmarshal(yamlFile, c.ConsoleConfig)
+	if err != nil {
+		return fmt.Errorf("unmarshaling console config file '%s': %s", c.ConsoleConfigPath, err)
+	}
+
+	if c.ConsoleConfig.ShareCustomScenarios == nil {
+		log.Debugf("no share_custom scenarios found, setting to true")
+		c.ConsoleConfig.ShareCustomScenarios = types.BoolPtr(true)
+	}
+	if c.ConsoleConfig.ShareTaintedScenarios == nil {
+		log.Debugf("no share_tainted scenarios found, setting to true")
+		c.ConsoleConfig.ShareTaintedScenarios = types.BoolPtr(true)
+	}
+	if c.ConsoleConfig.ShareManualDecisions == nil {
+		log.Debugf("no share_manual scenarios found, setting to false")
+		c.ConsoleConfig.ShareManualDecisions = types.BoolPtr(false)
+	}
+	log.Debugf("Console configuration '%s' loaded successfully", c.ConsoleConfigPath)
+
+	return nil
+}
+
+func (c *LocalApiServerCfg) DumpConsoleConfig() error {
+	var out []byte
+	var err error
+
+	if out, err = yaml.Marshal(c.ConsoleConfig); err != nil {
+		return errors.Wrapf(err, "while marshaling ConsoleConfig (for %s)", c.ConsoleConfigPath)
+	}
+	if c.ConsoleConfigPath == "" {
+		log.Debugf("Empty console_path, defaulting to %s", DefaultConsoleConfgFilePath)
+		c.ConsoleConfigPath = DefaultConsoleConfgFilePath
+	}
+
+	if err := os.WriteFile(c.ConsoleConfigPath, out, 0600); err != nil {
+		return errors.Wrapf(err, "while dumping console config to %s", c.ConsoleConfigPath)
+	}
+
+	return nil
+}

+ 0 - 1
pkg/database/decisions.go

@@ -15,7 +15,6 @@ import (
 
 
 func BuildDecisionRequestWithFilter(query *ent.DecisionQuery, filter map[string][]string) (*ent.DecisionQuery, error) {
 func BuildDecisionRequestWithFilter(query *ent.DecisionQuery, filter map[string][]string) (*ent.DecisionQuery, error) {
 
 
-	//func BuildDecisionRequestWithFilter(query *ent.Query, filter map[string][]string) (*ent.DecisionQuery, error) {
 	var err error
 	var err error
 	var start_ip, start_sfx, end_ip, end_sfx int64
 	var start_ip, start_sfx, end_ip, end_sfx int64
 	var ip_sz int
 	var ip_sz int

+ 11 - 1
pkg/database/ent/machine.go

@@ -20,6 +20,8 @@ type Machine struct {
 	CreatedAt time.Time `json:"created_at,omitempty"`
 	CreatedAt time.Time `json:"created_at,omitempty"`
 	// UpdatedAt holds the value of the "updated_at" field.
 	// UpdatedAt holds the value of the "updated_at" field.
 	UpdatedAt time.Time `json:"updated_at,omitempty"`
 	UpdatedAt time.Time `json:"updated_at,omitempty"`
+	// LastPush holds the value of the "last_push" field.
+	LastPush time.Time `json:"last_push,omitempty"`
 	// MachineId holds the value of the "machineId" field.
 	// MachineId holds the value of the "machineId" field.
 	MachineId string `json:"machineId,omitempty"`
 	MachineId string `json:"machineId,omitempty"`
 	// Password holds the value of the "password" field.
 	// Password holds the value of the "password" field.
@@ -68,7 +70,7 @@ func (*Machine) scanValues(columns []string) ([]interface{}, error) {
 			values[i] = new(sql.NullInt64)
 			values[i] = new(sql.NullInt64)
 		case machine.FieldMachineId, machine.FieldPassword, machine.FieldIpAddress, machine.FieldScenarios, machine.FieldVersion, machine.FieldStatus:
 		case machine.FieldMachineId, machine.FieldPassword, machine.FieldIpAddress, machine.FieldScenarios, machine.FieldVersion, machine.FieldStatus:
 			values[i] = new(sql.NullString)
 			values[i] = new(sql.NullString)
-		case machine.FieldCreatedAt, machine.FieldUpdatedAt:
+		case machine.FieldCreatedAt, machine.FieldUpdatedAt, machine.FieldLastPush:
 			values[i] = new(sql.NullTime)
 			values[i] = new(sql.NullTime)
 		default:
 		default:
 			return nil, fmt.Errorf("unexpected column %q for type Machine", columns[i])
 			return nil, fmt.Errorf("unexpected column %q for type Machine", columns[i])
@@ -103,6 +105,12 @@ func (m *Machine) assignValues(columns []string, values []interface{}) error {
 			} else if value.Valid {
 			} else if value.Valid {
 				m.UpdatedAt = value.Time
 				m.UpdatedAt = value.Time
 			}
 			}
+		case machine.FieldLastPush:
+			if value, ok := values[i].(*sql.NullTime); !ok {
+				return fmt.Errorf("unexpected type %T for field last_push", values[i])
+			} else if value.Valid {
+				m.LastPush = value.Time
+			}
 		case machine.FieldMachineId:
 		case machine.FieldMachineId:
 			if value, ok := values[i].(*sql.NullString); !ok {
 			if value, ok := values[i].(*sql.NullString); !ok {
 				return fmt.Errorf("unexpected type %T for field machineId", values[i])
 				return fmt.Errorf("unexpected type %T for field machineId", values[i])
@@ -182,6 +190,8 @@ func (m *Machine) String() string {
 	builder.WriteString(m.CreatedAt.Format(time.ANSIC))
 	builder.WriteString(m.CreatedAt.Format(time.ANSIC))
 	builder.WriteString(", updated_at=")
 	builder.WriteString(", updated_at=")
 	builder.WriteString(m.UpdatedAt.Format(time.ANSIC))
 	builder.WriteString(m.UpdatedAt.Format(time.ANSIC))
+	builder.WriteString(", last_push=")
+	builder.WriteString(m.LastPush.Format(time.ANSIC))
 	builder.WriteString(", machineId=")
 	builder.WriteString(", machineId=")
 	builder.WriteString(m.MachineId)
 	builder.WriteString(m.MachineId)
 	builder.WriteString(", password=<sensitive>")
 	builder.WriteString(", password=<sensitive>")

+ 5 - 0
pkg/database/ent/machine/machine.go

@@ -15,6 +15,8 @@ const (
 	FieldCreatedAt = "created_at"
 	FieldCreatedAt = "created_at"
 	// FieldUpdatedAt holds the string denoting the updated_at field in the database.
 	// FieldUpdatedAt holds the string denoting the updated_at field in the database.
 	FieldUpdatedAt = "updated_at"
 	FieldUpdatedAt = "updated_at"
+	// FieldLastPush holds the string denoting the last_push field in the database.
+	FieldLastPush = "last_push"
 	// FieldMachineId holds the string denoting the machineid field in the database.
 	// FieldMachineId holds the string denoting the machineid field in the database.
 	FieldMachineId = "machine_id"
 	FieldMachineId = "machine_id"
 	// FieldPassword holds the string denoting the password field in the database.
 	// FieldPassword holds the string denoting the password field in the database.
@@ -47,6 +49,7 @@ var Columns = []string{
 	FieldID,
 	FieldID,
 	FieldCreatedAt,
 	FieldCreatedAt,
 	FieldUpdatedAt,
 	FieldUpdatedAt,
+	FieldLastPush,
 	FieldMachineId,
 	FieldMachineId,
 	FieldPassword,
 	FieldPassword,
 	FieldIpAddress,
 	FieldIpAddress,
@@ -71,6 +74,8 @@ var (
 	DefaultCreatedAt func() time.Time
 	DefaultCreatedAt func() time.Time
 	// DefaultUpdatedAt holds the default value on creation for the "updated_at" field.
 	// DefaultUpdatedAt holds the default value on creation for the "updated_at" field.
 	DefaultUpdatedAt func() time.Time
 	DefaultUpdatedAt func() time.Time
+	// DefaultLastPush holds the default value on creation for the "last_push" field.
+	DefaultLastPush func() time.Time
 	// ScenariosValidator is a validator for the "scenarios" field. It is called by the builders before save.
 	// ScenariosValidator is a validator for the "scenarios" field. It is called by the builders before save.
 	ScenariosValidator func(string) error
 	ScenariosValidator func(string) error
 	// DefaultIsValidated holds the default value on creation for the "isValidated" field.
 	// DefaultIsValidated holds the default value on creation for the "isValidated" field.

+ 97 - 0
pkg/database/ent/machine/where.go

@@ -107,6 +107,13 @@ func UpdatedAt(v time.Time) predicate.Machine {
 	})
 	})
 }
 }
 
 
+// LastPush applies equality check predicate on the "last_push" field. It's identical to LastPushEQ.
+func LastPush(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.EQ(s.C(FieldLastPush), v))
+	})
+}
+
 // MachineId applies equality check predicate on the "machineId" field. It's identical to MachineIdEQ.
 // MachineId applies equality check predicate on the "machineId" field. It's identical to MachineIdEQ.
 func MachineId(v string) predicate.Machine {
 func MachineId(v string) predicate.Machine {
 	return predicate.Machine(func(s *sql.Selector) {
 	return predicate.Machine(func(s *sql.Selector) {
@@ -308,6 +315,96 @@ func UpdatedAtLTE(v time.Time) predicate.Machine {
 	})
 	})
 }
 }
 
 
+// LastPushEQ applies the EQ predicate on the "last_push" field.
+func LastPushEQ(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.EQ(s.C(FieldLastPush), v))
+	})
+}
+
+// LastPushNEQ applies the NEQ predicate on the "last_push" field.
+func LastPushNEQ(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.NEQ(s.C(FieldLastPush), v))
+	})
+}
+
+// LastPushIn applies the In predicate on the "last_push" field.
+func LastPushIn(vs ...time.Time) predicate.Machine {
+	v := make([]interface{}, len(vs))
+	for i := range v {
+		v[i] = vs[i]
+	}
+	return predicate.Machine(func(s *sql.Selector) {
+		// if not arguments were provided, append the FALSE constants,
+		// since we can't apply "IN ()". This will make this predicate falsy.
+		if len(v) == 0 {
+			s.Where(sql.False())
+			return
+		}
+		s.Where(sql.In(s.C(FieldLastPush), v...))
+	})
+}
+
+// LastPushNotIn applies the NotIn predicate on the "last_push" field.
+func LastPushNotIn(vs ...time.Time) predicate.Machine {
+	v := make([]interface{}, len(vs))
+	for i := range v {
+		v[i] = vs[i]
+	}
+	return predicate.Machine(func(s *sql.Selector) {
+		// if not arguments were provided, append the FALSE constants,
+		// since we can't apply "IN ()". This will make this predicate falsy.
+		if len(v) == 0 {
+			s.Where(sql.False())
+			return
+		}
+		s.Where(sql.NotIn(s.C(FieldLastPush), v...))
+	})
+}
+
+// LastPushGT applies the GT predicate on the "last_push" field.
+func LastPushGT(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.GT(s.C(FieldLastPush), v))
+	})
+}
+
+// LastPushGTE applies the GTE predicate on the "last_push" field.
+func LastPushGTE(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.GTE(s.C(FieldLastPush), v))
+	})
+}
+
+// LastPushLT applies the LT predicate on the "last_push" field.
+func LastPushLT(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.LT(s.C(FieldLastPush), v))
+	})
+}
+
+// LastPushLTE applies the LTE predicate on the "last_push" field.
+func LastPushLTE(v time.Time) predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.LTE(s.C(FieldLastPush), v))
+	})
+}
+
+// LastPushIsNil applies the IsNil predicate on the "last_push" field.
+func LastPushIsNil() predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.IsNull(s.C(FieldLastPush)))
+	})
+}
+
+// LastPushNotNil applies the NotNil predicate on the "last_push" field.
+func LastPushNotNil() predicate.Machine {
+	return predicate.Machine(func(s *sql.Selector) {
+		s.Where(sql.NotNull(s.C(FieldLastPush)))
+	})
+}
+
 // MachineIdEQ applies the EQ predicate on the "machineId" field.
 // MachineIdEQ applies the EQ predicate on the "machineId" field.
 func MachineIdEQ(v string) predicate.Machine {
 func MachineIdEQ(v string) predicate.Machine {
 	return predicate.Machine(func(s *sql.Selector) {
 	return predicate.Machine(func(s *sql.Selector) {

+ 26 - 0
pkg/database/ent/machine_create.go

@@ -49,6 +49,20 @@ func (mc *MachineCreate) SetNillableUpdatedAt(t *time.Time) *MachineCreate {
 	return mc
 	return mc
 }
 }
 
 
+// SetLastPush sets the "last_push" field.
+func (mc *MachineCreate) SetLastPush(t time.Time) *MachineCreate {
+	mc.mutation.SetLastPush(t)
+	return mc
+}
+
+// SetNillableLastPush sets the "last_push" field if the given value is not nil.
+func (mc *MachineCreate) SetNillableLastPush(t *time.Time) *MachineCreate {
+	if t != nil {
+		mc.SetLastPush(*t)
+	}
+	return mc
+}
+
 // SetMachineId sets the "machineId" field.
 // SetMachineId sets the "machineId" field.
 func (mc *MachineCreate) SetMachineId(s string) *MachineCreate {
 func (mc *MachineCreate) SetMachineId(s string) *MachineCreate {
 	mc.mutation.SetMachineId(s)
 	mc.mutation.SetMachineId(s)
@@ -217,6 +231,10 @@ func (mc *MachineCreate) defaults() {
 		v := machine.DefaultUpdatedAt()
 		v := machine.DefaultUpdatedAt()
 		mc.mutation.SetUpdatedAt(v)
 		mc.mutation.SetUpdatedAt(v)
 	}
 	}
+	if _, ok := mc.mutation.LastPush(); !ok {
+		v := machine.DefaultLastPush()
+		mc.mutation.SetLastPush(v)
+	}
 	if _, ok := mc.mutation.IsValidated(); !ok {
 	if _, ok := mc.mutation.IsValidated(); !ok {
 		v := machine.DefaultIsValidated
 		v := machine.DefaultIsValidated
 		mc.mutation.SetIsValidated(v)
 		mc.mutation.SetIsValidated(v)
@@ -291,6 +309,14 @@ func (mc *MachineCreate) createSpec() (*Machine, *sqlgraph.CreateSpec) {
 		})
 		})
 		_node.UpdatedAt = value
 		_node.UpdatedAt = value
 	}
 	}
+	if value, ok := mc.mutation.LastPush(); ok {
+		_spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{
+			Type:   field.TypeTime,
+			Value:  value,
+			Column: machine.FieldLastPush,
+		})
+		_node.LastPush = value
+	}
 	if value, ok := mc.mutation.MachineId(); ok {
 	if value, ok := mc.mutation.MachineId(); ok {
 		_spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{
 		_spec.Fields = append(_spec.Fields, &sqlgraph.FieldSpec{
 			Type:   field.TypeString,
 			Type:   field.TypeString,

+ 66 - 0
pkg/database/ent/machine_update.go

@@ -56,6 +56,26 @@ func (mu *MachineUpdate) SetNillableUpdatedAt(t *time.Time) *MachineUpdate {
 	return mu
 	return mu
 }
 }
 
 
+// SetLastPush sets the "last_push" field.
+func (mu *MachineUpdate) SetLastPush(t time.Time) *MachineUpdate {
+	mu.mutation.SetLastPush(t)
+	return mu
+}
+
+// SetNillableLastPush sets the "last_push" field if the given value is not nil.
+func (mu *MachineUpdate) SetNillableLastPush(t *time.Time) *MachineUpdate {
+	if t != nil {
+		mu.SetLastPush(*t)
+	}
+	return mu
+}
+
+// ClearLastPush clears the value of the "last_push" field.
+func (mu *MachineUpdate) ClearLastPush() *MachineUpdate {
+	mu.mutation.ClearLastPush()
+	return mu
+}
+
 // SetMachineId sets the "machineId" field.
 // SetMachineId sets the "machineId" field.
 func (mu *MachineUpdate) SetMachineId(s string) *MachineUpdate {
 func (mu *MachineUpdate) SetMachineId(s string) *MachineUpdate {
 	mu.mutation.SetMachineId(s)
 	mu.mutation.SetMachineId(s)
@@ -291,6 +311,19 @@ func (mu *MachineUpdate) sqlSave(ctx context.Context) (n int, err error) {
 			Column: machine.FieldUpdatedAt,
 			Column: machine.FieldUpdatedAt,
 		})
 		})
 	}
 	}
+	if value, ok := mu.mutation.LastPush(); ok {
+		_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
+			Type:   field.TypeTime,
+			Value:  value,
+			Column: machine.FieldLastPush,
+		})
+	}
+	if mu.mutation.LastPushCleared() {
+		_spec.Fields.Clear = append(_spec.Fields.Clear, &sqlgraph.FieldSpec{
+			Type:   field.TypeTime,
+			Column: machine.FieldLastPush,
+		})
+	}
 	if value, ok := mu.mutation.MachineId(); ok {
 	if value, ok := mu.mutation.MachineId(); ok {
 		_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
 		_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
 			Type:   field.TypeString,
 			Type:   field.TypeString,
@@ -459,6 +492,26 @@ func (muo *MachineUpdateOne) SetNillableUpdatedAt(t *time.Time) *MachineUpdateOn
 	return muo
 	return muo
 }
 }
 
 
+// SetLastPush sets the "last_push" field.
+func (muo *MachineUpdateOne) SetLastPush(t time.Time) *MachineUpdateOne {
+	muo.mutation.SetLastPush(t)
+	return muo
+}
+
+// SetNillableLastPush sets the "last_push" field if the given value is not nil.
+func (muo *MachineUpdateOne) SetNillableLastPush(t *time.Time) *MachineUpdateOne {
+	if t != nil {
+		muo.SetLastPush(*t)
+	}
+	return muo
+}
+
+// ClearLastPush clears the value of the "last_push" field.
+func (muo *MachineUpdateOne) ClearLastPush() *MachineUpdateOne {
+	muo.mutation.ClearLastPush()
+	return muo
+}
+
 // SetMachineId sets the "machineId" field.
 // SetMachineId sets the "machineId" field.
 func (muo *MachineUpdateOne) SetMachineId(s string) *MachineUpdateOne {
 func (muo *MachineUpdateOne) SetMachineId(s string) *MachineUpdateOne {
 	muo.mutation.SetMachineId(s)
 	muo.mutation.SetMachineId(s)
@@ -718,6 +771,19 @@ func (muo *MachineUpdateOne) sqlSave(ctx context.Context) (_node *Machine, err e
 			Column: machine.FieldUpdatedAt,
 			Column: machine.FieldUpdatedAt,
 		})
 		})
 	}
 	}
+	if value, ok := muo.mutation.LastPush(); ok {
+		_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
+			Type:   field.TypeTime,
+			Value:  value,
+			Column: machine.FieldLastPush,
+		})
+	}
+	if muo.mutation.LastPushCleared() {
+		_spec.Fields.Clear = append(_spec.Fields.Clear, &sqlgraph.FieldSpec{
+			Type:   field.TypeTime,
+			Column: machine.FieldLastPush,
+		})
+	}
 	if value, ok := muo.mutation.MachineId(); ok {
 	if value, ok := muo.mutation.MachineId(); ok {
 		_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
 		_spec.Fields.Set = append(_spec.Fields.Set, &sqlgraph.FieldSpec{
 			Type:   field.TypeString,
 			Type:   field.TypeString,

+ 1 - 0
pkg/database/ent/migrate/schema.go

@@ -137,6 +137,7 @@ var (
 		{Name: "id", Type: field.TypeInt, Increment: true},
 		{Name: "id", Type: field.TypeInt, Increment: true},
 		{Name: "created_at", Type: field.TypeTime},
 		{Name: "created_at", Type: field.TypeTime},
 		{Name: "updated_at", Type: field.TypeTime},
 		{Name: "updated_at", Type: field.TypeTime},
+		{Name: "last_push", Type: field.TypeTime, Nullable: true},
 		{Name: "machine_id", Type: field.TypeString, Unique: true},
 		{Name: "machine_id", Type: field.TypeString, Unique: true},
 		{Name: "password", Type: field.TypeString},
 		{Name: "password", Type: field.TypeString},
 		{Name: "ip_address", Type: field.TypeString},
 		{Name: "ip_address", Type: field.TypeString},

+ 74 - 1
pkg/database/ent/mutation.go

@@ -4986,6 +4986,7 @@ type MachineMutation struct {
 	id            *int
 	id            *int
 	created_at    *time.Time
 	created_at    *time.Time
 	updated_at    *time.Time
 	updated_at    *time.Time
+	last_push     *time.Time
 	machineId     *string
 	machineId     *string
 	password      *string
 	password      *string
 	ipAddress     *string
 	ipAddress     *string
@@ -5153,6 +5154,55 @@ func (m *MachineMutation) ResetUpdatedAt() {
 	m.updated_at = nil
 	m.updated_at = nil
 }
 }
 
 
+// SetLastPush sets the "last_push" field.
+func (m *MachineMutation) SetLastPush(t time.Time) {
+	m.last_push = &t
+}
+
+// LastPush returns the value of the "last_push" field in the mutation.
+func (m *MachineMutation) LastPush() (r time.Time, exists bool) {
+	v := m.last_push
+	if v == nil {
+		return
+	}
+	return *v, true
+}
+
+// OldLastPush returns the old "last_push" field's value of the Machine entity.
+// If the Machine object wasn't provided to the builder, the object is fetched from the database.
+// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
+func (m *MachineMutation) OldLastPush(ctx context.Context) (v time.Time, err error) {
+	if !m.op.Is(OpUpdateOne) {
+		return v, fmt.Errorf("OldLastPush is only allowed on UpdateOne operations")
+	}
+	if m.id == nil || m.oldValue == nil {
+		return v, fmt.Errorf("OldLastPush requires an ID field in the mutation")
+	}
+	oldValue, err := m.oldValue(ctx)
+	if err != nil {
+		return v, fmt.Errorf("querying old value for OldLastPush: %w", err)
+	}
+	return oldValue.LastPush, nil
+}
+
+// ClearLastPush clears the value of the "last_push" field.
+func (m *MachineMutation) ClearLastPush() {
+	m.last_push = nil
+	m.clearedFields[machine.FieldLastPush] = struct{}{}
+}
+
+// LastPushCleared returns if the "last_push" field was cleared in this mutation.
+func (m *MachineMutation) LastPushCleared() bool {
+	_, ok := m.clearedFields[machine.FieldLastPush]
+	return ok
+}
+
+// ResetLastPush resets all changes to the "last_push" field.
+func (m *MachineMutation) ResetLastPush() {
+	m.last_push = nil
+	delete(m.clearedFields, machine.FieldLastPush)
+}
+
 // SetMachineId sets the "machineId" field.
 // SetMachineId sets the "machineId" field.
 func (m *MachineMutation) SetMachineId(s string) {
 func (m *MachineMutation) SetMachineId(s string) {
 	m.machineId = &s
 	m.machineId = &s
@@ -5517,13 +5567,16 @@ func (m *MachineMutation) Type() string {
 // order to get all numeric fields that were incremented/decremented, call
 // order to get all numeric fields that were incremented/decremented, call
 // AddedFields().
 // AddedFields().
 func (m *MachineMutation) Fields() []string {
 func (m *MachineMutation) Fields() []string {
-	fields := make([]string, 0, 9)
+	fields := make([]string, 0, 10)
 	if m.created_at != nil {
 	if m.created_at != nil {
 		fields = append(fields, machine.FieldCreatedAt)
 		fields = append(fields, machine.FieldCreatedAt)
 	}
 	}
 	if m.updated_at != nil {
 	if m.updated_at != nil {
 		fields = append(fields, machine.FieldUpdatedAt)
 		fields = append(fields, machine.FieldUpdatedAt)
 	}
 	}
+	if m.last_push != nil {
+		fields = append(fields, machine.FieldLastPush)
+	}
 	if m.machineId != nil {
 	if m.machineId != nil {
 		fields = append(fields, machine.FieldMachineId)
 		fields = append(fields, machine.FieldMachineId)
 	}
 	}
@@ -5557,6 +5610,8 @@ func (m *MachineMutation) Field(name string) (ent.Value, bool) {
 		return m.CreatedAt()
 		return m.CreatedAt()
 	case machine.FieldUpdatedAt:
 	case machine.FieldUpdatedAt:
 		return m.UpdatedAt()
 		return m.UpdatedAt()
+	case machine.FieldLastPush:
+		return m.LastPush()
 	case machine.FieldMachineId:
 	case machine.FieldMachineId:
 		return m.MachineId()
 		return m.MachineId()
 	case machine.FieldPassword:
 	case machine.FieldPassword:
@@ -5584,6 +5639,8 @@ func (m *MachineMutation) OldField(ctx context.Context, name string) (ent.Value,
 		return m.OldCreatedAt(ctx)
 		return m.OldCreatedAt(ctx)
 	case machine.FieldUpdatedAt:
 	case machine.FieldUpdatedAt:
 		return m.OldUpdatedAt(ctx)
 		return m.OldUpdatedAt(ctx)
+	case machine.FieldLastPush:
+		return m.OldLastPush(ctx)
 	case machine.FieldMachineId:
 	case machine.FieldMachineId:
 		return m.OldMachineId(ctx)
 		return m.OldMachineId(ctx)
 	case machine.FieldPassword:
 	case machine.FieldPassword:
@@ -5621,6 +5678,13 @@ func (m *MachineMutation) SetField(name string, value ent.Value) error {
 		}
 		}
 		m.SetUpdatedAt(v)
 		m.SetUpdatedAt(v)
 		return nil
 		return nil
+	case machine.FieldLastPush:
+		v, ok := value.(time.Time)
+		if !ok {
+			return fmt.Errorf("unexpected type %T for field %s", value, name)
+		}
+		m.SetLastPush(v)
+		return nil
 	case machine.FieldMachineId:
 	case machine.FieldMachineId:
 		v, ok := value.(string)
 		v, ok := value.(string)
 		if !ok {
 		if !ok {
@@ -5700,6 +5764,9 @@ func (m *MachineMutation) AddField(name string, value ent.Value) error {
 // mutation.
 // mutation.
 func (m *MachineMutation) ClearedFields() []string {
 func (m *MachineMutation) ClearedFields() []string {
 	var fields []string
 	var fields []string
+	if m.FieldCleared(machine.FieldLastPush) {
+		fields = append(fields, machine.FieldLastPush)
+	}
 	if m.FieldCleared(machine.FieldScenarios) {
 	if m.FieldCleared(machine.FieldScenarios) {
 		fields = append(fields, machine.FieldScenarios)
 		fields = append(fields, machine.FieldScenarios)
 	}
 	}
@@ -5723,6 +5790,9 @@ func (m *MachineMutation) FieldCleared(name string) bool {
 // error if the field is not defined in the schema.
 // error if the field is not defined in the schema.
 func (m *MachineMutation) ClearField(name string) error {
 func (m *MachineMutation) ClearField(name string) error {
 	switch name {
 	switch name {
+	case machine.FieldLastPush:
+		m.ClearLastPush()
+		return nil
 	case machine.FieldScenarios:
 	case machine.FieldScenarios:
 		m.ClearScenarios()
 		m.ClearScenarios()
 		return nil
 		return nil
@@ -5746,6 +5816,9 @@ func (m *MachineMutation) ResetField(name string) error {
 	case machine.FieldUpdatedAt:
 	case machine.FieldUpdatedAt:
 		m.ResetUpdatedAt()
 		m.ResetUpdatedAt()
 		return nil
 		return nil
+	case machine.FieldLastPush:
+		m.ResetLastPush()
+		return nil
 	case machine.FieldMachineId:
 	case machine.FieldMachineId:
 		m.ResetMachineId()
 		m.ResetMachineId()
 		return nil
 		return nil

+ 6 - 2
pkg/database/ent/runtime.go

@@ -112,12 +112,16 @@ func init() {
 	machineDescUpdatedAt := machineFields[1].Descriptor()
 	machineDescUpdatedAt := machineFields[1].Descriptor()
 	// machine.DefaultUpdatedAt holds the default value on creation for the updated_at field.
 	// machine.DefaultUpdatedAt holds the default value on creation for the updated_at field.
 	machine.DefaultUpdatedAt = machineDescUpdatedAt.Default.(func() time.Time)
 	machine.DefaultUpdatedAt = machineDescUpdatedAt.Default.(func() time.Time)
+	// machineDescLastPush is the schema descriptor for last_push field.
+	machineDescLastPush := machineFields[2].Descriptor()
+	// machine.DefaultLastPush holds the default value on creation for the last_push field.
+	machine.DefaultLastPush = machineDescLastPush.Default.(func() time.Time)
 	// machineDescScenarios is the schema descriptor for scenarios field.
 	// machineDescScenarios is the schema descriptor for scenarios field.
-	machineDescScenarios := machineFields[5].Descriptor()
+	machineDescScenarios := machineFields[6].Descriptor()
 	// machine.ScenariosValidator is a validator for the "scenarios" field. It is called by the builders before save.
 	// machine.ScenariosValidator is a validator for the "scenarios" field. It is called by the builders before save.
 	machine.ScenariosValidator = machineDescScenarios.Validators[0].(func(string) error)
 	machine.ScenariosValidator = machineDescScenarios.Validators[0].(func(string) error)
 	// machineDescIsValidated is the schema descriptor for isValidated field.
 	// machineDescIsValidated is the schema descriptor for isValidated field.
-	machineDescIsValidated := machineFields[7].Descriptor()
+	machineDescIsValidated := machineFields[8].Descriptor()
 	// machine.DefaultIsValidated holds the default value on creation for the isValidated field.
 	// machine.DefaultIsValidated holds the default value on creation for the isValidated field.
 	machine.DefaultIsValidated = machineDescIsValidated.Default.(bool)
 	machine.DefaultIsValidated = machineDescIsValidated.Default.(bool)
 	metaFields := schema.Meta{}.Fields()
 	metaFields := schema.Meta{}.Fields()

+ 2 - 0
pkg/database/ent/schema/machine.go

@@ -20,6 +20,8 @@ func (Machine) Fields() []ent.Field {
 			Default(time.Now),
 			Default(time.Now),
 		field.Time("updated_at").
 		field.Time("updated_at").
 			Default(time.Now),
 			Default(time.Now),
+		field.Time("last_push").
+			Default(time.Now).Optional(),
 		field.String("machineId").Unique(),
 		field.String("machineId").Unique(),
 		field.String("password").Sensitive(),
 		field.String("password").Sensitive(),
 		field.String("ipAddress"),
 		field.String("ipAddress"),

+ 8 - 0
pkg/database/machines.go

@@ -110,6 +110,14 @@ func (c *Client) DeleteWatcher(name string) error {
 	return nil
 	return nil
 }
 }
 
 
+func (c *Client) UpdateMachineLastPush(machineID string) error {
+	_, err := c.Ent.Machine.Update().Where(machine.MachineIdEQ(machineID)).SetLastPush(time.Now()).Save(c.CTX)
+	if err != nil {
+		return errors.Wrapf(UpdateFail, "updating machine last_push: %s", err)
+	}
+	return nil
+}
+
 func (c *Client) UpdateMachineScenarios(scenarios string, ID int) error {
 func (c *Client) UpdateMachineScenarios(scenarios string, ID int) error {
 	_, err := c.Ent.Machine.UpdateOneID(ID).
 	_, err := c.Ent.Machine.UpdateOneID(ID).
 		SetUpdatedAt(time.Now()).
 		SetUpdatedAt(time.Now()).

+ 17 - 0
pkg/models/add_signals_request_item.go

@@ -37,6 +37,10 @@ type AddSignalsRequestItem struct {
 	// Required: true
 	// Required: true
 	ScenarioHash *string `json:"scenario_hash"`
 	ScenarioHash *string `json:"scenario_hash"`
 
 
+	// scenario trust
+	// Required: true
+	ScenarioTrust *string `json:"scenario_trust"`
+
 	// scenario version
 	// scenario version
 	// Required: true
 	// Required: true
 	ScenarioVersion *string `json:"scenario_version"`
 	ScenarioVersion *string `json:"scenario_version"`
@@ -70,6 +74,10 @@ func (m *AddSignalsRequestItem) Validate(formats strfmt.Registry) error {
 		res = append(res, err)
 		res = append(res, err)
 	}
 	}
 
 
+	if err := m.validateScenarioTrust(formats); err != nil {
+		res = append(res, err)
+	}
+
 	if err := m.validateScenarioVersion(formats); err != nil {
 	if err := m.validateScenarioVersion(formats); err != nil {
 		res = append(res, err)
 		res = append(res, err)
 	}
 	}
@@ -119,6 +127,15 @@ func (m *AddSignalsRequestItem) validateScenarioHash(formats strfmt.Registry) er
 	return nil
 	return nil
 }
 }
 
 
+func (m *AddSignalsRequestItem) validateScenarioTrust(formats strfmt.Registry) error {
+
+	if err := validate.Required("scenario_trust", "body", m.ScenarioTrust); err != nil {
+		return err
+	}
+
+	return nil
+}
+
 func (m *AddSignalsRequestItem) validateScenarioVersion(formats strfmt.Registry) error {
 func (m *AddSignalsRequestItem) validateScenarioVersion(formats strfmt.Registry) error {
 
 
 	if err := validate.Required("scenario_version", "body", m.ScenarioVersion); err != nil {
 	if err := validate.Required("scenario_version", "body", m.ScenarioVersion); err != nil {

+ 4 - 1
pkg/models/decision.go

@@ -19,7 +19,7 @@ import (
 // swagger:model Decision
 // swagger:model Decision
 type Decision struct {
 type Decision struct {
 
 
-	// duration
+	// the duration of the decisions
 	// Required: true
 	// Required: true
 	Duration *string `json:"duration"`
 	Duration *string `json:"duration"`
 
 
@@ -47,6 +47,9 @@ type Decision struct {
 	// Required: true
 	// Required: true
 	Type *string `json:"type"`
 	Type *string `json:"type"`
 
 
+	// the date until the decisions must be active
+	Until string `json:"until,omitempty"`
+
 	// the value of the decision scope : an IP, a range, a username, etc
 	// the value of the decision scope : an IP, a range, a username, etc
 	// Required: true
 	// Required: true
 	Value *string `json:"value"`
 	Value *string `json:"value"`

+ 34 - 4
pkg/models/localapi_swagger.yaml

@@ -777,17 +777,34 @@ definitions:
       bouncers:
       bouncers:
         type: array
         type: array
         items:
         items:
-            $ref: '#/definitions/MetricsSoftInfo'
+            $ref: '#/definitions/MetricsBouncerInfo'
       machines:
       machines:
         type: array
         type: array
         items:
         items:
-            $ref: '#/definitions/MetricsSoftInfo'
+            $ref: '#/definitions/MetricsAgentInfo'
     required:
     required:
       - apil_version
       - apil_version
       - bouncers
       - bouncers
       - machines
       - machines
-  MetricsSoftInfo:
-    title: MetricsSoftInfo
+  MetricsBouncerInfo:
+    title: MetricsBouncerInfo
+    description: Software version info (so we can warn users about out-of-date software). The software name and the version are "guessed" from the user-agent
+    type: object
+    properties:
+      custom_name:
+        type: string
+        description: name of the component
+      name:
+        type: string
+        description: bouncer type (firewall, php ...)
+      version:
+        type: string
+        description: software version
+      last_pull:
+        type: string
+        description: last bouncer pull date
+  MetricsAgentInfo:
+    title: MetricsAgentInfo
     description: Software version info (so we can warn users about out-of-date software). The software name and the version are "guessed" from the user-agent
     description: Software version info (so we can warn users about out-of-date software). The software name and the version are "guessed" from the user-agent
     type: object
     type: object
     properties:
     properties:
@@ -797,6 +814,12 @@ definitions:
       version:
       version:
         type: string
         type: string
         description: software version
         description: software version
+      last_update:
+        type: string
+        description: last agent update date
+      last_push:
+        type: string
+        description: last agent push date
   Decision:
   Decision:
     title: Decision
     title: Decision
     type: object
     type: object
@@ -818,7 +841,11 @@ definitions:
         description: 'the value of the decision scope : an IP, a range, a username, etc'
         description: 'the value of the decision scope : an IP, a range, a username, etc'
         type: string
         type: string
       duration:
       duration:
+        description: 'the duration of the decisions'
+        type: string
+      until:
         type: string
         type: string
+        description: 'the date until the decisions must be active'
       scenario:
       scenario:
         type: string
         type: string
       simulated:
       simulated:
@@ -926,6 +953,7 @@ definitions:
     - "source"
     - "source"
     - "start_at"
     - "start_at"
     - "stop_at"
     - "stop_at"
+    - "scenario_trust"
     properties:
     properties:
       scenario_hash:
       scenario_hash:
         type: "string"
         type: "string"
@@ -939,6 +967,8 @@ definitions:
         $ref: "#/definitions/Source"
         $ref: "#/definitions/Source"
       scenario_version:
       scenario_version:
         type: "string"
         type: "string"
+      scenario_trust:
+        type: "string"
       message:
       message:
         type: "string"
         type: "string"
         description: "a human readable message"
         description: "a human readable message"

+ 2 - 2
pkg/models/metrics.go

@@ -26,11 +26,11 @@ type Metrics struct {
 
 
 	// bouncers
 	// bouncers
 	// Required: true
 	// Required: true
-	Bouncers []*MetricsSoftInfo `json:"bouncers"`
+	Bouncers []*MetricsBouncerInfo `json:"bouncers"`
 
 
 	// machines
 	// machines
 	// Required: true
 	// Required: true
-	Machines []*MetricsSoftInfo `json:"machines"`
+	Machines []*MetricsAgentInfo `json:"machines"`
 }
 }
 
 
 // Validate validates this metrics
 // Validate validates this metrics

+ 16 - 10
pkg/models/metrics_soft_info.go → pkg/models/metrics_agent_info.go

@@ -12,12 +12,18 @@ import (
 	"github.com/go-openapi/swag"
 	"github.com/go-openapi/swag"
 )
 )
 
 
-// MetricsSoftInfo MetricsSoftInfo
+// MetricsAgentInfo MetricsAgentInfo
 //
 //
 // Software version info (so we can warn users about out-of-date software). The software name and the version are "guessed" from the user-agent
 // Software version info (so we can warn users about out-of-date software). The software name and the version are "guessed" from the user-agent
 //
 //
-// swagger:model MetricsSoftInfo
-type MetricsSoftInfo struct {
+// swagger:model MetricsAgentInfo
+type MetricsAgentInfo struct {
+
+	// last agent push date
+	LastPush string `json:"last_push,omitempty"`
+
+	// last agent update date
+	LastUpdate string `json:"last_update,omitempty"`
 
 
 	// name of the component
 	// name of the component
 	Name string `json:"name,omitempty"`
 	Name string `json:"name,omitempty"`
@@ -26,18 +32,18 @@ type MetricsSoftInfo struct {
 	Version string `json:"version,omitempty"`
 	Version string `json:"version,omitempty"`
 }
 }
 
 
-// Validate validates this metrics soft info
-func (m *MetricsSoftInfo) Validate(formats strfmt.Registry) error {
+// Validate validates this metrics agent info
+func (m *MetricsAgentInfo) Validate(formats strfmt.Registry) error {
 	return nil
 	return nil
 }
 }
 
 
-// ContextValidate validates this metrics soft info based on context it is used
-func (m *MetricsSoftInfo) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+// ContextValidate validates this metrics agent info based on context it is used
+func (m *MetricsAgentInfo) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
 	return nil
 	return nil
 }
 }
 
 
 // MarshalBinary interface implementation
 // MarshalBinary interface implementation
-func (m *MetricsSoftInfo) MarshalBinary() ([]byte, error) {
+func (m *MetricsAgentInfo) MarshalBinary() ([]byte, error) {
 	if m == nil {
 	if m == nil {
 		return nil, nil
 		return nil, nil
 	}
 	}
@@ -45,8 +51,8 @@ func (m *MetricsSoftInfo) MarshalBinary() ([]byte, error) {
 }
 }
 
 
 // UnmarshalBinary interface implementation
 // UnmarshalBinary interface implementation
-func (m *MetricsSoftInfo) UnmarshalBinary(b []byte) error {
-	var res MetricsSoftInfo
+func (m *MetricsAgentInfo) UnmarshalBinary(b []byte) error {
+	var res MetricsAgentInfo
 	if err := swag.ReadJSON(b, &res); err != nil {
 	if err := swag.ReadJSON(b, &res); err != nil {
 		return err
 		return err
 	}
 	}

+ 61 - 0
pkg/models/metrics_bouncer_info.go

@@ -0,0 +1,61 @@
+// Code generated by go-swagger; DO NOT EDIT.
+
+package models
+
+// This file was generated by the swagger tool.
+// Editing this file might prove futile when you re-run the swagger generate command
+
+import (
+	"context"
+
+	"github.com/go-openapi/strfmt"
+	"github.com/go-openapi/swag"
+)
+
+// MetricsBouncerInfo MetricsBouncerInfo
+//
+// Software version info (so we can warn users about out-of-date software). The software name and the version are "guessed" from the user-agent
+//
+// swagger:model MetricsBouncerInfo
+type MetricsBouncerInfo struct {
+
+	// name of the component
+	CustomName string `json:"custom_name,omitempty"`
+
+	// last bouncer pull date
+	LastPull string `json:"last_pull,omitempty"`
+
+	// bouncer type (firewall, php ...)
+	Name string `json:"name,omitempty"`
+
+	// software version
+	Version string `json:"version,omitempty"`
+}
+
+// Validate validates this metrics bouncer info
+func (m *MetricsBouncerInfo) Validate(formats strfmt.Registry) error {
+	return nil
+}
+
+// ContextValidate validates this metrics bouncer info based on context it is used
+func (m *MetricsBouncerInfo) ContextValidate(ctx context.Context, formats strfmt.Registry) error {
+	return nil
+}
+
+// MarshalBinary interface implementation
+func (m *MetricsBouncerInfo) MarshalBinary() ([]byte, error) {
+	if m == nil {
+		return nil, nil
+	}
+	return swag.WriteJSON(m)
+}
+
+// UnmarshalBinary interface implementation
+func (m *MetricsBouncerInfo) UnmarshalBinary(b []byte) error {
+	var res MetricsBouncerInfo
+	if err := swag.ReadJSON(b, &res); err != nil {
+		return err
+	}
+	*m = res
+	return nil
+}

+ 9 - 0
pkg/types/utils.go

@@ -216,3 +216,12 @@ func Int32Ptr(i int32) *int32 {
 func BoolPtr(b bool) *bool {
 func BoolPtr(b bool) *bool {
 	return &b
 	return &b
 }
 }
+
+func InSlice(str string, slice []string) bool {
+	for _, item := range slice {
+		if str == item {
+			return true
+		}
+	}
+	return false
+}

+ 1 - 0
rpm/SPECS/crowdsec.spec

@@ -60,6 +60,7 @@ install -m 644 -D config/patterns/* -t %{buildroot}%{_sysconfdir}/crowdsec/patte
 install -m 644 -D config/config.yaml %{buildroot}%{_sysconfdir}/crowdsec
 install -m 644 -D config/config.yaml %{buildroot}%{_sysconfdir}/crowdsec
 install -m 644 -D config/simulation.yaml %{buildroot}%{_sysconfdir}/crowdsec
 install -m 644 -D config/simulation.yaml %{buildroot}%{_sysconfdir}/crowdsec
 install -m 644 -D config/profiles.yaml %{buildroot}%{_sysconfdir}/crowdsec
 install -m 644 -D config/profiles.yaml %{buildroot}%{_sysconfdir}/crowdsec
+install -m 644 -D config/console_config.yaml %{buildroot}%{_sysconfdir}/crowdsec
 install -m 644 -D %{SOURCE1} %{buildroot}%{_presetdir}
 install -m 644 -D %{SOURCE1} %{buildroot}%{_presetdir}
 
 
 install -m 551 plugins/notifications/slack/notification-slack %{buildroot}%{_libdir}/%{name}/plugins/
 install -m 551 plugins/notifications/slack/notification-slack %{buildroot}%{_libdir}/%{name}/plugins/

+ 3 - 0
wizard.sh

@@ -31,6 +31,8 @@ CSCLI_BIN="./cmd/crowdsec-cli/cscli"
 CLIENT_SECRETS="local_api_credentials.yaml"
 CLIENT_SECRETS="local_api_credentials.yaml"
 LAPI_SECRETS="online_api_credentials.yaml"
 LAPI_SECRETS="online_api_credentials.yaml"
 
 
+CONSOLE_FILE="console_config.yaml"
+
 BIN_INSTALL_PATH="/usr/local/bin"
 BIN_INSTALL_PATH="/usr/local/bin"
 CROWDSEC_BIN_INSTALLED="${BIN_INSTALL_PATH}/crowdsec"
 CROWDSEC_BIN_INSTALLED="${BIN_INSTALL_PATH}/crowdsec"
 
 
@@ -406,6 +408,7 @@ install_crowdsec() {
     install -v -m 644 -D ./config/acquis.yaml "${CROWDSEC_CONFIG_PATH}" 1> /dev/null || exit
     install -v -m 644 -D ./config/acquis.yaml "${CROWDSEC_CONFIG_PATH}" 1> /dev/null || exit
     install -v -m 644 -D ./config/profiles.yaml "${CROWDSEC_CONFIG_PATH}" 1> /dev/null || exit
     install -v -m 644 -D ./config/profiles.yaml "${CROWDSEC_CONFIG_PATH}" 1> /dev/null || exit
     install -v -m 644 -D ./config/simulation.yaml "${CROWDSEC_CONFIG_PATH}" 1> /dev/null || exit
     install -v -m 644 -D ./config/simulation.yaml "${CROWDSEC_CONFIG_PATH}" 1> /dev/null || exit
+    install -v -m 644 -D ./config/"${CONSOLE_FILE}" "${CROWDSEC_CONFIG_PATH}" 1> /dev/null || exit
 
 
     mkdir -p ${PID_DIR} || exit
     mkdir -p ${PID_DIR} || exit
     PID=${PID_DIR} DATA=${CROWDSEC_DATA_DIR} CFG=${CROWDSEC_CONFIG_PATH} envsubst '$CFG $PID $DATA' < ./config/user.yaml > ${CROWDSEC_CONFIG_PATH}"/user.yaml" || log_fatal "unable to generate user configuration file"
     PID=${PID_DIR} DATA=${CROWDSEC_DATA_DIR} CFG=${CROWDSEC_CONFIG_PATH} envsubst '$CFG $PID $DATA' < ./config/user.yaml > ${CROWDSEC_CONFIG_PATH}"/user.yaml" || log_fatal "unable to generate user configuration file"