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

     1  package main
     2  
     3  import (
     4  	saferand "crypto/rand"
     5  	"encoding/csv"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"math/big"
    10  	"os"
    11  	"slices"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/AlecAivazis/survey/v2"
    16  	"github.com/fatih/color"
    17  	"github.com/go-openapi/strfmt"
    18  	"github.com/google/uuid"
    19  	log "github.com/sirupsen/logrus"
    20  	"github.com/spf13/cobra"
    21  	"gopkg.in/yaml.v3"
    22  
    23  	"github.com/crowdsecurity/machineid"
    24  
    25  	"github.com/crowdsecurity/crowdsec/cmd/crowdsec-cli/require"
    26  	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
    27  	"github.com/crowdsecurity/crowdsec/pkg/database"
    28  	"github.com/crowdsecurity/crowdsec/pkg/database/ent"
    29  	"github.com/crowdsecurity/crowdsec/pkg/types"
    30  )
    31  
    32  const passwordLength = 64
    33  
    34  func generatePassword(length int) string {
    35  	upper := "ABCDEFGHIJKLMNOPQRSTUVWXY"
    36  	lower := "abcdefghijklmnopqrstuvwxyz"
    37  	digits := "0123456789"
    38  
    39  	charset := upper + lower + digits
    40  	charsetLength := len(charset)
    41  
    42  	buf := make([]byte, length)
    43  
    44  	for i := 0; i < length; i++ {
    45  		rInt, err := saferand.Int(saferand.Reader, big.NewInt(int64(charsetLength)))
    46  		if err != nil {
    47  			log.Fatalf("failed getting data from prng for password generation : %s", err)
    48  		}
    49  
    50  		buf[i] = charset[rInt.Int64()]
    51  	}
    52  
    53  	return string(buf)
    54  }
    55  
    56  // Returns a unique identifier for each crowdsec installation, using an
    57  // identifier of the OS installation where available, otherwise a random
    58  // string.
    59  func generateIDPrefix() (string, error) {
    60  	prefix, err := machineid.ID()
    61  	if err == nil {
    62  		return prefix, nil
    63  	}
    64  
    65  	log.Debugf("failed to get machine-id with usual files: %s", err)
    66  
    67  	bID, err := uuid.NewRandom()
    68  	if err == nil {
    69  		return bID.String(), nil
    70  	}
    71  
    72  	return "", fmt.Errorf("generating machine id: %w", err)
    73  }
    74  
    75  // Generate a unique identifier, composed by a prefix and a random suffix.
    76  // The prefix can be provided by a parameter to use in test environments.
    77  func generateID(prefix string) (string, error) {
    78  	var err error
    79  	if prefix == "" {
    80  		prefix, err = generateIDPrefix()
    81  	}
    82  
    83  	if err != nil {
    84  		return "", err
    85  	}
    86  
    87  	prefix = strings.ReplaceAll(prefix, "-", "")[:32]
    88  	suffix := generatePassword(16)
    89  
    90  	return prefix + suffix, nil
    91  }
    92  
    93  // getLastHeartbeat returns the last heartbeat timestamp of a machine
    94  // and a boolean indicating if the machine is considered active or not.
    95  func getLastHeartbeat(m *ent.Machine) (string, bool) {
    96  	if m.LastHeartbeat == nil {
    97  		return "-", false
    98  	}
    99  
   100  	elapsed := time.Now().UTC().Sub(*m.LastHeartbeat)
   101  
   102  	hb := elapsed.Truncate(time.Second).String()
   103  	if elapsed > 2*time.Minute {
   104  		return hb, false
   105  	}
   106  
   107  	return hb, true
   108  }
   109  
   110  type cliMachines struct {
   111  	db  *database.Client
   112  	cfg configGetter
   113  }
   114  
   115  func NewCLIMachines(cfg configGetter) *cliMachines {
   116  	return &cliMachines{
   117  		cfg: cfg,
   118  	}
   119  }
   120  
   121  func (cli *cliMachines) NewCommand() *cobra.Command {
   122  	cmd := &cobra.Command{
   123  		Use:   "machines [action]",
   124  		Short: "Manage local API machines [requires local API]",
   125  		Long: `To list/add/delete/validate/prune machines.
   126  Note: This command requires database direct access, so is intended to be run on the local API machine.
   127  `,
   128  		Example:           `cscli machines [action]`,
   129  		DisableAutoGenTag: true,
   130  		Aliases:           []string{"machine"},
   131  		PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
   132  			var err error
   133  			if err = require.LAPI(cli.cfg()); err != nil {
   134  				return err
   135  			}
   136  			cli.db, err = database.NewClient(cli.cfg().DbConfig)
   137  			if err != nil {
   138  				return fmt.Errorf("unable to create new database client: %w", err)
   139  			}
   140  
   141  			return nil
   142  		},
   143  	}
   144  
   145  	cmd.AddCommand(cli.newListCmd())
   146  	cmd.AddCommand(cli.newAddCmd())
   147  	cmd.AddCommand(cli.newDeleteCmd())
   148  	cmd.AddCommand(cli.newValidateCmd())
   149  	cmd.AddCommand(cli.newPruneCmd())
   150  
   151  	return cmd
   152  }
   153  
   154  func (cli *cliMachines) list() error {
   155  	out := color.Output
   156  
   157  	machines, err := cli.db.ListMachines()
   158  	if err != nil {
   159  		return fmt.Errorf("unable to list machines: %w", err)
   160  	}
   161  
   162  	switch cli.cfg().Cscli.Output {
   163  	case "human":
   164  		getAgentsTable(out, machines)
   165  	case "json":
   166  		enc := json.NewEncoder(out)
   167  		enc.SetIndent("", "  ")
   168  
   169  		if err := enc.Encode(machines); err != nil {
   170  			return errors.New("failed to marshal")
   171  		}
   172  
   173  		return nil
   174  	case "raw":
   175  		csvwriter := csv.NewWriter(out)
   176  
   177  		err := csvwriter.Write([]string{"machine_id", "ip_address", "updated_at", "validated", "version", "auth_type", "last_heartbeat"})
   178  		if err != nil {
   179  			return fmt.Errorf("failed to write header: %w", err)
   180  		}
   181  
   182  		for _, m := range machines {
   183  			validated := "false"
   184  			if m.IsValidated {
   185  				validated = "true"
   186  			}
   187  
   188  			hb, _ := getLastHeartbeat(m)
   189  
   190  			if err := csvwriter.Write([]string{m.MachineId, m.IpAddress, m.UpdatedAt.Format(time.RFC3339), validated, m.Version, m.AuthType, hb}); err != nil {
   191  				return fmt.Errorf("failed to write raw output: %w", err)
   192  			}
   193  		}
   194  
   195  		csvwriter.Flush()
   196  	}
   197  
   198  	return nil
   199  }
   200  
   201  func (cli *cliMachines) newListCmd() *cobra.Command {
   202  	cmd := &cobra.Command{
   203  		Use:               "list",
   204  		Short:             "list all machines in the database",
   205  		Long:              `list all machines in the database with their status and last heartbeat`,
   206  		Example:           `cscli machines list`,
   207  		Args:              cobra.NoArgs,
   208  		DisableAutoGenTag: true,
   209  		RunE: func(_ *cobra.Command, _ []string) error {
   210  			return cli.list()
   211  		},
   212  	}
   213  
   214  	return cmd
   215  }
   216  
   217  func (cli *cliMachines) newAddCmd() *cobra.Command {
   218  	var (
   219  		password    MachinePassword
   220  		dumpFile    string
   221  		apiURL      string
   222  		interactive bool
   223  		autoAdd     bool
   224  		force       bool
   225  	)
   226  
   227  	cmd := &cobra.Command{
   228  		Use:               "add",
   229  		Short:             "add a single machine to the database",
   230  		DisableAutoGenTag: true,
   231  		Long:              `Register a new machine in the database. cscli should be on the same machine as LAPI.`,
   232  		Example: `cscli machines add --auto
   233  cscli machines add MyTestMachine --auto
   234  cscli machines add MyTestMachine --password MyPassword
   235  cscli machines add -f- --auto > /tmp/mycreds.yaml`,
   236  		RunE: func(_ *cobra.Command, args []string) error {
   237  			return cli.add(args, string(password), dumpFile, apiURL, interactive, autoAdd, force)
   238  		},
   239  	}
   240  
   241  	flags := cmd.Flags()
   242  	flags.VarP(&password, "password", "p", "machine password to login to the API")
   243  	flags.StringVarP(&dumpFile, "file", "f", "", "output file destination (defaults to "+csconfig.DefaultConfigPath("local_api_credentials.yaml")+")")
   244  	flags.StringVarP(&apiURL, "url", "u", "", "URL of the local API")
   245  	flags.BoolVarP(&interactive, "interactive", "i", false, "interfactive mode to enter the password")
   246  	flags.BoolVarP(&autoAdd, "auto", "a", false, "automatically generate password (and username if not provided)")
   247  	flags.BoolVar(&force, "force", false, "will force add the machine if it already exist")
   248  
   249  	return cmd
   250  }
   251  
   252  func (cli *cliMachines) add(args []string, machinePassword string, dumpFile string, apiURL string, interactive bool, autoAdd bool, force bool) error {
   253  	var (
   254  		err       error
   255  		machineID string
   256  	)
   257  
   258  	// create machineID if not specified by user
   259  	if len(args) == 0 {
   260  		if !autoAdd {
   261  			return errors.New("please specify a machine name to add, or use --auto")
   262  		}
   263  
   264  		machineID, err = generateID("")
   265  		if err != nil {
   266  			return fmt.Errorf("unable to generate machine id: %w", err)
   267  		}
   268  	} else {
   269  		machineID = args[0]
   270  	}
   271  
   272  	clientCfg := cli.cfg().API.Client
   273  	serverCfg := cli.cfg().API.Server
   274  
   275  	/*check if file already exists*/
   276  	if dumpFile == "" && clientCfg != nil && clientCfg.CredentialsFilePath != "" {
   277  		credFile := clientCfg.CredentialsFilePath
   278  		// use the default only if the file does not exist
   279  		_, err = os.Stat(credFile)
   280  
   281  		switch {
   282  		case os.IsNotExist(err) || force:
   283  			dumpFile = credFile
   284  		case err != nil:
   285  			return fmt.Errorf("unable to stat '%s': %w", credFile, err)
   286  		default:
   287  			return fmt.Errorf(`credentials file '%s' already exists: please remove it, use "--force" or specify a different file with "-f" ("-f -" for standard output)`, credFile)
   288  		}
   289  	}
   290  
   291  	if dumpFile == "" {
   292  		return errors.New(`please specify a file to dump credentials to, with -f ("-f -" for standard output)`)
   293  	}
   294  
   295  	// create a password if it's not specified by user
   296  	if machinePassword == "" && !interactive {
   297  		if !autoAdd {
   298  			return errors.New("please specify a password with --password or use --auto")
   299  		}
   300  
   301  		machinePassword = generatePassword(passwordLength)
   302  	} else if machinePassword == "" && interactive {
   303  		qs := &survey.Password{
   304  			Message: "Please provide a password for the machine:",
   305  		}
   306  		survey.AskOne(qs, &machinePassword)
   307  	}
   308  
   309  	password := strfmt.Password(machinePassword)
   310  
   311  	_, err = cli.db.CreateMachine(&machineID, &password, "", true, force, types.PasswordAuthType)
   312  	if err != nil {
   313  		return fmt.Errorf("unable to create machine: %w", err)
   314  	}
   315  
   316  	fmt.Fprintf(os.Stderr, "Machine '%s' successfully added to the local API.\n", machineID)
   317  
   318  	if apiURL == "" {
   319  		if clientCfg != nil && clientCfg.Credentials != nil && clientCfg.Credentials.URL != "" {
   320  			apiURL = clientCfg.Credentials.URL
   321  		} else if serverCfg.ClientURL() != "" {
   322  			apiURL = serverCfg.ClientURL()
   323  		} else {
   324  			return errors.New("unable to dump an api URL. Please provide it in your configuration or with the -u parameter")
   325  		}
   326  	}
   327  
   328  	apiCfg := csconfig.ApiCredentialsCfg{
   329  		Login:    machineID,
   330  		Password: password.String(),
   331  		URL:      apiURL,
   332  	}
   333  
   334  	apiConfigDump, err := yaml.Marshal(apiCfg)
   335  	if err != nil {
   336  		return fmt.Errorf("unable to marshal api credentials: %w", err)
   337  	}
   338  
   339  	if dumpFile != "" && dumpFile != "-" {
   340  		if err = os.WriteFile(dumpFile, apiConfigDump, 0o600); err != nil {
   341  			return fmt.Errorf("write api credentials in '%s' failed: %w", dumpFile, err)
   342  		}
   343  
   344  		fmt.Fprintf(os.Stderr, "API credentials written to '%s'.\n", dumpFile)
   345  	} else {
   346  		fmt.Print(string(apiConfigDump))
   347  	}
   348  
   349  	return nil
   350  }
   351  
   352  func (cli *cliMachines) deleteValid(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
   353  	machines, err := cli.db.ListMachines()
   354  	if err != nil {
   355  		cobra.CompError("unable to list machines " + err.Error())
   356  	}
   357  
   358  	ret := []string{}
   359  
   360  	for _, machine := range machines {
   361  		if strings.Contains(machine.MachineId, toComplete) && !slices.Contains(args, machine.MachineId) {
   362  			ret = append(ret, machine.MachineId)
   363  		}
   364  	}
   365  
   366  	return ret, cobra.ShellCompDirectiveNoFileComp
   367  }
   368  
   369  func (cli *cliMachines) delete(machines []string) error {
   370  	for _, machineID := range machines {
   371  		if err := cli.db.DeleteWatcher(machineID); err != nil {
   372  			log.Errorf("unable to delete machine '%s': %s", machineID, err)
   373  			return nil
   374  		}
   375  
   376  		log.Infof("machine '%s' deleted successfully", machineID)
   377  	}
   378  
   379  	return nil
   380  }
   381  
   382  func (cli *cliMachines) newDeleteCmd() *cobra.Command {
   383  	cmd := &cobra.Command{
   384  		Use:               "delete [machine_name]...",
   385  		Short:             "delete machine(s) by name",
   386  		Example:           `cscli machines delete "machine1" "machine2"`,
   387  		Args:              cobra.MinimumNArgs(1),
   388  		Aliases:           []string{"remove"},
   389  		DisableAutoGenTag: true,
   390  		ValidArgsFunction: cli.deleteValid,
   391  		RunE: func(_ *cobra.Command, args []string) error {
   392  			return cli.delete(args)
   393  		},
   394  	}
   395  
   396  	return cmd
   397  }
   398  
   399  func (cli *cliMachines) prune(duration time.Duration, notValidOnly bool, force bool) error {
   400  	if duration < 2*time.Minute && !notValidOnly {
   401  		if yes, err := askYesNo(
   402  				"The duration you provided is less than 2 minutes. " +
   403  				"This can break installations if the machines are only temporarily disconnected. Continue?", false); err != nil {
   404  			return err
   405  		} else if !yes {
   406  			fmt.Println("User aborted prune. No changes were made.")
   407  			return nil
   408  		}
   409  	}
   410  
   411  	machines := []*ent.Machine{}
   412  	if pending, err := cli.db.QueryPendingMachine(); err == nil {
   413  		machines = append(machines, pending...)
   414  	}
   415  
   416  	if !notValidOnly {
   417  		if pending, err := cli.db.QueryLastValidatedHeartbeatLT(time.Now().UTC().Add(-duration)); err == nil {
   418  			machines = append(machines, pending...)
   419  		}
   420  	}
   421  
   422  	if len(machines) == 0 {
   423  		fmt.Println("No machines to prune.")
   424  		return nil
   425  	}
   426  
   427  	getAgentsTable(color.Output, machines)
   428  
   429  	if !force {
   430  		if yes, err := askYesNo(
   431  				"You are about to PERMANENTLY remove the above machines from the database. " +
   432  				"These will NOT be recoverable. Continue?", false); err != nil {
   433  			return err
   434  		} else if !yes {
   435  			fmt.Println("User aborted prune. No changes were made.")
   436  			return nil
   437  		}
   438  	}
   439  
   440  	deleted, err := cli.db.BulkDeleteWatchers(machines)
   441  	if err != nil {
   442  		return fmt.Errorf("unable to prune machines: %w", err)
   443  	}
   444  
   445  	fmt.Fprintf(os.Stderr, "successfully delete %d machines\n", deleted)
   446  
   447  	return nil
   448  }
   449  
   450  func (cli *cliMachines) newPruneCmd() *cobra.Command {
   451  	var (
   452  		duration     time.Duration
   453  		notValidOnly bool
   454  		force        bool
   455  	)
   456  
   457  	const defaultDuration = 10 * time.Minute
   458  
   459  	cmd := &cobra.Command{
   460  		Use:   "prune",
   461  		Short: "prune multiple machines from the database",
   462  		Long:  `prune multiple machines that are not validated or have not connected to the local API in a given duration.`,
   463  		Example: `cscli machines prune
   464  cscli machines prune --duration 1h
   465  cscli machines prune --not-validated-only --force`,
   466  		Args:              cobra.NoArgs,
   467  		DisableAutoGenTag: true,
   468  		RunE: func(_ *cobra.Command, _ []string) error {
   469  			return cli.prune(duration, notValidOnly, force)
   470  		},
   471  	}
   472  
   473  	flags := cmd.Flags()
   474  	flags.DurationVarP(&duration, "duration", "d", defaultDuration, "duration of time since validated machine last heartbeat")
   475  	flags.BoolVar(&notValidOnly, "not-validated-only", false, "only prune machines that are not validated")
   476  	flags.BoolVar(&force, "force", false, "force prune without asking for confirmation")
   477  
   478  	return cmd
   479  }
   480  
   481  func (cli *cliMachines) validate(machineID string) error {
   482  	if err := cli.db.ValidateMachine(machineID); err != nil {
   483  		return fmt.Errorf("unable to validate machine '%s': %w", machineID, err)
   484  	}
   485  
   486  	log.Infof("machine '%s' validated successfully", machineID)
   487  
   488  	return nil
   489  }
   490  
   491  func (cli *cliMachines) newValidateCmd() *cobra.Command {
   492  	cmd := &cobra.Command{
   493  		Use:               "validate",
   494  		Short:             "validate a machine to access the local API",
   495  		Long:              `validate a machine to access the local API.`,
   496  		Example:           `cscli machines validate "machine_name"`,
   497  		Args:              cobra.ExactArgs(1),
   498  		DisableAutoGenTag: true,
   499  		RunE: func(_ *cobra.Command, args []string) error {
   500  			return cli.validate(args[0])
   501  		},
   502  	}
   503  
   504  	return cmd
   505  }