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(¬ValidOnly, "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 }