github.com/crowdsecurity/crowdsec@v1.6.1/cmd/crowdsec-cli/dashboard.go (about)

     1  //go:build linux
     2  
     3  package main
     4  
     5  import (
     6  	"fmt"
     7  	"math"
     8  	"os"
     9  	"os/exec"
    10  	"os/user"
    11  	"path/filepath"
    12  	"strconv"
    13  	"strings"
    14  	"syscall"
    15  	"unicode"
    16  
    17  	"github.com/AlecAivazis/survey/v2"
    18  	"github.com/pbnjay/memory"
    19  	log "github.com/sirupsen/logrus"
    20  	"github.com/spf13/cobra"
    21  
    22  	"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/require"
    23  	"github.com/crowdsecurity/crowdsec/pkg/metabase"
    24  )
    25  
    26  var (
    27  	metabaseUser         = "crowdsec@crowdsec.net"
    28  	metabasePassword     string
    29  	metabaseDBPath       string
    30  	metabaseConfigPath   string
    31  	metabaseConfigFolder = "metabase/"
    32  	metabaseConfigFile   = "metabase.yaml"
    33  	metabaseImage        = "metabase/metabase:v0.46.6.1"
    34  	/**/
    35  	metabaseListenAddress = "127.0.0.1"
    36  	metabaseListenPort    = "3000"
    37  	metabaseContainerID   = "crowdsec-metabase"
    38  	crowdsecGroup         = "crowdsec"
    39  
    40  	forceYes bool
    41  
    42  	// information needed to set up a random password on user's behalf
    43  )
    44  
    45  type cliDashboard struct {
    46  	cfg configGetter
    47  }
    48  
    49  func NewCLIDashboard(cfg configGetter) *cliDashboard {
    50  	return &cliDashboard{
    51  		cfg: cfg,
    52  	}
    53  }
    54  
    55  func (cli *cliDashboard) NewCommand() *cobra.Command {
    56  	cmd := &cobra.Command{
    57  		Use:   "dashboard [command]",
    58  		Short: "Manage your metabase dashboard container [requires local API]",
    59  		Long: `Install/Start/Stop/Remove a metabase container exposing dashboard and metrics.
    60  Note: This command requires database direct access, so is intended to be run on Local API/master.
    61  		`,
    62  		Args:              cobra.ExactArgs(1),
    63  		DisableAutoGenTag: true,
    64  		Example: `
    65  cscli dashboard setup
    66  cscli dashboard start
    67  cscli dashboard stop
    68  cscli dashboard remove
    69  `,
    70  		PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
    71  			cfg := cli.cfg()
    72  			if err := require.LAPI(cfg); err != nil {
    73  				return err
    74  			}
    75  
    76  			if err := metabase.TestAvailability(); err != nil {
    77  				return err
    78  			}
    79  
    80  			metabaseConfigFolderPath := filepath.Join(cfg.ConfigPaths.ConfigDir, metabaseConfigFolder)
    81  			metabaseConfigPath = filepath.Join(metabaseConfigFolderPath, metabaseConfigFile)
    82  			if err := os.MkdirAll(metabaseConfigFolderPath, os.ModePerm); err != nil {
    83  				return err
    84  			}
    85  
    86  			if err := require.DB(cfg); err != nil {
    87  				return err
    88  			}
    89  
    90  			/*
    91  				Old container name was "/crowdsec-metabase" but podman doesn't
    92  				allow '/' in container name. We do this check to not break
    93  				existing dashboard setup.
    94  			*/
    95  			if !metabase.IsContainerExist(metabaseContainerID) {
    96  				oldContainerID := fmt.Sprintf("/%s", metabaseContainerID)
    97  				if metabase.IsContainerExist(oldContainerID) {
    98  					metabaseContainerID = oldContainerID
    99  				}
   100  			}
   101  
   102  			return nil
   103  		},
   104  	}
   105  
   106  	cmd.AddCommand(cli.newSetupCmd())
   107  	cmd.AddCommand(cli.newStartCmd())
   108  	cmd.AddCommand(cli.newStopCmd())
   109  	cmd.AddCommand(cli.newShowPasswordCmd())
   110  	cmd.AddCommand(cli.newRemoveCmd())
   111  
   112  	return cmd
   113  }
   114  
   115  func (cli *cliDashboard) newSetupCmd() *cobra.Command {
   116  	var force bool
   117  
   118  	cmd := &cobra.Command{
   119  		Use:               "setup",
   120  		Short:             "Setup a metabase container.",
   121  		Long:              `Perform a metabase docker setup, download standard dashboards, create a fresh user and start the container`,
   122  		Args:              cobra.ExactArgs(0),
   123  		DisableAutoGenTag: true,
   124  		Example: `
   125  cscli dashboard setup
   126  cscli dashboard setup --listen 0.0.0.0
   127  cscli dashboard setup -l 0.0.0.0 -p 443 --password <password>
   128   `,
   129  		RunE: func(_ *cobra.Command, _ []string) error {
   130  			if metabaseDBPath == "" {
   131  				metabaseDBPath = cli.cfg().ConfigPaths.DataDir
   132  			}
   133  
   134  			if metabasePassword == "" {
   135  				isValid := passwordIsValid(metabasePassword)
   136  				for !isValid {
   137  					metabasePassword = generatePassword(16)
   138  					isValid = passwordIsValid(metabasePassword)
   139  				}
   140  			}
   141  			if err := checkSystemMemory(&forceYes); err != nil {
   142  				return err
   143  			}
   144  			warnIfNotLoopback(metabaseListenAddress)
   145  			if err := disclaimer(&forceYes); err != nil {
   146  				return err
   147  			}
   148  			dockerGroup, err := checkGroups(&forceYes)
   149  			if err != nil {
   150  				return err
   151  			}
   152  			if err = cli.chownDatabase(dockerGroup.Gid); err != nil {
   153  				return err
   154  			}
   155  			mb, err := metabase.SetupMetabase(cli.cfg().API.Server.DbConfig, metabaseListenAddress, metabaseListenPort, metabaseUser, metabasePassword, metabaseDBPath, dockerGroup.Gid, metabaseContainerID, metabaseImage)
   156  			if err != nil {
   157  				return err
   158  			}
   159  			if err := mb.DumpConfig(metabaseConfigPath); err != nil {
   160  				return err
   161  			}
   162  
   163  			log.Infof("Metabase is ready")
   164  			fmt.Println()
   165  			fmt.Printf("\tURL       : '%s'\n", mb.Config.ListenURL)
   166  			fmt.Printf("\tusername  : '%s'\n", mb.Config.Username)
   167  			fmt.Printf("\tpassword  : '%s'\n", mb.Config.Password)
   168  
   169  			return nil
   170  		},
   171  	}
   172  
   173  	flags := cmd.Flags()
   174  	flags.BoolVarP(&force, "force", "f", false, "Force setup : override existing files")
   175  	flags.StringVarP(&metabaseDBPath, "dir", "d", "", "Shared directory with metabase container")
   176  	flags.StringVarP(&metabaseListenAddress, "listen", "l", metabaseListenAddress, "Listen address of container")
   177  	flags.StringVar(&metabaseImage, "metabase-image", metabaseImage, "Metabase image to use")
   178  	flags.StringVarP(&metabaseListenPort, "port", "p", metabaseListenPort, "Listen port of container")
   179  	flags.BoolVarP(&forceYes, "yes", "y", false, "force  yes")
   180  	// flags.StringVarP(&metabaseUser, "user", "u", "crowdsec@crowdsec.net", "metabase user")
   181  	flags.StringVar(&metabasePassword, "password", "", "metabase password")
   182  
   183  	return cmd
   184  }
   185  
   186  func (cli *cliDashboard) newStartCmd() *cobra.Command {
   187  	cmd := &cobra.Command{
   188  		Use:               "start",
   189  		Short:             "Start the metabase container.",
   190  		Long:              `Stats the metabase container using docker.`,
   191  		Args:              cobra.ExactArgs(0),
   192  		DisableAutoGenTag: true,
   193  		RunE: func(_ *cobra.Command, _ []string) error {
   194  			mb, err := metabase.NewMetabase(metabaseConfigPath, metabaseContainerID)
   195  			if err != nil {
   196  				return err
   197  			}
   198  			warnIfNotLoopback(mb.Config.ListenAddr)
   199  			if err := disclaimer(&forceYes); err != nil {
   200  				return err
   201  			}
   202  			if err := mb.Container.Start(); err != nil {
   203  				return fmt.Errorf("failed to start metabase container : %s", err)
   204  			}
   205  			log.Infof("Started metabase")
   206  			log.Infof("url : http://%s:%s", mb.Config.ListenAddr, mb.Config.ListenPort)
   207  
   208  			return nil
   209  		},
   210  	}
   211  
   212  	cmd.Flags().BoolVarP(&forceYes, "yes", "y", false, "force  yes")
   213  
   214  	return cmd
   215  }
   216  
   217  func (cli *cliDashboard) newStopCmd() *cobra.Command {
   218  	cmd := &cobra.Command{
   219  		Use:               "stop",
   220  		Short:             "Stops the metabase container.",
   221  		Long:              `Stops the metabase container using docker.`,
   222  		Args:              cobra.ExactArgs(0),
   223  		DisableAutoGenTag: true,
   224  		RunE: func(_ *cobra.Command, _ []string) error {
   225  			if err := metabase.StopContainer(metabaseContainerID); err != nil {
   226  				return fmt.Errorf("unable to stop container '%s': %s", metabaseContainerID, err)
   227  			}
   228  			return nil
   229  		},
   230  	}
   231  
   232  	return cmd
   233  }
   234  
   235  func (cli *cliDashboard) newShowPasswordCmd() *cobra.Command {
   236  	cmd := &cobra.Command{Use: "show-password",
   237  		Short:             "displays password of metabase.",
   238  		Args:              cobra.ExactArgs(0),
   239  		DisableAutoGenTag: true,
   240  		RunE: func(_ *cobra.Command, _ []string) error {
   241  			m := metabase.Metabase{}
   242  			if err := m.LoadConfig(metabaseConfigPath); err != nil {
   243  				return err
   244  			}
   245  			log.Printf("'%s'", m.Config.Password)
   246  
   247  			return nil
   248  		},
   249  	}
   250  
   251  	return cmd
   252  }
   253  
   254  func (cli *cliDashboard) newRemoveCmd() *cobra.Command {
   255  	var force bool
   256  
   257  	cmd := &cobra.Command{
   258  		Use:               "remove",
   259  		Short:             "removes the metabase container.",
   260  		Long:              `removes the metabase container using docker.`,
   261  		Args:              cobra.ExactArgs(0),
   262  		DisableAutoGenTag: true,
   263  		Example: `
   264  cscli dashboard remove
   265  cscli dashboard remove --force
   266   `,
   267  		RunE: func(_ *cobra.Command, _ []string) error {
   268  			if !forceYes {
   269  				var answer bool
   270  				prompt := &survey.Confirm{
   271  					Message: "Do you really want to remove crowdsec dashboard? (all your changes will be lost)",
   272  					Default: true,
   273  				}
   274  				if err := survey.AskOne(prompt, &answer); err != nil {
   275  					return fmt.Errorf("unable to ask to force: %s", err)
   276  				}
   277  				if !answer {
   278  					return fmt.Errorf("user stated no to continue")
   279  				}
   280  			}
   281  			if metabase.IsContainerExist(metabaseContainerID) {
   282  				log.Debugf("Stopping container %s", metabaseContainerID)
   283  				if err := metabase.StopContainer(metabaseContainerID); err != nil {
   284  					log.Warningf("unable to stop container '%s': %s", metabaseContainerID, err)
   285  				}
   286  				dockerGroup, err := user.LookupGroup(crowdsecGroup)
   287  				if err == nil { // if group exist, remove it
   288  					groupDelCmd, err := exec.LookPath("groupdel")
   289  					if err != nil {
   290  						return fmt.Errorf("unable to find 'groupdel' command, can't continue")
   291  					}
   292  
   293  					groupDel := &exec.Cmd{Path: groupDelCmd, Args: []string{groupDelCmd, crowdsecGroup}}
   294  					if err := groupDel.Run(); err != nil {
   295  						log.Warnf("unable to delete group '%s': %s", dockerGroup, err)
   296  					}
   297  				}
   298  				log.Debugf("Removing container %s", metabaseContainerID)
   299  				if err := metabase.RemoveContainer(metabaseContainerID); err != nil {
   300  					log.Warnf("unable to remove container '%s': %s", metabaseContainerID, err)
   301  				}
   302  				log.Infof("container %s stopped & removed", metabaseContainerID)
   303  			}
   304  			log.Debugf("Removing metabase db %s", cli.cfg().ConfigPaths.DataDir)
   305  			if err := metabase.RemoveDatabase(cli.cfg().ConfigPaths.DataDir); err != nil {
   306  				log.Warnf("failed to remove metabase internal db : %s", err)
   307  			}
   308  			if force {
   309  				m := metabase.Metabase{}
   310  				if err := m.LoadConfig(metabaseConfigPath); err != nil {
   311  					return err
   312  				}
   313  				if err := metabase.RemoveImageContainer(m.Config.Image); err != nil {
   314  					if !strings.Contains(err.Error(), "No such image") {
   315  						return fmt.Errorf("removing docker image: %s", err)
   316  					}
   317  				}
   318  			}
   319  
   320  			return nil
   321  		},
   322  	}
   323  
   324  	flags := cmd.Flags()
   325  	flags.BoolVarP(&force, "force", "f", false, "Remove also the metabase image")
   326  	flags.BoolVarP(&forceYes, "yes", "y", false, "force  yes")
   327  
   328  	return cmd
   329  }
   330  
   331  func passwordIsValid(password string) bool {
   332  	hasDigit := false
   333  
   334  	for _, j := range password {
   335  		if unicode.IsDigit(j) {
   336  			hasDigit = true
   337  
   338  			break
   339  		}
   340  	}
   341  
   342  	if !hasDigit || len(password) < 6 {
   343  		return false
   344  	}
   345  
   346  	return true
   347  }
   348  
   349  func checkSystemMemory(forceYes *bool) error {
   350  	totMem := memory.TotalMemory()
   351  	if totMem >= uint64(math.Pow(2, 30)) {
   352  		return nil
   353  	}
   354  
   355  	if !*forceYes {
   356  		var answer bool
   357  
   358  		prompt := &survey.Confirm{
   359  			Message: "Metabase requires 1-2GB of RAM, your system is below this requirement continue ?",
   360  			Default: true,
   361  		}
   362  		if err := survey.AskOne(prompt, &answer); err != nil {
   363  			return fmt.Errorf("unable to ask about RAM check: %s", err)
   364  		}
   365  
   366  		if !answer {
   367  			return fmt.Errorf("user stated no to continue")
   368  		}
   369  
   370  		return nil
   371  	}
   372  
   373  	log.Warn("Metabase requires 1-2GB of RAM, your system is below this requirement")
   374  
   375  	return nil
   376  }
   377  
   378  func warnIfNotLoopback(addr string) {
   379  	if addr == "127.0.0.1" || addr == "::1" {
   380  		return
   381  	}
   382  
   383  	log.Warnf("You are potentially exposing your metabase port to the internet (addr: %s), please consider using a reverse proxy", addr)
   384  }
   385  
   386  func disclaimer(forceYes *bool) error {
   387  	if !*forceYes {
   388  		var answer bool
   389  
   390  		prompt := &survey.Confirm{
   391  			Message: "CrowdSec takes no responsibility for the security of your metabase instance. Do you accept these responsibilities ?",
   392  			Default: true,
   393  		}
   394  
   395  		if err := survey.AskOne(prompt, &answer); err != nil {
   396  			return fmt.Errorf("unable to ask to question: %s", err)
   397  		}
   398  
   399  		if !answer {
   400  			return fmt.Errorf("user stated no to responsibilities")
   401  		}
   402  
   403  		return nil
   404  	}
   405  
   406  	log.Warn("CrowdSec takes no responsibility for the security of your metabase instance. You used force yes, so you accept this disclaimer")
   407  
   408  	return nil
   409  }
   410  
   411  func checkGroups(forceYes *bool) (*user.Group, error) {
   412  	dockerGroup, err := user.LookupGroup(crowdsecGroup)
   413  	if err == nil {
   414  		return dockerGroup, nil
   415  	}
   416  
   417  	if !*forceYes {
   418  		var answer bool
   419  
   420  		prompt := &survey.Confirm{
   421  			Message: fmt.Sprintf("For metabase docker to be able to access SQLite file we need to add a new group called '%s' to the system, is it ok for you ?", crowdsecGroup),
   422  			Default: true,
   423  		}
   424  
   425  		if err := survey.AskOne(prompt, &answer); err != nil {
   426  			return dockerGroup, fmt.Errorf("unable to ask to question: %s", err)
   427  		}
   428  
   429  		if !answer {
   430  			return dockerGroup, fmt.Errorf("unable to continue without creating '%s' group", crowdsecGroup)
   431  		}
   432  	}
   433  
   434  	groupAddCmd, err := exec.LookPath("groupadd")
   435  	if err != nil {
   436  		return dockerGroup, fmt.Errorf("unable to find 'groupadd' command, can't continue")
   437  	}
   438  
   439  	groupAdd := &exec.Cmd{Path: groupAddCmd, Args: []string{groupAddCmd, crowdsecGroup}}
   440  	if err := groupAdd.Run(); err != nil {
   441  		return dockerGroup, fmt.Errorf("unable to add group '%s': %s", dockerGroup, err)
   442  	}
   443  
   444  	return user.LookupGroup(crowdsecGroup)
   445  }
   446  
   447  func (cli *cliDashboard) chownDatabase(gid string) error {
   448  	cfg := cli.cfg()
   449  	intID, err := strconv.Atoi(gid)
   450  
   451  	if err != nil {
   452  		return fmt.Errorf("unable to convert group ID to int: %s", err)
   453  	}
   454  
   455  	if stat, err := os.Stat(cfg.DbConfig.DbPath); !os.IsNotExist(err) {
   456  		info := stat.Sys()
   457  		if err := os.Chown(cfg.DbConfig.DbPath, int(info.(*syscall.Stat_t).Uid), intID); err != nil {
   458  			return fmt.Errorf("unable to chown sqlite db file '%s': %s", cfg.DbConfig.DbPath, err)
   459  		}
   460  	}
   461  
   462  	if cfg.DbConfig.Type == "sqlite" && cfg.DbConfig.UseWal != nil && *cfg.DbConfig.UseWal {
   463  		for _, ext := range []string{"-wal", "-shm"} {
   464  			file := cfg.DbConfig.DbPath + ext
   465  			if stat, err := os.Stat(file); !os.IsNotExist(err) {
   466  				info := stat.Sys()
   467  				if err := os.Chown(file, int(info.(*syscall.Stat_t).Uid), intID); err != nil {
   468  					return fmt.Errorf("unable to chown sqlite db file '%s': %s", file, err)
   469  				}
   470  			}
   471  		}
   472  	}
   473  
   474  	return nil
   475  }