This commit is contained in:
mmetc 2025-05-09 16:46:45 +02:00 committed by GitHub
commit 62aa1ebdf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 15 additions and 1223 deletions

View file

@ -5,47 +5,8 @@ package main
import (
"errors"
"fmt"
"math"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"syscall"
"unicode"
"github.com/AlecAivazis/survey/v2"
"github.com/pbnjay/memory"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/crowdsecurity/go-cs-lib/version"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/args"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/idgen"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/require"
"github.com/crowdsecurity/crowdsec/pkg/metabase"
)
var (
metabaseUser = "crowdsec@crowdsec.net"
metabasePassword string
metabaseDBPath string
metabaseConfigPath string
metabaseConfigFolder = "metabase/"
metabaseConfigFile = "metabase.yaml"
metabaseImage = "metabase/metabase:v0.46.6.1"
/**/
metabaseListenAddress = "127.0.0.1"
metabaseListenPort = "3000"
metabaseContainerID = "crowdsec-metabase"
metabaseContainerEnvironmentVariables []string
crowdsecGroup = "crowdsec"
forceYes bool
// information needed to set up a random password on user's behalf
)
type cliDashboard struct {
@ -58,436 +19,22 @@ func NewCLIDashboard(cfg configGetter) *cliDashboard {
}
}
var ErrDashboardDeprecated = errors.New("command 'dashboard' has been removed, please read https://docs.crowdsec.net/blog/cscli_dashboard_deprecation/")
func (cli *cliDashboard) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "dashboard [command]",
Short: "Manage your metabase dashboard container [requires local API]",
Long: `Install/Start/Stop/Remove a metabase container exposing dashboard and metrics.
Note: This command requires database direct access, so is intended to be run on Local API/master.
`,
DisableAutoGenTag: true,
Example: `
cscli dashboard setup
cscli dashboard start
cscli dashboard stop
cscli dashboard remove
`,
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
if version.System == "docker" {
return errors.New("cscli dashboard is not supported whilst running CrowdSec within a container please see: https://github.com/crowdsecurity/example-docker-compose/tree/main/basic")
}
cfg := cli.cfg()
if err := require.LAPI(cfg); err != nil {
return err
}
if err := metabase.TestAvailability(); err != nil {
return err
}
metabaseConfigFolderPath := filepath.Join(cfg.ConfigPaths.ConfigDir, metabaseConfigFolder)
metabaseConfigPath = filepath.Join(metabaseConfigFolderPath, metabaseConfigFile)
if err := os.MkdirAll(metabaseConfigFolderPath, os.ModePerm); err != nil {
return err
}
if err := require.DB(cfg); err != nil {
return err
}
/*
Old container name was "/crowdsec-metabase" but podman doesn't
allow '/' in container name. We do this check to not break
existing dashboard setup.
*/
if !metabase.IsContainerExist(metabaseContainerID) {
oldContainerID := fmt.Sprintf("/%s", metabaseContainerID)
if metabase.IsContainerExist(oldContainerID) {
metabaseContainerID = oldContainerID
}
}
log.Warn("cscli dashboard will be deprecated in version 1.7.0, read more at https://docs.crowdsec.net/blog/cscli_dashboard_deprecation/")
return nil
},
}
cmd.AddCommand(cli.newSetupCmd())
cmd.AddCommand(cli.newStartCmd())
cmd.AddCommand(cli.newStopCmd())
cmd.AddCommand(cli.newShowPasswordCmd())
cmd.AddCommand(cli.newRemoveCmd())
return cmd
}
func (cli *cliDashboard) newSetupCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "setup",
Short: "Setup a metabase container.",
Long: `Perform a metabase docker setup, download standard dashboards, create a fresh user and start the container`,
Args: args.NoArgs,
DisableAutoGenTag: true,
Example: `
cscli dashboard setup
cscli dashboard setup --listen 0.0.0.0
cscli dashboard setup -l 0.0.0.0 -p 443 --password <password>
`,
RunE: func(_ *cobra.Command, _ []string) error {
if metabaseDBPath == "" {
metabaseDBPath = cli.cfg().ConfigPaths.DataDir
}
if metabasePassword == "" {
isValid := passwordIsValid(metabasePassword)
for !isValid {
var err error
metabasePassword, err = idgen.GeneratePassword(16)
if err != nil {
return err
}
isValid = passwordIsValid(metabasePassword)
}
}
if err := checkSystemMemory(&forceYes); err != nil {
return err
}
warnIfNotLoopback(metabaseListenAddress)
if err := disclaimer(&forceYes); err != nil {
return err
}
dockerGroup, err := checkGroups(&forceYes)
if err != nil {
return err
}
if err = cli.chownDatabase(dockerGroup.Gid); err != nil {
return err
}
mb, err := metabase.SetupMetabase(cli.cfg().API.Server.DbConfig, metabaseListenAddress,
metabaseListenPort, metabaseUser, metabasePassword, metabaseDBPath, dockerGroup.Gid,
metabaseContainerID, metabaseImage, metabaseContainerEnvironmentVariables)
if err != nil {
return err
}
if err := mb.DumpConfig(metabaseConfigPath); err != nil {
return err
}
log.Infof("Metabase is ready")
fmt.Println()
fmt.Printf("\tURL : '%s'\n", mb.Config.ListenURL)
fmt.Printf("\tusername : '%s'\n", mb.Config.Username)
fmt.Printf("\tpassword : '%s'\n", mb.Config.Password)
return nil
},
}
flags := cmd.Flags()
flags.BoolVarP(&force, "force", "f", false, "Force setup : override existing files")
flags.StringVarP(&metabaseDBPath, "dir", "d", "", "Shared directory with metabase container")
flags.StringVarP(&metabaseListenAddress, "listen", "l", metabaseListenAddress, "Listen address of container")
flags.StringVar(&metabaseImage, "metabase-image", metabaseImage, "Metabase image to use")
flags.StringVarP(&metabaseListenPort, "port", "p", metabaseListenPort, "Listen port of container")
flags.BoolVarP(&forceYes, "yes", "y", false, "force yes")
// flags.StringVarP(&metabaseUser, "user", "u", "crowdsec@crowdsec.net", "metabase user")
flags.StringVar(&metabasePassword, "password", "", "metabase password")
flags.StringSliceVarP(&metabaseContainerEnvironmentVariables, "env", "e", nil, "Additional environment variables to pass to the metabase container")
return cmd
}
func (cli *cliDashboard) newStartCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "start",
Short: "Start the metabase container.",
Long: `Stats the metabase container using docker.`,
Args: args.NoArgs,
Use: "dashboard [command]",
Hidden: true,
Short: "Manage your metabase dashboard container [requires local API]",
DisableAutoGenTag: true,
RunE: func(_ *cobra.Command, _ []string) error {
mb, err := metabase.NewMetabase(metabaseConfigPath, metabaseContainerID)
if err != nil {
return err
}
warnIfNotLoopback(mb.Config.ListenAddr)
if err := disclaimer(&forceYes); err != nil {
return err
}
if err := mb.Container.Start(); err != nil {
return fmt.Errorf("failed to start metabase container : %s", err)
}
log.Infof("Started metabase")
log.Infof("url : http://%s:%s", mb.Config.ListenAddr, mb.Config.ListenPort)
return nil
return ErrDashboardDeprecated
},
}
cmd.Flags().BoolVarP(&forceYes, "yes", "y", false, "force yes")
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
fmt.Println(ErrDashboardDeprecated.Error())
})
return cmd
}
func (cli *cliDashboard) newStopCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "stop",
Short: "Stops the metabase container.",
Long: `Stops the metabase container using docker.`,
Args: args.NoArgs,
DisableAutoGenTag: true,
RunE: func(_ *cobra.Command, _ []string) error {
if err := metabase.StopContainer(metabaseContainerID); err != nil {
return fmt.Errorf("unable to stop container '%s': %s", metabaseContainerID, err)
}
return nil
},
}
return cmd
}
func (cli *cliDashboard) newShowPasswordCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "show-password",
Short: "displays password of metabase.",
Args: args.NoArgs,
DisableAutoGenTag: true,
RunE: func(_ *cobra.Command, _ []string) error {
m := metabase.Metabase{}
if err := m.LoadConfig(metabaseConfigPath); err != nil {
return err
}
log.Printf("'%s'", m.Config.Password)
return nil
},
}
return cmd
}
func (cli *cliDashboard) newRemoveCmd() *cobra.Command {
var force bool
cmd := &cobra.Command{
Use: "remove",
Short: "removes the metabase container.",
Long: `removes the metabase container using docker.`,
Args: args.NoArgs,
DisableAutoGenTag: true,
Example: `
cscli dashboard remove
cscli dashboard remove --force
`,
RunE: func(_ *cobra.Command, _ []string) error {
if !forceYes {
var answer bool
prompt := &survey.Confirm{
Message: "Do you really want to remove crowdsec dashboard? (all your changes will be lost)",
Default: true,
}
if err := survey.AskOne(prompt, &answer); err != nil {
return fmt.Errorf("unable to ask to force: %s", err)
}
if !answer {
return errors.New("user stated no to continue")
}
}
if metabase.IsContainerExist(metabaseContainerID) {
log.Debugf("Stopping container %s", metabaseContainerID)
if err := metabase.StopContainer(metabaseContainerID); err != nil {
log.Warningf("unable to stop container '%s': %s", metabaseContainerID, err)
}
dockerGroup, err := user.LookupGroup(crowdsecGroup)
if err == nil { // if group exist, remove it
groupDelCmd, err := exec.LookPath("groupdel")
if err != nil {
return errors.New("unable to find 'groupdel' command, can't continue")
}
groupDel := &exec.Cmd{Path: groupDelCmd, Args: []string{groupDelCmd, crowdsecGroup}}
if err := groupDel.Run(); err != nil {
log.Warnf("unable to delete group '%s': %s", dockerGroup, err)
}
}
log.Debugf("Removing container %s", metabaseContainerID)
if err := metabase.RemoveContainer(metabaseContainerID); err != nil {
log.Warnf("unable to remove container '%s': %s", metabaseContainerID, err)
}
log.Infof("container %s stopped & removed", metabaseContainerID)
}
log.Debugf("Removing metabase db %s", cli.cfg().ConfigPaths.DataDir)
if err := metabase.RemoveDatabase(cli.cfg().ConfigPaths.DataDir); err != nil {
log.Warnf("failed to remove metabase internal db : %s", err)
}
if force {
m := metabase.Metabase{}
if err := m.LoadConfig(metabaseConfigPath); err != nil {
return err
}
if err := metabase.RemoveImageContainer(m.Config.Image); err != nil {
if !strings.Contains(err.Error(), "No such image") {
return fmt.Errorf("removing docker image: %s", err)
}
}
}
return nil
},
}
flags := cmd.Flags()
flags.BoolVarP(&force, "force", "f", false, "Remove also the metabase image")
flags.BoolVarP(&forceYes, "yes", "y", false, "force yes")
return cmd
}
func passwordIsValid(password string) bool {
hasDigit := false
for _, j := range password {
if unicode.IsDigit(j) {
hasDigit = true
break
}
}
if !hasDigit || len(password) < 6 {
return false
}
return true
}
func checkSystemMemory(forceYes *bool) error {
totMem := memory.TotalMemory()
if totMem >= uint64(math.Pow(2, 30)) {
return nil
}
if !*forceYes {
var answer bool
prompt := &survey.Confirm{
Message: "Metabase requires 1-2GB of RAM, your system is below this requirement continue ?",
Default: true,
}
if err := survey.AskOne(prompt, &answer); err != nil {
return fmt.Errorf("unable to ask about RAM check: %s", err)
}
if !answer {
return errors.New("user stated no to continue")
}
return nil
}
log.Warn("Metabase requires 1-2GB of RAM, your system is below this requirement")
return nil
}
func warnIfNotLoopback(addr string) {
if addr == "127.0.0.1" || addr == "::1" {
return
}
log.Warnf("You are potentially exposing your metabase port to the internet (addr: %s), please consider using a reverse proxy", addr)
}
func disclaimer(forceYes *bool) error {
if !*forceYes {
var answer bool
prompt := &survey.Confirm{
Message: "CrowdSec takes no responsibility for the security of your metabase instance. Do you accept these responsibilities ?",
Default: true,
}
if err := survey.AskOne(prompt, &answer); err != nil {
return fmt.Errorf("unable to ask to question: %s", err)
}
if !answer {
return errors.New("user stated no to responsibilities")
}
return nil
}
log.Warn("CrowdSec takes no responsibility for the security of your metabase instance. You used force yes, so you accept this disclaimer")
return nil
}
func checkGroups(forceYes *bool) (*user.Group, error) {
dockerGroup, err := user.LookupGroup(crowdsecGroup)
if err == nil {
return dockerGroup, nil
}
if !*forceYes {
var answer bool
prompt := &survey.Confirm{
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),
Default: true,
}
if err := survey.AskOne(prompt, &answer); err != nil {
return dockerGroup, fmt.Errorf("unable to ask to question: %s", err)
}
if !answer {
return dockerGroup, fmt.Errorf("unable to continue without creating '%s' group", crowdsecGroup)
}
}
groupAddCmd, err := exec.LookPath("groupadd")
if err != nil {
return dockerGroup, errors.New("unable to find 'groupadd' command, can't continue")
}
groupAdd := &exec.Cmd{Path: groupAddCmd, Args: []string{groupAddCmd, crowdsecGroup}}
if err := groupAdd.Run(); err != nil {
return dockerGroup, fmt.Errorf("unable to add group '%s': %s", dockerGroup, err)
}
return user.LookupGroup(crowdsecGroup)
}
func (cli *cliDashboard) chownDatabase(gid string) error {
cfg := cli.cfg()
intID, err := strconv.Atoi(gid)
if err != nil {
return fmt.Errorf("unable to convert group ID to int: %s", err)
}
if stat, err := os.Stat(cfg.DbConfig.DbPath); !os.IsNotExist(err) {
info := stat.Sys()
if err := os.Chown(cfg.DbConfig.DbPath, int(info.(*syscall.Stat_t).Uid), intID); err != nil {
return fmt.Errorf("unable to chown sqlite db file '%s': %s", cfg.DbConfig.DbPath, err)
}
}
if cfg.DbConfig.Type == "sqlite" && cfg.DbConfig.UseWal != nil && *cfg.DbConfig.UseWal {
for _, ext := range []string{"-wal", "-shm"} {
file := cfg.DbConfig.DbPath + ext
if stat, err := os.Stat(file); !os.IsNotExist(err) {
info := stat.Sys()
if err := os.Chown(file, int(info.(*syscall.Stat_t).Uid), intID); err != nil {
return fmt.Errorf("unable to chown sqlite db file '%s': %s", file, err)
}
}
}
}
return nil
}

4
go.mod
View file

@ -28,10 +28,9 @@ require (
github.com/crowdsecurity/grokky v0.2.2
github.com/crowdsecurity/machineid v1.0.2
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
github.com/dghubble/sling v1.4.2
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v27.3.1+incompatible
github.com/docker/go-connections v0.5.0
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/expr-lang/expr v1.17.2
github.com/fatih/color v1.18.0
@ -76,7 +75,6 @@ require (
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/oschwald/geoip2-golang v1.9.0
github.com/oschwald/maxminddb-golang v1.12.0
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.17.0
github.com/prometheus/client_model v0.5.0

4
go.sum
View file

@ -122,8 +122,6 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dghubble/sling v1.4.2 h1:vs1HIGBbSl2SEALyU+irpYFLZMfc49Fp+jYryFebQjM=
github.com/dghubble/sling v1.4.2/go.mod h1:o0arCOz0HwfqYQJLrRtqunaWOn4X6jxE/6ORKRpVTD4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
@ -569,8 +567,6 @@ github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrz
github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y=
github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
github.com/pelletier/go-toml v1.4.0/go.mod h1:PN7xzY2wHTK0K9p34ErDQMlFxa51Fk0OUruD3k1mMwo=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=

View file

@ -1,85 +0,0 @@
package metabase
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/dghubble/sling"
log "github.com/sirupsen/logrus"
"github.com/crowdsecurity/crowdsec/pkg/apiclient/useragent"
)
type MBClient struct {
CTX *sling.Sling
Client *http.Client
}
const (
sessionEndpoint = "login"
scanEndpoint = "scan"
resetPasswordEndpoint = "reset_password"
userEndpoint = "user"
databaseEndpoint = "database"
)
var (
routes = map[string]string{
sessionEndpoint: "api/session",
scanEndpoint: "api/database/2/rescan_values",
resetPasswordEndpoint: "api/user/1/password",
userEndpoint: "api/user/1",
databaseEndpoint: "api/database/2",
}
)
func NewMBClient(url string) (*MBClient, error) {
httpClient := &http.Client{Timeout: 20 * time.Second}
return &MBClient{
CTX: sling.New().Client(httpClient).Base(url).Set("User-Agent", useragent.Default()),
Client: httpClient,
}, nil
}
func (h *MBClient) Do(method string, route string, body interface{}) (interface{}, interface{}, error) {
var Success interface{}
var Error interface{}
var resp *http.Response
var err error
var data []byte
if body != nil {
data, _ = json.Marshal(body)
}
switch method {
case "POST":
log.Debugf("POST /%s", route)
log.Debugf("%s", string(data))
resp, err = h.CTX.New().Post(route).BodyJSON(body).Receive(&Success, &Error)
case "GET":
log.Debugf("GET /%s", route)
resp, err = h.CTX.New().Get(route).Receive(&Success, &Error)
case "PUT":
log.Debugf("PUT /%s", route)
log.Debugf("%s", string(data))
resp, err = h.CTX.New().Put(route).BodyJSON(body).Receive(&Success, &Error)
case "DELETE":
default:
return nil, nil, fmt.Errorf("unsupported method '%s'", method)
}
if Error != nil {
return Success, Error, fmt.Errorf("http error: %v", Error)
}
if resp != nil && resp.StatusCode != 200 && resp.StatusCode != 202 {
return Success, Error, fmt.Errorf("bad status code '%d': (success: %+v) | (error: %+v)", resp.StatusCode, Success, Error)
}
return Success, Error, err
}
// Set headers as key:value
func (h *MBClient) Set(key string, value string) {
h.CTX = h.CTX.Set(key, value)
}

View file

@ -1,180 +0,0 @@
package metabase
import (
"bufio"
"context"
"fmt"
"github.com/docker/docker/api/types/container"
typesImage "github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/docker/go-connections/nat"
log "github.com/sirupsen/logrus"
"github.com/crowdsecurity/go-cs-lib/ptr"
)
type Container struct {
ListenAddr string
ListenPort string
SharedFolder string
Image string
Name string
ID string
CLI *client.Client
MBDBUri string
DockerGroupID string
EnvironmentVariables []string
}
func NewContainer(listenAddr string, listenPort string, sharedFolder string, containerName string, image string, mbDBURI string, dockerGroupID string, environmentVariables []string) (*Container, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("failed to create docker client : %s", err)
}
return &Container{
ListenAddr: listenAddr,
ListenPort: listenPort,
SharedFolder: sharedFolder,
Image: image,
Name: containerName,
CLI: cli,
MBDBUri: mbDBURI,
DockerGroupID: dockerGroupID,
EnvironmentVariables: environmentVariables,
}, nil
}
func (c *Container) Create() error {
ctx := context.Background()
log.Printf("Pulling docker image %s", c.Image)
reader, err := c.CLI.ImagePull(ctx, c.Image, typesImage.PullOptions{})
if err != nil {
return fmt.Errorf("failed to pull docker image : %s", err)
}
defer reader.Close()
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
fmt.Print(".")
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to read imagepull reader: %s", err)
}
fmt.Print("\n")
hostConfig := &container.HostConfig{
PortBindings: nat.PortMap{
"3000/tcp": []nat.PortBinding{
{
HostIP: c.ListenAddr,
HostPort: c.ListenPort,
},
},
},
Mounts: []mount.Mount{
{
Type: mount.TypeBind,
Source: c.SharedFolder,
Target: containerSharedFolder,
},
},
}
env := c.EnvironmentVariables
env = append(env, fmt.Sprintf("MB_DB_FILE=%s/metabase.db", containerSharedFolder))
if c.MBDBUri != "" {
env = append(env, c.MBDBUri)
}
env = append(env, fmt.Sprintf("MGID=%s", c.DockerGroupID))
dockerConfig := &container.Config{
Image: c.Image,
Tty: true,
Env: env,
}
log.Infof("creating container '%s'", c.Name)
resp, err := c.CLI.ContainerCreate(ctx, dockerConfig, hostConfig, nil, nil, c.Name)
if err != nil {
return fmt.Errorf("failed to create container : %s", err)
}
c.ID = resp.ID
return nil
}
func (c *Container) Start() error {
ctx := context.Background()
if err := c.CLI.ContainerStart(ctx, c.Name, container.StartOptions{}); err != nil {
return fmt.Errorf("failed while starting %s : %s", c.ID, err)
}
return nil
}
func StartContainer(name string) error {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return fmt.Errorf("failed to create docker client : %s", err)
}
ctx := context.Background()
if err := cli.ContainerStart(ctx, name, container.StartOptions{}); err != nil {
return fmt.Errorf("failed while starting %s : %s", name, err)
}
return nil
}
func StopContainer(name string) error {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return fmt.Errorf("failed to create docker client : %s", err)
}
ctx := context.Background()
to := container.StopOptions{Timeout: ptr.Of(20)}
if err := cli.ContainerStop(ctx, name, to); err != nil {
return fmt.Errorf("failed while stopping %s : %s", name, err)
}
log.Printf("container stopped successfully")
return nil
}
func RemoveContainer(name string) error {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return fmt.Errorf("failed to create docker client : %s", err)
}
ctx := context.Background()
log.Printf("Removing docker metabase %s", name)
if err := cli.ContainerRemove(ctx, name, container.RemoveOptions{}); err != nil {
return fmt.Errorf("failed to remove container %s : %s", name, err)
}
return nil
}
func RemoveImageContainer(image string) error {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return fmt.Errorf("failed to create docker client : %s", err)
}
ctx := context.Background()
log.Printf("Removing docker image '%s'", image)
if _, err := cli.ImageRemove(ctx, image, typesImage.RemoveOptions{}); err != nil {
return fmt.Errorf("failed to remove image container %s : %s", image, err)
}
return nil
}
func IsContainerExist(name string) bool {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
log.Fatalf("failed to create docker client : %s", err)
}
ctx := context.Background()
if _, err := cli.ContainerInspect(ctx, name); err != nil {
return false
}
return true
}

View file

@ -1,100 +0,0 @@
package metabase
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/crowdsecurity/crowdsec/pkg/csconfig"
)
type Database struct {
DBUrl string
Model *Model
Config *csconfig.DatabaseCfg
Client *MBClient
Details *Details
// in case mysql host is 127.0.0.1 the ip address of mysql/pgsql host will be the docker gateway since metabase run in a container
}
type Details struct {
Db string `json:"db"`
Host string `json:"host"`
Port int `json:"port"`
Dbname string `json:"dbname"`
User string `json:"user"`
Password string `json:"password"`
Ssl bool `json:"ssl"`
AdditionalOptions interface{} `json:"additional-options"`
TunnelEnabled bool `json:"tunnel-enabled"`
}
type Model struct {
Engine string `json:"engine"`
Name string `json:"name"`
Details *Details `json:"details"`
AutoRunQueries bool `json:"auto_run_queries"`
IsFullSync bool `json:"is_full_sync"`
IsOnDemand bool `json:"is_on_demand"`
Schedules map[string]interface{} `json:"schedules"`
}
func NewDatabase(config *csconfig.DatabaseCfg, client *MBClient, remoteDBAddr string) (*Database, error) {
var details *Details
database := Database{}
switch config.Type {
case "mysql":
return nil, fmt.Errorf("database '%s' is not supported yet", config.Type)
case "sqlite":
database.DBUrl = metabaseSQLiteDBURL
localFolder := filepath.Dir(config.DbPath)
// replace /var/lib/crowdsec/data/ with /metabase-data/
dbPath := strings.Replace(config.DbPath, localFolder, containerSharedFolder, 1)
details = &Details{
Db: dbPath,
}
case "postgresql", "postgres", "pgsql":
return nil, fmt.Errorf("database '%s' is not supported yet", config.Type)
default:
return nil, fmt.Errorf("database '%s' not supported", config.Type)
}
database.Details = details
database.Client = client
database.Config = config
return &database, nil
}
func (d *Database) Update() error {
success, errormsg, err := d.Client.Do("GET", routes[databaseEndpoint], nil)
if err != nil {
return err
}
if errormsg != nil {
return fmt.Errorf("update sqlite db http error: %+v", errormsg)
}
data, err := json.Marshal(success)
if err != nil {
return fmt.Errorf("update sqlite db response (marshal): %w", err)
}
model := Model{}
if err := json.Unmarshal(data, &model); err != nil {
return fmt.Errorf("update sqlite db response (unmarshal): %w", err)
}
model.Details = d.Details
_, errormsg, err = d.Client.Do("PUT", routes[databaseEndpoint], model)
if err != nil {
return err
}
if errormsg != nil {
return fmt.Errorf("update sqlite db http error: %+v", errormsg)
}
return nil
}

View file

@ -1,388 +0,0 @@
package metabase
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/docker/docker/client"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v2"
"github.com/crowdsecurity/crowdsec/pkg/csconfig"
)
type Metabase struct {
Config *Config
Client *MBClient
Container *Container
Database *Database
InternalDBURL string
}
type Config struct {
Database *csconfig.DatabaseCfg `yaml:"database"`
ListenAddr string `yaml:"listen_addr"`
ListenPort string `yaml:"listen_port"`
ListenURL string `yaml:"listen_url"`
Username string `yaml:"username"`
Password string `yaml:"password"`
DBPath string `yaml:"metabase_db_path"`
DockerGroupID string `yaml:"-"`
Image string `yaml:"image"`
EnvironmentVariables []string `yaml:"environment_variables"`
}
var (
metabaseDefaultUser = "crowdsec@crowdsec.net"
metabaseDefaultPassword = "!!Cr0wdS3c_M3t4b4s3??"
containerSharedFolder = "/metabase-data"
metabaseSQLiteDBURL = "https://crowdsec-statics-assets.s3-eu-west-1.amazonaws.com/metabase_sqlite.zip"
)
func TestAvailability() error {
if runtime.GOARCH != "amd64" {
return fmt.Errorf("cscli dashboard is only available on amd64, but you are running %s", runtime.GOARCH)
}
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return fmt.Errorf("failed to create docker client : %s", err)
}
_, err = cli.Ping(context.TODO())
return err
}
func (m *Metabase) Init(containerName string, image string) error {
var err error
var DBConnectionURI string
var remoteDBAddr string
switch m.Config.Database.Type {
case "mysql":
return errors.New("'mysql' is not supported yet for cscli dashboard")
//DBConnectionURI = fmt.Sprintf("MB_DB_CONNECTION_URI=mysql://%s:%d/%s?user=%s&password=%s&allowPublicKeyRetrieval=true", remoteDBAddr, m.Config.Database.Port, m.Config.Database.DbName, m.Config.Database.User, m.Config.Database.Password)
case "sqlite":
m.InternalDBURL = metabaseSQLiteDBURL
case "postgresql", "postgres", "pgsql":
return errors.New("'postgresql' is not supported yet by cscli dashboard")
default:
return fmt.Errorf("database '%s' not supported", m.Config.Database.Type)
}
m.Client, err = NewMBClient(m.Config.ListenURL)
if err != nil {
return err
}
m.Database, err = NewDatabase(m.Config.Database, m.Client, remoteDBAddr)
if err != nil {
return err
}
m.Container, err = NewContainer(m.Config.ListenAddr, m.Config.ListenPort, m.Config.DBPath, containerName, image, DBConnectionURI, m.Config.DockerGroupID, m.Config.EnvironmentVariables)
if err != nil {
return fmt.Errorf("container init: %w", err)
}
return nil
}
func NewMetabase(configPath string, containerName string) (*Metabase, error) {
m := &Metabase{}
if err := m.LoadConfig(configPath); err != nil {
return m, err
}
if err := m.Init(containerName, m.Config.Image); err != nil {
return m, err
}
return m, nil
}
func (m *Metabase) LoadConfig(configPath string) error {
yamlFile, err := os.ReadFile(configPath)
if err != nil {
return err
}
config := &Config{}
err = yaml.Unmarshal(yamlFile, config)
if err != nil {
return err
}
if config.Username == "" {
return fmt.Errorf("'username' not found in configuration file '%s'", configPath)
}
if config.Password == "" {
return fmt.Errorf("'password' not found in configuration file '%s'", configPath)
}
if config.ListenURL == "" {
return fmt.Errorf("'listen_url' not found in configuration file '%s'", configPath)
}
/* Default image for backporting */
if config.Image == "" {
config.Image = "metabase/metabase:v0.41.5"
log.Warn("Image not found in configuration file, you are using an old dashboard setup (v0.41.5), please remove your dashboard and re-create it to use the latest version.")
}
m.Config = config
return nil
}
func SetupMetabase(dbConfig *csconfig.DatabaseCfg, listenAddr string, listenPort string, username string, password string, mbDBPath string, dockerGroupID string, containerName string, image string, environmentVariables []string) (*Metabase, error) {
metabase := &Metabase{
Config: &Config{
Database: dbConfig,
ListenAddr: listenAddr,
ListenPort: listenPort,
Username: username,
Password: password,
ListenURL: fmt.Sprintf("http://%s:%s", listenAddr, listenPort),
DBPath: mbDBPath,
DockerGroupID: dockerGroupID,
Image: image,
EnvironmentVariables: environmentVariables,
},
}
if err := metabase.Init(containerName, image); err != nil {
return nil, fmt.Errorf("metabase setup init: %w", err)
}
if err := metabase.DownloadDatabase(false); err != nil {
return nil, fmt.Errorf("metabase db download: %w", err)
}
if err := metabase.Container.Create(); err != nil {
return nil, fmt.Errorf("container create: %w", err)
}
if err := metabase.Container.Start(); err != nil {
return nil, fmt.Errorf("container start: %w", err)
}
log.Infof("waiting for metabase to be up (can take up to a minute)")
if err := metabase.WaitAlive(); err != nil {
return nil, fmt.Errorf("wait alive: %w", err)
}
if err := metabase.Database.Update(); err != nil {
return nil, fmt.Errorf("update database: %w", err)
}
if err := metabase.Scan(); err != nil {
return nil, fmt.Errorf("db scan: %w", err)
}
if err := metabase.ResetCredentials(); err != nil {
return nil, fmt.Errorf("reset creds: %w", err)
}
return metabase, nil
}
func (m *Metabase) WaitAlive() error {
var err error
for {
err = m.Login(metabaseDefaultUser, metabaseDefaultPassword)
if err != nil {
if strings.Contains(err.Error(), "password:did not match stored password") {
log.Errorf("Password mismatch error, is your dashboard already setup ? Run 'cscli dashboard remove' to reset it.")
return fmt.Errorf("password mismatch error: %w", err)
}
log.Debugf("%+v", err)
} else {
break
}
fmt.Printf(".")
time.Sleep(2 * time.Second)
}
fmt.Printf("\n")
return nil
}
func (m *Metabase) Login(username string, password string) error {
body := map[string]string{"username": username, "password": password}
successmsg, errormsg, err := m.Client.Do("POST", routes[sessionEndpoint], body)
if err != nil {
return err
}
if errormsg != nil {
return fmt.Errorf("http login: %s", errormsg)
}
resp, ok := successmsg.(map[string]interface{})
if !ok {
return fmt.Errorf("login: bad response type: %+v", successmsg)
}
if _, ok = resp["id"]; !ok {
return fmt.Errorf("login: can't update session id, no id in response: %v", successmsg)
}
id, ok := resp["id"].(string)
if !ok {
return fmt.Errorf("login: bad id type: %+v", resp["id"])
}
m.Client.Set("Cookie", fmt.Sprintf("metabase.SESSION=%s", id))
return nil
}
func (m *Metabase) Scan() error {
_, errormsg, err := m.Client.Do("POST", routes[scanEndpoint], nil)
if err != nil {
return err
}
if errormsg != nil {
return fmt.Errorf("http scan: %s", errormsg)
}
return nil
}
func (m *Metabase) ResetPassword(current string, newPassword string) error {
body := map[string]string{
"id": "1",
"password": newPassword,
"old_password": current,
}
_, errormsg, err := m.Client.Do("PUT", routes[resetPasswordEndpoint], body)
if err != nil {
return fmt.Errorf("reset username: %w", err)
}
if errormsg != nil {
return fmt.Errorf("http reset password: %s", errormsg)
}
return nil
}
func (m *Metabase) ResetUsername(username string) error {
body := struct {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
GroupIDs []int `json:"group_ids"`
}{
FirstName: "Crowdsec",
LastName: "Crowdsec",
Email: username,
GroupIDs: []int{1, 2},
}
_, errormsg, err := m.Client.Do("PUT", routes[userEndpoint], body)
if err != nil {
return fmt.Errorf("reset username: %w", err)
}
if errormsg != nil {
return fmt.Errorf("http reset username: %s", errormsg)
}
return nil
}
func (m *Metabase) ResetCredentials() error {
if err := m.ResetPassword(metabaseDefaultPassword, m.Config.Password); err != nil {
return err
}
/*if err := m.ResetUsername(m.Config.Username); err != nil {
return err
}*/
return nil
}
func (m *Metabase) DumpConfig(path string) error {
data, err := yaml.Marshal(m.Config)
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
func (m *Metabase) DownloadDatabase(force bool) error {
metabaseDBSubpath := filepath.Join(m.Config.DBPath, "metabase.db")
_, err := os.Stat(metabaseDBSubpath)
if err == nil && !force {
log.Printf("%s exists, skip.", metabaseDBSubpath)
return nil
}
if err := os.MkdirAll(metabaseDBSubpath, 0755); err != nil {
return fmt.Errorf("failed to create %s : %s", metabaseDBSubpath, err)
}
req, err := http.NewRequest(http.MethodGet, m.InternalDBURL, nil)
if err != nil {
return fmt.Errorf("failed to build request to fetch metabase db : %s", err)
}
//This needs to be removed once we move the zip out of github
//req.Header.Add("Accept", `application/vnd.github.v3.raw`)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed request to fetch metabase db : %s", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("got http %d while requesting metabase db %s, stop", resp.StatusCode, m.InternalDBURL)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed request read while fetching metabase db : %s", err)
}
log.Debugf("Got %d bytes archive", len(body))
if err := m.ExtractDatabase(bytes.NewReader(body)); err != nil {
return fmt.Errorf("while extracting zip : %s", err)
}
return nil
}
func (m *Metabase) ExtractDatabase(buf *bytes.Reader) error {
r, err := zip.NewReader(buf, int64(buf.Len()))
if err != nil {
return err
}
for _, f := range r.File {
if strings.Contains(f.Name, "..") {
return fmt.Errorf("invalid path '%s' in archive", f.Name)
}
tfname := fmt.Sprintf("%s/%s", m.Config.DBPath, f.Name)
log.Tracef("%s -> %d", f.Name, f.UncompressedSize64)
if f.UncompressedSize64 == 0 {
continue
}
tfd, err := os.OpenFile(tfname, os.O_RDWR|os.O_TRUNC|os.O_CREATE, 0644)
if err != nil {
return fmt.Errorf("failed opening target file '%s' : %s", tfname, err)
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("while opening zip content %s : %s", f.Name, err)
}
written, err := io.Copy(tfd, rc)
if errors.Is(err, io.EOF) {
log.Printf("files finished ok")
} else if err != nil {
return fmt.Errorf("while copying content to %s : %s", tfname, err)
}
log.Debugf("written %d bytes to %s", written, tfname)
rc.Close()
}
return nil
}
func RemoveDatabase(dataDir string) error {
return os.RemoveAll(filepath.Join(dataDir, "metabase.db"))
}

View file

@ -293,7 +293,11 @@ teardown() {
export CROWDSEC_FEATURE_CSCLI_SETUP="true"
rune -0 cscli config feature-flags
assert_line '✓ cscli_setup: Enable cscli setup command (service detection)'
# there are no retired features
rune -0 cscli config feature-flags --retired
}
@test "cscli dashboard" {
rune -1 cscli dashboard xyz
assert_stderr --partial "command 'dashboard' has been removed, please read https://docs.crowdsec.net/blog/cscli_dashboard_deprecation/"
}