[cscli] notifications test command and slight re write (#2391)
* Merge main and apply stash * Rework some of cscli notif stuff and add a generic test which works with non active profiles * Update wording * Fix merge * Final version * Cleanup
This commit is contained in:
parent
15542b78fb
commit
6a61b919e7
|
@ -18,15 +18,19 @@ import (
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/tomb.v2"
|
"gopkg.in/tomb.v2"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/crowdsecurity/go-cs-lib/ptr"
|
||||||
"github.com/crowdsecurity/go-cs-lib/version"
|
"github.com/crowdsecurity/go-cs-lib/version"
|
||||||
|
|
||||||
"github.com/crowdsecurity/crowdsec/pkg/apiclient"
|
"github.com/crowdsecurity/crowdsec/pkg/apiclient"
|
||||||
"github.com/crowdsecurity/crowdsec/pkg/csconfig"
|
"github.com/crowdsecurity/crowdsec/pkg/csconfig"
|
||||||
"github.com/crowdsecurity/crowdsec/pkg/csplugin"
|
"github.com/crowdsecurity/crowdsec/pkg/csplugin"
|
||||||
"github.com/crowdsecurity/crowdsec/pkg/csprofiles"
|
"github.com/crowdsecurity/crowdsec/pkg/csprofiles"
|
||||||
|
"github.com/crowdsecurity/crowdsec/pkg/types"
|
||||||
|
|
||||||
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/require"
|
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/require"
|
||||||
|
"github.com/crowdsecurity/crowdsec/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NotificationsCfg struct {
|
type NotificationsCfg struct {
|
||||||
|
@ -61,11 +65,12 @@ func NewNotificationsCmd() *cobra.Command {
|
||||||
cmdNotifications.AddCommand(NewNotificationsListCmd())
|
cmdNotifications.AddCommand(NewNotificationsListCmd())
|
||||||
cmdNotifications.AddCommand(NewNotificationsInspectCmd())
|
cmdNotifications.AddCommand(NewNotificationsInspectCmd())
|
||||||
cmdNotifications.AddCommand(NewNotificationsReinjectCmd())
|
cmdNotifications.AddCommand(NewNotificationsReinjectCmd())
|
||||||
|
cmdNotifications.AddCommand(NewNotificationsTestCmd())
|
||||||
|
|
||||||
return cmdNotifications
|
return cmdNotifications
|
||||||
}
|
}
|
||||||
|
|
||||||
func getNotificationsConfiguration() (map[string]NotificationsCfg, error) {
|
func getPluginConfigs() (map[string]csplugin.PluginConfig, error) {
|
||||||
pcfgs := map[string]csplugin.PluginConfig{}
|
pcfgs := map[string]csplugin.PluginConfig{}
|
||||||
wf := func(path string, info fs.FileInfo, err error) error {
|
wf := func(path string, info fs.FileInfo, err error) error {
|
||||||
if info == nil {
|
if info == nil {
|
||||||
|
@ -78,6 +83,7 @@ func getNotificationsConfiguration() (map[string]NotificationsCfg, error) {
|
||||||
return fmt.Errorf("loading notifification plugin configuration with %s: %w", name, err)
|
return fmt.Errorf("loading notifification plugin configuration with %s: %w", name, err)
|
||||||
}
|
}
|
||||||
for _, t := range ts {
|
for _, t := range ts {
|
||||||
|
csplugin.SetRequiredFields(&t)
|
||||||
pcfgs[t.Name] = t
|
pcfgs[t.Name] = t
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,8 +93,15 @@ func getNotificationsConfiguration() (map[string]NotificationsCfg, error) {
|
||||||
if err := filepath.Walk(csConfig.ConfigPaths.NotificationDir, wf); err != nil {
|
if err := filepath.Walk(csConfig.ConfigPaths.NotificationDir, wf); err != nil {
|
||||||
return nil, fmt.Errorf("while loading notifification plugin configuration: %w", err)
|
return nil, fmt.Errorf("while loading notifification plugin configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
return pcfgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getProfilesConfigs() (map[string]NotificationsCfg, error) {
|
||||||
// A bit of a tricky stuf now: reconcile profiles and notification plugins
|
// A bit of a tricky stuf now: reconcile profiles and notification plugins
|
||||||
|
pcfgs, err := getPluginConfigs()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
ncfgs := map[string]NotificationsCfg{}
|
ncfgs := map[string]NotificationsCfg{}
|
||||||
profiles, err := csprofiles.NewProfile(csConfig.API.Server.Profiles)
|
profiles, err := csprofiles.NewProfile(csConfig.API.Server.Profiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -131,13 +144,13 @@ func getNotificationsConfiguration() (map[string]NotificationsCfg, error) {
|
||||||
func NewNotificationsListCmd() *cobra.Command {
|
func NewNotificationsListCmd() *cobra.Command {
|
||||||
var cmdNotificationsList = &cobra.Command{
|
var cmdNotificationsList = &cobra.Command{
|
||||||
Use: "list",
|
Use: "list",
|
||||||
Short: "List active notifications plugins",
|
Short: "list active notifications plugins",
|
||||||
Long: `List active notifications plugins`,
|
Long: `list active notifications plugins`,
|
||||||
Example: `cscli notifications list`,
|
Example: `cscli notifications list`,
|
||||||
Args: cobra.ExactArgs(0),
|
Args: cobra.ExactArgs(0),
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(cmd *cobra.Command, arg []string) error {
|
RunE: func(cmd *cobra.Command, arg []string) error {
|
||||||
ncfgs, err := getNotificationsConfiguration()
|
ncfgs, err := getProfilesConfigs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("can't build profiles configuration: %w", err)
|
return fmt.Errorf("can't build profiles configuration: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -183,25 +196,21 @@ func NewNotificationsInspectCmd() *cobra.Command {
|
||||||
Example: `cscli notifications inspect <plugin_name>`,
|
Example: `cscli notifications inspect <plugin_name>`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
RunE: func(cmd *cobra.Command, arg []string) error {
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
var (
|
if args[0] == "" {
|
||||||
cfg NotificationsCfg
|
|
||||||
ok bool
|
|
||||||
)
|
|
||||||
|
|
||||||
pluginName := arg[0]
|
|
||||||
|
|
||||||
if pluginName == "" {
|
|
||||||
return fmt.Errorf("please provide a plugin name to inspect")
|
return fmt.Errorf("please provide a plugin name to inspect")
|
||||||
}
|
}
|
||||||
ncfgs, err := getNotificationsConfiguration()
|
return nil
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
ncfgs, err := getProfilesConfigs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("can't build profiles configuration: %w", err)
|
return fmt.Errorf("can't build profiles configuration: %w", err)
|
||||||
}
|
}
|
||||||
if cfg, ok = ncfgs[pluginName]; !ok {
|
cfg, ok := ncfgs[args[0]]
|
||||||
return fmt.Errorf("plugin '%s' does not exist or is not active", pluginName)
|
if !ok {
|
||||||
|
return fmt.Errorf("plugin '%s' does not exist or is not active", args[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
if csConfig.Cscli.Output == "human" || csConfig.Cscli.Output == "raw" {
|
if csConfig.Cscli.Output == "human" || csConfig.Cscli.Output == "raw" {
|
||||||
fmt.Printf(" - %15s: %15s\n", "Type", cfg.Config.Type)
|
fmt.Printf(" - %15s: %15s\n", "Type", cfg.Config.Type)
|
||||||
fmt.Printf(" - %15s: %15s\n", "Name", cfg.Config.Name)
|
fmt.Printf(" - %15s: %15s\n", "Name", cfg.Config.Name)
|
||||||
|
@ -224,75 +233,125 @@ func NewNotificationsInspectCmd() *cobra.Command {
|
||||||
return cmdNotificationsInspect
|
return cmdNotificationsInspect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NewNotificationsTestCmd() *cobra.Command {
|
||||||
|
var (
|
||||||
|
pluginBroker csplugin.PluginBroker
|
||||||
|
pluginTomb tomb.Tomb
|
||||||
|
alertOverride string
|
||||||
|
)
|
||||||
|
var cmdNotificationsTest = &cobra.Command{
|
||||||
|
Use: "test [plugin name]",
|
||||||
|
Short: "send a generic test alert to notification plugin",
|
||||||
|
Long: `send a generic test alert to a notification plugin to test configuration even if is not active`,
|
||||||
|
Example: `cscli notifications test [plugin_name]`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
DisableAutoGenTag: true,
|
||||||
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
pconfigs, err := getPluginConfigs()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("can't build profiles configuration: %w", err)
|
||||||
|
}
|
||||||
|
cfg, ok := pconfigs[args[0]]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("plugin name: '%s' does not exist", args[0])
|
||||||
|
}
|
||||||
|
//Create a single profile with plugin name as notification name
|
||||||
|
return pluginBroker.Init(csConfig.PluginConfig, []*csconfig.ProfileCfg{
|
||||||
|
{
|
||||||
|
Notifications: []string{
|
||||||
|
cfg.Name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, csConfig.ConfigPaths)
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
pluginTomb.Go(func() error {
|
||||||
|
pluginBroker.Run(&pluginTomb)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
alert := &models.Alert{
|
||||||
|
Capacity: ptr.Of(int32(0)),
|
||||||
|
Decisions: []*models.Decision{{
|
||||||
|
Duration: ptr.Of("4h"),
|
||||||
|
Scope: ptr.Of("Ip"),
|
||||||
|
Value: ptr.Of("10.10.10.10"),
|
||||||
|
Type: ptr.Of("ban"),
|
||||||
|
Scenario: ptr.Of("test alert"),
|
||||||
|
Origin: ptr.Of(types.CscliOrigin),
|
||||||
|
}},
|
||||||
|
Events: []*models.Event{},
|
||||||
|
EventsCount: ptr.Of(int32(1)),
|
||||||
|
Leakspeed: ptr.Of("0"),
|
||||||
|
Message: ptr.Of("test alert"),
|
||||||
|
ScenarioHash: ptr.Of(""),
|
||||||
|
Scenario: ptr.Of("test alert"),
|
||||||
|
ScenarioVersion: ptr.Of(""),
|
||||||
|
Simulated: ptr.Of(false),
|
||||||
|
Source: &models.Source{
|
||||||
|
AsName: "",
|
||||||
|
AsNumber: "",
|
||||||
|
Cn: "",
|
||||||
|
IP: "10.10.10.10",
|
||||||
|
Range: "",
|
||||||
|
Scope: ptr.Of("Ip"),
|
||||||
|
Value: ptr.Of("10.10.10.10"),
|
||||||
|
},
|
||||||
|
StartAt: ptr.Of(time.Now().UTC().Format(time.RFC3339)),
|
||||||
|
StopAt: ptr.Of(time.Now().UTC().Format(time.RFC3339)),
|
||||||
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal([]byte(alertOverride), alert); err != nil {
|
||||||
|
return fmt.Errorf("failed to unmarshal alert override: %w", err)
|
||||||
|
}
|
||||||
|
pluginBroker.PluginChannel <- csplugin.ProfileAlert{
|
||||||
|
ProfileID: uint(0),
|
||||||
|
Alert: alert,
|
||||||
|
}
|
||||||
|
//time.Sleep(2 * time.Second) // There's no mechanism to ensure notification has been sent
|
||||||
|
pluginTomb.Kill(fmt.Errorf("terminating"))
|
||||||
|
pluginTomb.Wait()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmdNotificationsTest.Flags().StringVarP(&alertOverride, "alert", "a", "", "JSON string used to override alert fields in the generic alert (see crowdsec/pkg/models/alert.go in the source tree for the full definition of the object)")
|
||||||
|
|
||||||
|
return cmdNotificationsTest
|
||||||
|
}
|
||||||
|
|
||||||
func NewNotificationsReinjectCmd() *cobra.Command {
|
func NewNotificationsReinjectCmd() *cobra.Command {
|
||||||
var remediation bool
|
|
||||||
var alertOverride string
|
var alertOverride string
|
||||||
|
var alert *models.Alert
|
||||||
|
|
||||||
var cmdNotificationsReinject = &cobra.Command{
|
var cmdNotificationsReinject = &cobra.Command{
|
||||||
Use: "reinject",
|
Use: "reinject",
|
||||||
Short: "reinject alert into notifications system",
|
Short: "reinject an alert into profiles to trigger notifications",
|
||||||
Long: `Reinject alert into notifications system`,
|
Long: `reinject an alert into profiles to be evaluated by the filter and sent to matched notifications plugins`,
|
||||||
Example: `
|
Example: `
|
||||||
cscli notifications reinject <alert_id>
|
cscli notifications reinject <alert_id>
|
||||||
cscli notifications reinject <alert_id> --remediation
|
cscli notifications reinject <alert_id> -a '{"remediation": false,"scenario":"notification/test"}'
|
||||||
cscli notifications reinject <alert_id> -a '{"remediation": true,"scenario":"notification/test"}'
|
cscli notifications reinject <alert_id> -a '{"remediation": true,"scenario":"notification/test"}'
|
||||||
`,
|
`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
DisableAutoGenTag: true,
|
DisableAutoGenTag: true,
|
||||||
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var err error
|
||||||
|
alert, err = FetchAlertFromArgString(args[0])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
var (
|
var (
|
||||||
pluginBroker csplugin.PluginBroker
|
pluginBroker csplugin.PluginBroker
|
||||||
pluginTomb tomb.Tomb
|
pluginTomb tomb.Tomb
|
||||||
)
|
)
|
||||||
if len(args) != 1 {
|
|
||||||
printHelp(cmd)
|
|
||||||
return fmt.Errorf("wrong number of argument: there should be one argument")
|
|
||||||
}
|
|
||||||
|
|
||||||
//first: get the alert
|
|
||||||
id, err := strconv.Atoi(args[0])
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("bad alert id %s", args[0])
|
|
||||||
}
|
|
||||||
if err := csConfig.LoadAPIClient(); err != nil {
|
|
||||||
return fmt.Errorf("loading api client: %w", err)
|
|
||||||
}
|
|
||||||
if csConfig.API.Client == nil {
|
|
||||||
return fmt.Errorf("missing configuration on 'api_client:'")
|
|
||||||
}
|
|
||||||
if csConfig.API.Client.Credentials == nil {
|
|
||||||
return fmt.Errorf("missing API credentials in '%s'", csConfig.API.Client.CredentialsFilePath)
|
|
||||||
}
|
|
||||||
apiURL, err := url.Parse(csConfig.API.Client.Credentials.URL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error parsing the URL of the API: %w", err)
|
|
||||||
}
|
|
||||||
client, err := apiclient.NewClient(&apiclient.Config{
|
|
||||||
MachineID: csConfig.API.Client.Credentials.Login,
|
|
||||||
Password: strfmt.Password(csConfig.API.Client.Credentials.Password),
|
|
||||||
UserAgent: fmt.Sprintf("crowdsec/%s", version.String()),
|
|
||||||
URL: apiURL,
|
|
||||||
VersionPrefix: "v1",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error creating the client for the API: %w", err)
|
|
||||||
}
|
|
||||||
alert, _, err := client.Alerts.GetByID(context.Background(), id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("can't find alert with id %s: %w", args[0], err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if alertOverride != "" {
|
if alertOverride != "" {
|
||||||
if err = json.Unmarshal([]byte(alertOverride), alert); err != nil {
|
if err := json.Unmarshal([]byte(alertOverride), alert); err != nil {
|
||||||
return fmt.Errorf("can't unmarshal data in the alert flag: %w", err)
|
return fmt.Errorf("can't unmarshal data in the alert flag: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !remediation {
|
err := pluginBroker.Init(csConfig.PluginConfig, csConfig.API.Server.Profiles, csConfig.ConfigPaths)
|
||||||
alert.Remediation = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// second we start plugins
|
|
||||||
err = pluginBroker.Init(csConfig.PluginConfig, csConfig.API.Server.Profiles, csConfig.ConfigPaths)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("can't initialize plugins: %w", err)
|
return fmt.Errorf("can't initialize plugins: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -302,8 +361,6 @@ cscli notifications reinject <alert_id> -a '{"remediation": true,"scenario":"not
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
//third: get the profile(s), and process the whole stuff
|
|
||||||
|
|
||||||
profiles, err := csprofiles.NewProfile(csConfig.API.Server.Profiles)
|
profiles, err := csprofiles.NewProfile(csConfig.API.Server.Profiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot extract profiles from configuration: %w", err)
|
return fmt.Errorf("cannot extract profiles from configuration: %w", err)
|
||||||
|
@ -338,15 +395,39 @@ cscli notifications reinject <alert_id> -a '{"remediation": true,"scenario":"not
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//time.Sleep(2 * time.Second) // There's no mechanism to ensure notification has been sent
|
//time.Sleep(2 * time.Second) // There's no mechanism to ensure notification has been sent
|
||||||
pluginTomb.Kill(fmt.Errorf("terminating"))
|
pluginTomb.Kill(fmt.Errorf("terminating"))
|
||||||
pluginTomb.Wait()
|
pluginTomb.Wait()
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmdNotificationsReinject.Flags().BoolVarP(&remediation, "remediation", "r", false, "Set Alert.Remediation to false in the reinjected alert (see your profile filter configuration)")
|
|
||||||
cmdNotificationsReinject.Flags().StringVarP(&alertOverride, "alert", "a", "", "JSON string used to override alert fields in the reinjected alert (see crowdsec/pkg/models/alert.go in the source tree for the full definition of the object)")
|
cmdNotificationsReinject.Flags().StringVarP(&alertOverride, "alert", "a", "", "JSON string used to override alert fields in the reinjected alert (see crowdsec/pkg/models/alert.go in the source tree for the full definition of the object)")
|
||||||
|
|
||||||
return cmdNotificationsReinject
|
return cmdNotificationsReinject
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func FetchAlertFromArgString(toParse string) (*models.Alert, error) {
|
||||||
|
id, err := strconv.Atoi(toParse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("bad alert id %s", toParse)
|
||||||
|
}
|
||||||
|
apiURL, err := url.Parse(csConfig.API.Client.Credentials.URL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing the URL of the API: %w", err)
|
||||||
|
}
|
||||||
|
client, err := apiclient.NewClient(&apiclient.Config{
|
||||||
|
MachineID: csConfig.API.Client.Credentials.Login,
|
||||||
|
Password: strfmt.Password(csConfig.API.Client.Credentials.Password),
|
||||||
|
UserAgent: fmt.Sprintf("crowdsec/%s", version.String()),
|
||||||
|
URL: apiURL,
|
||||||
|
VersionPrefix: "v1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error creating the client for the API: %w", err)
|
||||||
|
}
|
||||||
|
alert, _, err := client.Alerts.GetByID(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("can't find alert with id %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return alert, nil
|
||||||
|
}
|
||||||
|
|
|
@ -192,7 +192,7 @@ func (pb *PluginBroker) loadConfig(path string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, pluginConfig := range pluginConfigs {
|
for _, pluginConfig := range pluginConfigs {
|
||||||
setRequiredFields(&pluginConfig)
|
SetRequiredFields(&pluginConfig)
|
||||||
if _, ok := pb.pluginConfigByName[pluginConfig.Name]; ok {
|
if _, ok := pb.pluginConfigByName[pluginConfig.Name]; ok {
|
||||||
log.Warningf("notification '%s' is defined multiple times", pluginConfig.Name)
|
log.Warningf("notification '%s' is defined multiple times", pluginConfig.Name)
|
||||||
}
|
}
|
||||||
|
@ -376,7 +376,7 @@ func ParsePluginConfigFile(path string) ([]PluginConfig, error) {
|
||||||
return parsedConfigs, nil
|
return parsedConfigs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setRequiredFields(pluginCfg *PluginConfig) {
|
func SetRequiredFields(pluginCfg *PluginConfig) {
|
||||||
if pluginCfg.MaxRetry == 0 {
|
if pluginCfg.MaxRetry == 0 {
|
||||||
pluginCfg.MaxRetry++
|
pluginCfg.MaxRetry++
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue