crowdsec/cmd/crowdsec-cli/clisupport/support.go
mmetc 12a3c70860
lint: gocritic/httpNoBody (#3493)
* lint: gocritic/httpNoBody
2025-03-07 14:35:25 +01:00

642 lines
16 KiB
Go

package clisupport
import (
"archive/zip"
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/blackfireio/osinfo"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/crowdsecurity/go-cs-lib/trace"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/args"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/clibouncer"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/clicapi"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/clihub"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/clilapi"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/climachine"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/climetrics"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/clipapi"
"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/require"
"github.com/crowdsecurity/crowdsec/pkg/csconfig"
"github.com/crowdsecurity/crowdsec/pkg/cwhub"
"github.com/crowdsecurity/crowdsec/pkg/cwversion"
"github.com/crowdsecurity/crowdsec/pkg/database"
"github.com/crowdsecurity/crowdsec/pkg/fflag"
)
const (
SUPPORT_METRICS_DIR = "metrics/"
SUPPORT_VERSION_PATH = "version.txt"
SUPPORT_FEATURES_PATH = "features.txt"
SUPPORT_OS_INFO_PATH = "osinfo.txt"
SUPPORT_HUB = "hub.txt"
SUPPORT_BOUNCERS_PATH = "lapi/bouncers.txt"
SUPPORT_AGENTS_PATH = "lapi/agents.txt"
SUPPORT_CROWDSEC_CONFIG_PATH = "config/crowdsec.yaml"
SUPPORT_LAPI_STATUS_PATH = "lapi_status.txt"
SUPPORT_CAPI_STATUS_PATH = "capi_status.txt"
SUPPORT_PAPI_STATUS_PATH = "papi_status.txt"
SUPPORT_ACQUISITION_DIR = "config/acquis/"
SUPPORT_CROWDSEC_PROFILE_PATH = "config/profiles.yaml"
SUPPORT_CRASH_DIR = "crash/"
SUPPORT_LOG_DIR = "log/"
SUPPORT_PPROF_DIR = "pprof/"
)
// StringHook collects log entries in a string
type StringHook struct {
LogBuilder strings.Builder
LogLevels []log.Level
}
func (hook *StringHook) Levels() []log.Level {
return hook.LogLevels
}
func (hook *StringHook) Fire(entry *log.Entry) error {
logEntry, err := entry.String()
if err != nil {
return err
}
hook.LogBuilder.WriteString(logEntry)
return nil
}
// from https://github.com/acarl005/stripansi
var reStripAnsi = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))")
func stripAnsiString(str string) string {
// the byte version doesn't strip correctly
return reStripAnsi.ReplaceAllString(str, "")
}
type configGetter func() *csconfig.Config
type cliSupport struct {
cfg configGetter
}
func (cli *cliSupport) dumpMetrics(ctx context.Context, db *database.Client, zw *zip.Writer) error {
log.Info("Collecting prometheus metrics")
cfg := cli.cfg()
if cfg.Cscli.PrometheusUrl == "" {
log.Warn("can't collect metrics: prometheus_uri is not set")
}
humanMetrics := new(bytes.Buffer)
ms := climetrics.NewMetricStore()
if err := ms.Fetch(ctx, cfg.Cscli.PrometheusUrl, db); err != nil {
return err
}
if err := ms.Format(humanMetrics, cfg.Cscli.Color, nil, "human", false); err != nil {
return fmt.Errorf("could not format prometheus metrics: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.Cscli.PrometheusUrl, http.NoBody)
if err != nil {
return fmt.Errorf("could not create request to prometheus endpoint: %w", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("could not get metrics from prometheus endpoint: %w", err)
}
defer resp.Body.Close()
cli.writeToZip(zw, SUPPORT_METRICS_DIR+"metrics.prometheus", time.Now(), resp.Body)
stripped := stripAnsiString(humanMetrics.String())
cli.writeToZip(zw, SUPPORT_METRICS_DIR+"metrics.human", time.Now(), strings.NewReader(stripped))
return nil
}
func (cli *cliSupport) dumpVersion(zw *zip.Writer) {
log.Info("Collecting version")
cli.writeToZip(zw, SUPPORT_VERSION_PATH, time.Now(), strings.NewReader(cwversion.FullString()))
}
func (cli *cliSupport) dumpFeatures(zw *zip.Writer) {
log.Info("Collecting feature flags")
w := new(bytes.Buffer)
for _, k := range fflag.Crowdsec.GetEnabledFeatures() {
fmt.Fprintln(w, k)
}
cli.writeToZip(zw, SUPPORT_FEATURES_PATH, time.Now(), w)
}
func (cli *cliSupport) dumpOSInfo(zw *zip.Writer) error {
log.Info("Collecting OS info")
info, err := osinfo.GetOSInfo()
if err != nil {
return err
}
w := new(bytes.Buffer)
fmt.Fprintf(w, "Architecture: %s\n", info.Architecture)
fmt.Fprintf(w, "Family: %s\n", info.Family)
fmt.Fprintf(w, "ID: %s\n", info.ID)
fmt.Fprintf(w, "Name: %s\n", info.Name)
fmt.Fprintf(w, "Codename: %s\n", info.Codename)
fmt.Fprintf(w, "Version: %s\n", info.Version)
fmt.Fprintf(w, "Build: %s\n", info.Build)
cli.writeToZip(zw, SUPPORT_OS_INFO_PATH, time.Now(), w)
return nil
}
func (cli *cliSupport) dumpHubItems(zw *zip.Writer, hub *cwhub.Hub) error {
log.Infof("Collecting hub")
if hub == nil {
return errors.New("no hub connection")
}
out := new(bytes.Buffer)
ch := clihub.New(cli.cfg)
if err := ch.List(out, hub, false); err != nil {
return err
}
stripped := stripAnsiString(out.String())
cli.writeToZip(zw, SUPPORT_HUB, time.Now(), strings.NewReader(stripped))
return nil
}
func (cli *cliSupport) dumpBouncers(ctx context.Context, zw *zip.Writer, db *database.Client) error {
log.Info("Collecting bouncers")
if db == nil {
return errors.New("no database connection")
}
out := new(bytes.Buffer)
cb := clibouncer.New(cli.cfg)
if err := cb.List(ctx, out, db); err != nil {
return err
}
stripped := stripAnsiString(out.String())
cli.writeToZip(zw, SUPPORT_BOUNCERS_PATH, time.Now(), strings.NewReader(stripped))
return nil
}
func (cli *cliSupport) dumpAgents(ctx context.Context, zw *zip.Writer, db *database.Client) error {
log.Info("Collecting agents")
if db == nil {
return errors.New("no database connection")
}
out := new(bytes.Buffer)
cm := climachine.New(cli.cfg)
if err := cm.List(ctx, out, db); err != nil {
return err
}
stripped := stripAnsiString(out.String())
cli.writeToZip(zw, SUPPORT_AGENTS_PATH, time.Now(), strings.NewReader(stripped))
return nil
}
func (cli *cliSupport) dumpLAPIStatus(ctx context.Context, zw *zip.Writer, hub *cwhub.Hub) error {
log.Info("Collecting LAPI status")
out := new(bytes.Buffer)
cl := clilapi.New(cli.cfg)
err := cl.Status(ctx, out, hub)
if err != nil {
fmt.Fprintf(out, "%s\n", err)
}
stripped := stripAnsiString(out.String())
cli.writeToZip(zw, SUPPORT_LAPI_STATUS_PATH, time.Now(), strings.NewReader(stripped))
return nil
}
func (cli *cliSupport) dumpCAPIStatus(ctx context.Context, zw *zip.Writer, hub *cwhub.Hub) error {
log.Info("Collecting CAPI status")
out := new(bytes.Buffer)
cc := clicapi.New(cli.cfg)
err := cc.Status(ctx, out, hub)
if err != nil {
fmt.Fprintf(out, "%s\n", err)
}
stripped := stripAnsiString(out.String())
cli.writeToZip(zw, SUPPORT_CAPI_STATUS_PATH, time.Now(), strings.NewReader(stripped))
return nil
}
func (cli *cliSupport) dumpPAPIStatus(ctx context.Context, zw *zip.Writer, db *database.Client) error {
log.Info("Collecting PAPI status")
out := new(bytes.Buffer)
cp := clipapi.New(cli.cfg)
err := cp.Status(ctx, out, db)
if err != nil {
fmt.Fprintf(out, "%s\n", err)
}
stripped := stripAnsiString(out.String())
cli.writeToZip(zw, SUPPORT_PAPI_STATUS_PATH, time.Now(), strings.NewReader(stripped))
return nil
}
func (cli *cliSupport) dumpConfigYAML(zw *zip.Writer) error {
log.Info("Collecting crowdsec config")
cfg := cli.cfg()
config, err := os.ReadFile(cfg.FilePath)
if err != nil {
return fmt.Errorf("could not read config file: %w", err)
}
r := regexp.MustCompile(`(\s+password:|\s+user:|\s+host:)\s+.*`)
redacted := r.ReplaceAll(config, []byte("$1 ****REDACTED****"))
cli.writeToZip(zw, SUPPORT_CROWDSEC_CONFIG_PATH, time.Now(), bytes.NewReader(redacted))
return nil
}
func (cli *cliSupport) dumpPprof(ctx context.Context, zw *zip.Writer, prometheusCfg csconfig.PrometheusCfg, endpoint string) error {
log.Infof("Collecting pprof/%s data", endpoint)
ctx, cancel := context.WithTimeout(ctx, 120*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
fmt.Sprintf(
"http://%s/debug/pprof/%s",
net.JoinHostPort(
prometheusCfg.ListenAddr,
strconv.Itoa(prometheusCfg.ListenPort),
),
endpoint,
),
http.NoBody,
)
if err != nil {
return fmt.Errorf("could not create request to pprof endpoint: %w", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("could not get pprof data from endpoint: %w", err)
}
defer resp.Body.Close()
cli.writeToZip(zw, SUPPORT_PPROF_DIR+endpoint+".pprof", time.Now(), resp.Body)
return nil
}
func (cli *cliSupport) dumpProfiles(zw *zip.Writer) {
log.Info("Collecting crowdsec profile")
cfg := cli.cfg()
cli.writeFileToZip(zw, SUPPORT_CROWDSEC_PROFILE_PATH, cfg.API.Server.ProfilesPath)
}
func (cli *cliSupport) dumpAcquisitionConfig(zw *zip.Writer) {
log.Info("Collecting acquisition config")
cfg := cli.cfg()
for _, filename := range cfg.Crowdsec.AcquisitionFiles {
fname := strings.ReplaceAll(filename, string(filepath.Separator), "___")
cli.writeFileToZip(zw, SUPPORT_ACQUISITION_DIR+fname, filename)
}
}
func (cli *cliSupport) dumpLogs(zw *zip.Writer) error {
log.Info("Collecting CrowdSec logs")
cfg := cli.cfg()
logDir := cfg.Common.LogDir
logFiles, err := filepath.Glob(filepath.Join(logDir, "crowdsec*.log"))
if err != nil {
return fmt.Errorf("could not list log files: %w", err)
}
for _, filename := range logFiles {
cli.writeFileToZip(zw, SUPPORT_LOG_DIR+filepath.Base(filename), filename)
}
return nil
}
func (cli *cliSupport) dumpCrash(zw *zip.Writer) error {
log.Info("Collecting crash dumps")
traceFiles, err := trace.List()
if err != nil {
return fmt.Errorf("could not list crash dumps: %w", err)
}
for _, filename := range traceFiles {
cli.writeFileToZip(zw, SUPPORT_CRASH_DIR+filepath.Base(filename), filename)
}
return nil
}
func New(cfg configGetter) *cliSupport {
return &cliSupport{
cfg: cfg,
}
}
func (cli *cliSupport) NewCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "support [action]",
Short: "Provide commands to help during support",
DisableAutoGenTag: true,
}
cmd.AddCommand(cli.NewDumpCmd())
return cmd
}
// writeToZip adds a file to the zip archive, from a reader
func (cli *cliSupport) writeToZip(zipWriter *zip.Writer, filename string, mtime time.Time, reader io.Reader) {
header := &zip.FileHeader{
Name: filename,
Method: zip.Deflate,
Modified: mtime,
}
fw, err := zipWriter.CreateHeader(header)
if err != nil {
log.Errorf("could not add zip entry for %s: %s", filename, err)
return
}
_, err = io.Copy(fw, reader)
if err != nil {
log.Errorf("could not write zip entry for %s: %s", filename, err)
}
}
// writeFileToZip adds a file to the zip archive, from a file, and retains the mtime
func (cli *cliSupport) writeFileToZip(zw *zip.Writer, filename string, fromFile string) {
mtime := time.Now()
fi, err := os.Stat(fromFile)
if err == nil {
mtime = fi.ModTime()
}
fin, err := os.Open(fromFile)
if err != nil {
log.Errorf("could not open file %s: %s", fromFile, err)
return
}
defer fin.Close()
cli.writeToZip(zw, filename, mtime, fin)
}
func (cli *cliSupport) dump(ctx context.Context, outFile string) error {
var skipCAPI, skipLAPI, skipAgent bool
collector := &StringHook{
LogLevels: log.AllLevels,
}
log.AddHook(collector)
cfg := cli.cfg()
if outFile == "" {
outFile = filepath.Join(os.TempDir(), "crowdsec-support.zip")
}
w := bytes.NewBuffer(nil)
zipWriter := zip.NewWriter(w)
db, err := require.DBClient(ctx, cfg.DbConfig)
if err != nil {
log.Warn(err)
}
if err = cfg.LoadAPIServer(true); err != nil {
log.Warnf("could not load LAPI, skipping CAPI check")
skipCAPI = true
}
if err = cfg.LoadCrowdsec(); err != nil {
log.Warnf("could not load agent config, skipping crowdsec config check")
skipAgent = true
}
hub, err := require.Hub(cfg, nil)
if err != nil {
log.Warn("Could not init hub, running on LAPI? Hub related information will not be collected")
// XXX: lapi status check requires scenarios, will return an error
}
if cfg.API.Client == nil || cfg.API.Client.Credentials == nil {
log.Warn("no agent credentials found, skipping LAPI connectivity check")
skipLAPI = true
}
if cfg.API.Server == nil || cfg.API.Server.OnlineClient == nil || cfg.API.Server.OnlineClient.Credentials == nil {
log.Warn("no CAPI credentials found, skipping CAPI connectivity check")
skipCAPI = true
}
if err = cli.dumpMetrics(ctx, db, zipWriter); err != nil {
log.Warn(err)
}
if err = cli.dumpOSInfo(zipWriter); err != nil {
log.Warnf("could not collect OS information: %s", err)
}
if err = cli.dumpConfigYAML(zipWriter); err != nil {
log.Warnf("could not collect main config file: %s", err)
}
if err = cli.dumpHubItems(zipWriter, hub); err != nil {
log.Warnf("could not collect hub information: %s", err)
}
if err = cli.dumpBouncers(ctx, zipWriter, db); err != nil {
log.Warnf("could not collect bouncers information: %s", err)
}
if err = cli.dumpAgents(ctx, zipWriter, db); err != nil {
log.Warnf("could not collect agents information: %s", err)
}
if !skipCAPI {
if err = cli.dumpCAPIStatus(ctx, zipWriter, hub); err != nil {
log.Warnf("could not collect CAPI status: %s", err)
}
if err = cli.dumpPAPIStatus(ctx, zipWriter, db); err != nil {
log.Warnf("could not collect PAPI status: %s", err)
}
}
if !skipLAPI {
if err = cli.dumpLAPIStatus(ctx, zipWriter, hub); err != nil {
log.Warnf("could not collect LAPI status: %s", err)
}
// call pprof separately, one might fail for timeout
if err = cli.dumpPprof(ctx, zipWriter, *cfg.Prometheus, "goroutine"); err != nil {
log.Warnf("could not collect pprof goroutine data: %s", err)
}
if err = cli.dumpPprof(ctx, zipWriter, *cfg.Prometheus, "heap"); err != nil {
log.Warnf("could not collect pprof heap data: %s", err)
}
if err = cli.dumpPprof(ctx, zipWriter, *cfg.Prometheus, "profile"); err != nil {
log.Warnf("could not collect pprof cpu data: %s", err)
}
cli.dumpProfiles(zipWriter)
}
if !skipAgent {
cli.dumpAcquisitionConfig(zipWriter)
}
if err = cli.dumpCrash(zipWriter); err != nil {
log.Warnf("could not collect crash dumps: %s", err)
}
if err = cli.dumpLogs(zipWriter); err != nil {
log.Warnf("could not collect log files: %s", err)
}
cli.dumpVersion(zipWriter)
cli.dumpFeatures(zipWriter)
// log of the dump process, without color codes
collectedOutput := stripAnsiString(collector.LogBuilder.String())
cli.writeToZip(zipWriter, "dump.log", time.Now(), strings.NewReader(collectedOutput))
err = zipWriter.Close()
if err != nil {
return fmt.Errorf("could not finalize zip file: %w", err)
}
if outFile == "-" {
_, err = os.Stdout.Write(w.Bytes())
return err
}
err = os.WriteFile(outFile, w.Bytes(), 0o600)
if err != nil {
return fmt.Errorf("could not write zip file to %s: %w", outFile, err)
}
log.Infof("Written zip file to %s", outFile)
return nil
}
func (cli *cliSupport) NewDumpCmd() *cobra.Command {
var outFile string
cmd := &cobra.Command{
Use: "dump",
Short: "Dump all your configuration to a zip file for easier support",
Long: `Dump the following information:
- Crowdsec version
- OS version
- Enabled feature flags
- Latest Crowdsec logs (log processor, LAPI, remediation components)
- Installed collections, parsers, scenarios...
- Bouncers and machines list
- CAPI/LAPI status
- Crowdsec config (sensitive information like username and password are redacted)
- Crowdsec metrics
- Stack trace in case of process crash`,
Example: `cscli support dump
cscli support dump -f /tmp/crowdsec-support.zip
`,
Args: args.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, _ []string) error {
output := cli.cfg().Cscli.Output
if output != "human" {
return fmt.Errorf("output format %s not supported for this command", output)
}
return cli.dump(cmd.Context(), outFile)
},
}
cmd.Flags().StringVarP(&outFile, "outFile", "f", "", "File to dump the information to")
return cmd
}