package main import ( "archive/zip" "bytes" "crypto/tls" "errors" "fmt" "io" "io/ioutil" "net/http" "os" "os/exec" "path" "path/filepath" "runtime" "strconv" "strings" "time" "github.com/cenkalti/backoff/v4" "github.com/fleetdm/fleet/v4/orbit/pkg/packaging" "github.com/fleetdm/fleet/v4/orbit/pkg/update" "github.com/fleetdm/fleet/v4/pkg/fleethttp" "github.com/fleetdm/fleet/v4/pkg/open" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/service" "github.com/mitchellh/go-ps" "github.com/urfave/cli/v2" ) const ( downloadUrl = "https://github.com/fleetdm/osquery-in-a-box/archive/%s.zip" standardQueryLibraryUrl = "https://raw.githubusercontent.com/fleetdm/fleet/main/docs/01-Using-Fleet/standard-query-library/standard-query-library.yml" licenseKeyFlagName = "license-key" tagFlagName = "tag" previewConfigFlagName = "preview-config" noHostsFlagName = "no-hosts" orbitChannel = "orbit-channel" osquerydChannel = "osqueryd-channel" ) func previewCommand() *cli.Command { return &cli.Command{ Name: "preview", Aliases: []string{"sandbox"}, Usage: "Start a sandbox deployment of the Fleet server", Description: `Start a sandbox deployment of the Fleet server using Docker and docker-compose. Docker tools must be available in the environment. Use the stop and reset subcommands to manage the server and dependencies once started.`, Subcommands: []*cli.Command{ previewStopCommand(), previewResetCommand(), }, Flags: []cli.Flag{ configFlag(), contextFlag(), debugFlag(), &cli.StringFlag{ Name: licenseKeyFlagName, Usage: "License key to enable Fleet Premium (optional)", }, &cli.StringFlag{ Name: tagFlagName, Usage: "Run a specific version of Fleet", Value: "latest", }, &cli.StringFlag{ Name: previewConfigFlagName, Usage: "Run a specific branch of the preview repository", Value: "production", }, &cli.BoolFlag{ Name: noHostsFlagName, Usage: "Start the server without adding any hosts", Value: false, }, &cli.StringFlag{ Name: orbitChannel, Usage: "Use a custom orbit channel", Value: "stable", }, &cli.StringFlag{ Name: osquerydChannel, Usage: "Use a custom osqueryd channel", Value: "stable", }, }, Action: func(c *cli.Context) error { if err := checkDocker(); err != nil { return err } // Download files every time to ensure the user gets the most up to date versions previewDir := previewDirectory() osqueryBranch := c.String(previewConfigFlagName) fmt.Printf("Downloading dependencies from %s into %s...\n", osqueryBranch, previewDir) if err := downloadFiles(osqueryBranch); err != nil { return fmt.Errorf("Error downloading dependencies: %w", err) } if err := os.Chdir(previewDir); err != nil { return err } if _, err := os.Stat("docker-compose.yml"); err != nil { return fmt.Errorf("docker-compose file not found in preview directory: %w", err) } // Make sure the logs directory is writable, otherwise the Fleet // server errors on startup. This can be a problem when running on // Linux with a non-root user inside the container. if err := os.Chmod(filepath.Join(previewDir, "logs"), 0o777); err != nil { return fmt.Errorf("make logs writable: %w", err) } if err := os.Chmod(filepath.Join(previewDir, "vulndb"), 0o777); err != nil { return fmt.Errorf("make vulndb writable: %w", err) } if err := os.Setenv("FLEET_VERSION", c.String(tagFlagName)); err != nil { return fmt.Errorf("failed to set Fleet version: %w", err) } fmt.Println("Pulling Docker dependencies...") out, err := exec.Command("docker-compose", "pull").CombinedOutput() if err != nil { fmt.Println(string(out)) return errors.New("Failed to run docker-compose") } fmt.Println("Starting Docker containers...") cmd := exec.Command("docker-compose", "up", "-d", "--remove-orphans", "mysql01", "redis01", "fleet01") cmd.Env = append(os.Environ(), "FLEET_LICENSE_KEY="+c.String(licenseKeyFlagName)) out, err = cmd.CombinedOutput() if err != nil { fmt.Println(string(out)) return errors.New("Failed to run docker-compose") } fmt.Println("Waiting for server to start up...") if err := waitStartup(); err != nil { return fmt.Errorf("wait for server startup: %w", err) } // Start fleet02 (UI server) after fleet01 (agent/fleetctl server) // has finished starting up so that there is no conflict with // running database migrations. cmd = exec.Command("docker-compose", "up", "-d", "--remove-orphans", "fleet02") cmd.Env = append(os.Environ(), "FLEET_LICENSE_KEY="+c.String(licenseKeyFlagName)) out, err = cmd.CombinedOutput() if err != nil { fmt.Println(string(out)) return errors.New("Failed to run docker-compose") } fmt.Println("Initializing server...") const ( address = "https://localhost:8412" email = "admin@example.com" password = "admin123#" ) fleetClient, err := service.NewClient(address, true, "", "") if err != nil { return fmt.Errorf("Error creating Fleet API client handler: %w", err) } token, err := fleetClient.Setup(email, "Admin", password, "Fleet for osquery") if err != nil { switch ctxerr.Cause(err).(type) { case service.SetupAlreadyErr: // Ignore this error default: return fmt.Errorf("Error setting up Fleet: %w", err) } } configPath, context := c.String("config"), "default" contextConfig := Context{ Address: address, Email: email, Token: token, TLSSkipVerify: true, } config, err := readConfig(configPath) if err != nil { // No existing config config.Contexts = map[string]Context{ "default": contextConfig, } } else { fmt.Println("Configured fleetctl in the 'preview' context to avoid overwriting existing config.") context = "preview" config.Contexts["preview"] = contextConfig } c.Set("context", context) if err := writeConfig(configPath, config); err != nil { return fmt.Errorf("Error writing fleetctl configuration: %w", err) } // Create client and get enroll secret client, err := unauthenticatedClientFromCLI(c) if err != nil { return fmt.Errorf("Error making fleetctl client: %w", err) } token, err = client.Login(email, password) if err != nil { return fmt.Errorf("fleetctl login failed: %w", err) } if err := setConfigValue(configPath, context, "token", token); err != nil { return fmt.Errorf("Error setting token for the current context: %w", err) } client.SetToken(token) fmt.Println("Loading standard query library...") buf, err := downloadStandardQueryLibrary() if err != nil { return fmt.Errorf("failed to download standard query library: %w", err) } err = applyYamlBytes(c, buf, client) if err != nil { return err } // disable anonymous analytics collection and enable software inventory for preview if err := client.ApplyAppConfig(map[string]map[string]bool{ "host_settings": {"enable_software_inventory": true}, "server_settings": {"enable_analytics": false}, }); err != nil { return fmt.Errorf("failed to apply updated app config: %w", err) } secrets, err := client.GetEnrollSecretSpec() if err != nil { return fmt.Errorf("Error retrieving enroll secret: %w", err) } if len(secrets.Secrets) != 1 { return errors.New("Expected 1 active enroll secret") } // disable anonymous analytics collection for preview if err := client.ApplyAppConfig(map[string]map[string]bool{ "server_settings": {"enable_analytics": false}, }, ); err != nil { return fmt.Errorf("Error disabling anonymous analytics collection in app config: %w", err) } fmt.Println("Fleet will now log you into the UI automatically.") fmt.Println("You can also open the UI at this URL: http://localhost:1337/previewlogin.") fmt.Println("Email:", email) fmt.Println("Password:", password) if !c.Bool(noHostsFlagName) { fmt.Println("Enrolling local host...") if err := downloadOrbitAndStart(previewDir, secrets.Secrets[0].Secret, address, c.String(orbitChannel), c.String(osquerydChannel)); err != nil { return fmt.Errorf("downloading orbit and osqueryd: %w", err) } // Give it a bit of time so the current device is the one with id 1 fmt.Println("Waiting for host to enroll...") if err := waitFirstHost(client); err != nil { return fmt.Errorf("wait for current host: %w", err) } if err := open.Browser("http://localhost:1337/previewlogin"); err != nil { fmt.Println("Automatic browser open failed. Please navigate to http://localhost:1337/previewlogin.") } fmt.Println("Starting simulated Linux hosts...") cmd = exec.Command("docker-compose", "up", "-d", "--remove-orphans") cmd.Dir = filepath.Join(previewDir, "osquery") cmd.Env = append(os.Environ(), "ENROLL_SECRET="+secrets.Secrets[0].Secret, "FLEET_URL="+address, ) out, err = cmd.CombinedOutput() if err != nil { fmt.Println(string(out)) return errors.New("Failed to run docker-compose") } } else { if err := open.Browser("http://localhost:1337/previewlogin"); err != nil { fmt.Println("Automatic browser open failed. Please navigate to http://localhost:1337/previewlogin.") } } fmt.Println("Preview environment complete. Enjoy using Fleet!") return nil }, } } var testOverridePreviewDirectory string func previewDirectory() string { if testOverridePreviewDirectory != "" { return testOverridePreviewDirectory } homeDir, err := os.UserHomeDir() if err != nil { homeDir = "~" } return filepath.Join(homeDir, ".fleet", "preview") } func downloadFiles(branch string) error { resp, err := http.Get(fmt.Sprintf(downloadUrl, branch)) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download got status %d", resp.StatusCode) } zipContents, err := ioutil.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read download contents: %w", err) } zipReader, err := zip.NewReader(bytes.NewReader(zipContents), int64(len(zipContents))) if err != nil { return fmt.Errorf("open download contents for unzip: %w", err) } // zip.NewReader does not need to be closed (and cannot be) if err := unzip(zipReader, branch); err != nil { return fmt.Errorf("unzip download contents: %w", err) } return nil } func downloadStandardQueryLibrary() ([]byte, error) { resp, err := http.Get(standardQueryLibraryUrl) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("status: %d", resp.StatusCode) } buf, err := ioutil.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response body: %w", err) } return buf, nil } // Adapted from https://stackoverflow.com/a/24792688/491710 func unzip(r *zip.Reader, branch string) error { previewDir := previewDirectory() // Closure to address file descriptors issue with all the deferred .Close() // methods replacePath := fmt.Sprintf("osquery-in-a-box-%s", branch) extractAndWriteFile := func(f *zip.File) error { rc, err := f.Open() if err != nil { return err } defer rc.Close() path := f.Name path = strings.Replace(path, replacePath, previewDir, 1) // We don't need to check for directory traversal as we are already // trusting the validity of this ZIP file. if f.FileInfo().IsDir() { if err := os.MkdirAll(path, f.Mode()); err != nil { return err } } else { if err := os.MkdirAll(filepath.Dir(path), f.Mode()); err != nil { return err } f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { return err } defer f.Close() _, err = io.Copy(f, rc) if err != nil { return err } } return nil } for _, f := range r.File { err := extractAndWriteFile(f) if err != nil { return err } } return nil } func waitStartup() error { retryStrategy := backoff.NewExponentialBackOff() retryStrategy.MaxInterval = 1 * time.Second client := fleethttp.NewClient(fleethttp.WithTLSClientConfig(&tls.Config{InsecureSkipVerify: true})) if err := backoff.Retry( func() error { resp, err := client.Get("https://localhost:8412/healthz") if err != nil { return err } if resp.StatusCode != http.StatusOK { return fmt.Errorf("got status code %d", resp.StatusCode) } return nil }, retryStrategy, ); err != nil { return fmt.Errorf("checking server health: %w", err) } return nil } func waitFirstHost(client *service.Client) error { retryStrategy := backoff.NewExponentialBackOff() retryStrategy.MaxInterval = 1 * time.Second if err := backoff.Retry( func() error { hosts, err := client.GetHosts("") if err != nil { return err } if len(hosts) == 0 { return errors.New("no hosts yet") } return nil }, retryStrategy, ); err != nil { return fmt.Errorf("checking host count: %w", err) } return nil } func checkDocker() error { // Check installed if _, err := exec.LookPath("docker"); err != nil { return errors.New("Docker is required for the fleetctl preview experience.\n\nPlease install Docker (https://docs.docker.com/get-docker/).") } if _, err := exec.LookPath("docker-compose"); err != nil { return errors.New("Docker Compose is required for the fleetctl preview experience.\n\nPlease install Docker Compose (https://docs.docker.com/compose/install/).") } // Check running if err := exec.Command("docker", "info").Run(); err != nil { return errors.New("Please start Docker daemon before running fleetctl preview.") } return nil } func previewStopCommand() *cli.Command { return &cli.Command{ Name: "stop", Usage: "Stop the Fleet preview server and dependencies", Flags: []cli.Flag{ configFlag(), contextFlag(), debugFlag(), }, Action: func(c *cli.Context) error { if err := checkDocker(); err != nil { return err } previewDir := previewDirectory() if err := os.Chdir(previewDir); err != nil { return err } if _, err := os.Stat("docker-compose.yml"); err != nil { return fmt.Errorf("docker-compose file not found in preview directory: %w", err) } out, err := exec.Command("docker-compose", "stop").CombinedOutput() if err != nil { fmt.Println(string(out)) return errors.New("Failed to run docker-compose stop for Fleet server and dependencies") } cmd := exec.Command("docker-compose", "stop") cmd.Dir = filepath.Join(previewDir, "osquery") cmd.Env = append(os.Environ(), // Note that these must be set even though they are unused while // stopping because docker-compose will error otherwise. "ENROLL_SECRET=empty", "FLEET_URL=empty", ) out, err = cmd.CombinedOutput() if err != nil { fmt.Println(string(out)) return errors.New("Failed to run docker-compose stop for simulated hosts") } if err := stopOrbit(previewDir); err != nil { return fmt.Errorf("Failed to stop orbit: %w", err) } fmt.Println("Fleet preview server and dependencies stopped. Start again with fleetctl preview.") return nil }, } } func previewResetCommand() *cli.Command { return &cli.Command{ Name: "reset", Usage: "Reset the Fleet preview server and dependencies", Flags: []cli.Flag{ configFlag(), contextFlag(), debugFlag(), }, Action: func(c *cli.Context) error { if err := checkDocker(); err != nil { return err } previewDir := previewDirectory() if err := os.Chdir(previewDir); err != nil { return err } if _, err := os.Stat("docker-compose.yml"); err != nil { return fmt.Errorf("docker-compose file not found in preview directory: %w", err) } out, err := exec.Command("docker-compose", "rm", "-sf").CombinedOutput() if err != nil { fmt.Println(string(out)) return errors.New("Failed to run docker-compose rm -sf for Fleet server and dependencies.") } cmd := exec.Command("docker-compose", "rm", "-sf") cmd.Dir = filepath.Join(previewDir, "osquery") cmd.Env = append(os.Environ(), // Note that these must be set even though they are unused while // stopping because docker-compose will error otherwise. "ENROLL_SECRET=empty", "FLEET_URL=empty", ) out, err = cmd.CombinedOutput() if err != nil { fmt.Println(string(out)) return errors.New("Failed to run docker-compose rm -sf for simulated hosts.") } if err := stopOrbit(previewDir); err != nil { return fmt.Errorf("Failed to stop orbit: %w", err) } fmt.Println("Fleet preview server and dependencies reset. Start again with fleetctl preview.") return nil }, } } func storePidFile(destDir string, pid int) error { pidFilePath := path.Join(destDir, "orbit.pid") err := os.WriteFile(pidFilePath, []byte(fmt.Sprint(pid)), os.FileMode(0o644)) if err != nil { return fmt.Errorf("error writing pidfile %s: %s", pidFilePath, err) } return nil } func readPidFromFile(destDir string, what string) (int, error) { pidFilePath := path.Join(destDir, what) data, err := os.ReadFile(pidFilePath) if err != nil { return 0, fmt.Errorf("error reading pidfile %s: %w", pidFilePath, err) } return strconv.Atoi(strings.TrimSpace(string(data))) } // processNameMatches returns whether the process running with the given pid matches // the executable name (case insensitive). // // If there's no process running with the given pid then (false, nil) is returned. func processNameMatches(pid int, expectedPrefix string) (bool, error) { process, err := ps.FindProcess(pid) if err != nil { return false, fmt.Errorf("find process: %d: %w", pid, err) } if process == nil { return false, nil } return strings.HasPrefix(strings.ToLower(process.Executable()), strings.ToLower(expectedPrefix)), nil } func downloadOrbitAndStart(destDir, enrollSecret, address, orbitChannel, osquerydChannel string) error { // Stop any current intance of orbit running, otherwise the configured enroll secret // won't match the generated in the preview run. if err := stopOrbit(destDir); err != nil { fmt.Println("Failed to stop an existing instance of orbit running: ", err) return err } fmt.Println("Trying to clear orbit and osquery directories...") if err := os.RemoveAll(path.Join(destDir, "osquery.db")); err != nil { fmt.Println("Warning: clearing osquery db dir:", err) } if err := os.RemoveAll(path.Join(destDir, "orbit.db")); err != nil { fmt.Println("Warning: clearing orbit db dir:", err) } if err := cleanUpSocketFiles(destDir); err != nil { fmt.Println("Warning: cleaning up socket files:", err) } updateOpt := update.DefaultOptions if runtime.GOOS == "darwin" { // We need to initialize updates for latest orbit which does not // support .app bundle yet. updateOpt.Targets = update.DarwinLegacyTargets } // Override default channels with the provided values. updateOpt.Targets.SetTargetChannel("orbit", orbitChannel) updateOpt.Targets.SetTargetChannel("osqueryd", osquerydChannel) updateOpt.RootDirectory = destDir if _, err := packaging.InitializeUpdates(updateOpt); err != nil { return fmt.Errorf("initialize updates: %w", err) } orbitPath, err := update.NewDisabled(updateOpt).ExecutableLocalPath("orbit") if err != nil { return fmt.Errorf("failed to locate executable for orbit: %w", err) } cmd := exec.Command(orbitPath, "--root-dir", destDir, "--fleet-url", address, "--insecure", "--debug", "--enroll-secret", enrollSecret, "--orbit-channel", orbitChannel, "--osqueryd-channel", osquerydChannel, "--log-file", path.Join(destDir, "orbit.log"), ) if err := cmd.Start(); err != nil { return fmt.Errorf("starting orbit: %w", err) } if err := storePidFile(destDir, cmd.Process.Pid); err != nil { return fmt.Errorf("saving pid file: %w", err) } return nil } // cleanUpSocketFiles cleans up fleet-osqueryd's socket file // ("orbit-osquery.em") and osquery extension socket files // ("orbit-osquery.em.*"). func cleanUpSocketFiles(path string) error { entries, err := os.ReadDir(path) if err != nil { return fmt.Errorf("read dir: %w", err) } for _, entry := range entries { if !strings.HasPrefix(entry.Name(), "orbit-osquery.em") { continue } entryPath := filepath.Join(path, entry.Name()) if err := os.Remove(entryPath); err != nil { return fmt.Errorf("remove %q: %w", entryPath, err) } } return nil } func stopOrbit(destDir string) error { err := killFromPIDFile(destDir, "osquery.pid", "osqueryd") if err != nil { return err } err = killFromPIDFile(destDir, "orbit.pid", "orbit") if err != nil { return err } return nil } func killFromPIDFile(destDir string, pidFileName string, expectedExecName string) error { pid, err := readPidFromFile(destDir, pidFileName) switch { case err == nil: // OK case errors.Is(err, os.ErrNotExist): return nil // we assume it's not running default: return fmt.Errorf("reading pid from: %s: %w", destDir, err) } matches, err := processNameMatches(pid, expectedExecName) if err != nil { return fmt.Errorf("inspecting process %d: %w", pid, err) } if !matches { // Nothing to do, another process may be running with this pid // (e.g. could happen after a restart). return nil } if err := killPID(pid); err != nil { return fmt.Errorf("killing %d: %w", pid, err) } return nil }