machines.go 14 KB

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