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 }