387 lines
11 KiB
Go
387 lines
11 KiB
Go
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"`
|
|
}
|
|
|
|
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 fmt.Errorf("'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 fmt.Errorf("'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)
|
|
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) (*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,
|
|
},
|
|
}
|
|
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"))
|
|
}
|