machines.go 14 KB

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