diff --git a/cmd/launcher/doctor.go b/cmd/launcher/doctor.go new file mode 100644 index 000000000..c5b27436a --- /dev/null +++ b/cmd/launcher/doctor.go @@ -0,0 +1,499 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "text/tabwriter" + + "github.com/fatih/color" + "github.com/go-kit/kit/log" + "github.com/kolide/kit/logutil" + "github.com/kolide/kit/version" + "github.com/kolide/launcher/pkg/agent/flags" + "github.com/kolide/launcher/pkg/agent/knapsack" + "github.com/kolide/launcher/pkg/agent/types" + "github.com/kolide/launcher/pkg/autoupdate/tuf" + "github.com/kolide/launcher/pkg/launcher" + "github.com/kolide/launcher/pkg/log/checkpoint" + "github.com/peterbourgon/ff/v3" + "github.com/shirou/gopsutil/v3/process" + + "golang.org/x/exp/slices" +) + +var ( + doctorWriter io.Writer + + // Command line colors + cyanText *color.Color + headerText *color.Color + yellowText *color.Color + whiteText *color.Color + greenText *color.Color + redText *color.Color + + // Printf functions + cyan func(format string, a ...interface{}) + header func(format string, a ...interface{}) + + // Println functions for checkup details + green func(a ...interface{}) + red func(a ...interface{}) + + // Indented output for checkup results + info func(a ...interface{}) + warn func(a ...interface{}) + fail func(a ...interface{}) + pass func(a ...interface{}) +) + +func configureOutput(w io.Writer) { + // Set the writer to be used for doctor output + writer := tabwriter.NewWriter(w, 0, 8, 1, '\t', tabwriter.AlignRight) + doctorWriter = writer + + // Command line colors + cyanText = color.New(color.FgCyan, color.BgBlack) + headerText = color.New(color.Bold, color.FgHiWhite, color.BgBlack) + yellowText = color.New(color.FgHiYellow, color.BgBlack) + whiteText = color.New(color.FgWhite, color.BgBlack) + greenText = color.New(color.FgGreen, color.BgBlack) + redText = color.New(color.Bold, color.FgRed, color.BgBlack) + + // Printf functions + cyan = func(format string, a ...interface{}) { + cyanText.Fprintf(doctorWriter, format, a...) + } + header = func(format string, a ...interface{}) { + headerText.Fprintf(doctorWriter, format, a...) + } + + // Println functions for checkup details + green = func(a ...interface{}) { + greenText.Fprintln(doctorWriter, a...) + } + red = func(a ...interface{}) { + redText.Fprintln(doctorWriter, a...) + } + + // Indented output for checkup results + info = func(a ...interface{}) { + whiteText.FprintlnFunc()(doctorWriter, fmt.Sprintf("\t%s", a...)) + } + warn = func(a ...interface{}) { + yellowText.FprintlnFunc()(doctorWriter, fmt.Sprintf("\t%s", a...)) + } + fail = func(a ...interface{}) { + whiteText.FprintlnFunc()(doctorWriter, fmt.Sprintf("❌\t%s", a...)) + } + pass = func(a ...interface{}) { + whiteText.FprintlnFunc()(doctorWriter, fmt.Sprintf("✅\t%s", a...)) + } +} + +// checkup encapsulates a launcher health checkup +type checkup struct { + name string + check func() (string, error) +} + +func runDoctor(args []string) error { + // Doctor assumes a launcher installation (at least partially) exists + // Overriding some of the default values allows options to be parsed making this assumption + defaultKolideHosted = true + defaultAutoupdate = true + setDefaultPaths() + + opts, err := parseOptions("doctor", os.Args[2:]) + if err != nil { + return err + } + + fcOpts := []flags.Option{flags.WithCmdLineOpts(opts)} + logger := log.With(logutil.NewCLILogger(true), "caller", log.DefaultCaller) + flagController := flags.NewFlagController(logger, nil, fcOpts...) + k := knapsack.New(nil, flagController, nil) + + buildAndRunCheckups(logger, k, opts, os.Stdout) + + return nil +} + +// buildAndRunCheckups creates a list of checkups and executes them +func buildAndRunCheckups(logger log.Logger, k types.Knapsack, opts *launcher.Options, w io.Writer) { + configureOutput(w) + + cyan("Kolide launcher doctor version:\n") + version.PrintFull() + cyan("\nRunning Kolide launcher checkups...\n") + + checkups := []*checkup{ + { + name: "Platform", + check: func() (string, error) { + return checkupPlatform(runtime.GOOS) + }, + }, + { + name: "Architecture", + check: func() (string, error) { + return checkupArch(runtime.GOARCH) + }, + }, + { + name: "Root directory contents", + check: func() (string, error) { + return checkupRootDir(getFilepaths(k.RootDirectory(), "*")) + }, + }, + { + name: "Launcher application", + check: func() (string, error) { + return checkupAppBinaries(getAppBinaryPaths()) + }, + }, + { + name: "Osquery", + check: func() (string, error) { + return checkupOsquery(opts.UpdateChannel.String(), opts.TufServerURL, opts.OsquerydPath) + }, + }, + { + name: "Check communication with Kolide", + check: func() (string, error) { + return checkupConnectivity(logger, k) + }, + }, + { + name: "Check launcher version", + check: func() (string, error) { + return checkupVersion(opts.UpdateChannel.String(), opts.TufServerURL, version.Version()) + }, + }, + { + name: "Check config file", + check: func() (string, error) { + return checkupConfigFile(opts.ConfigFilePath) + }, + }, + { + name: "Check logs", + check: func() (string, error) { + return checkupLogFiles(getFilepaths(k.RootDirectory(), "debug*")) + }, + }, + { + name: "Process report", + check: func() (string, error) { + return checkupProcessReport() + }, + }, + } + + runCheckups(checkups) +} + +// runCheckups iterates through the checkups and logs success/failure information +func runCheckups(checkups []*checkup) { + failedCheckups := []*checkup{} + + // Sequentially run all of the checkups + for _, c := range checkups { + err := c.run() + if err != nil { + failedCheckups = append(failedCheckups, c) + } + } + + if len(failedCheckups) > 0 { + red("\nSome checkups failed:") + + for _, c := range failedCheckups { + fail(fmt.Sprintf("\t%s\n", c.name)) + } + return + } + + green("\nAll checkups passed! Your Kolide launcher is healthy.") +} + +// run logs the results of a checkup being run +func (c *checkup) run() error { + if c.check == nil { + return errors.New("checkup is nil") + } + + cyan("\nRunning checkup: ") + header("%s\n", c.name) + + result, err := c.check() + if err != nil { + info(result) + fail(err) + red("𐄂\tCheckup failed!") + return err + } + + pass(result) + green("✔\tCheckup passed!") + return nil +} + +// checkupPlatform verifies that the current OS is supported by launcher +func checkupPlatform(os string) (string, error) { + if slices.Contains([]string{"windows", "darwin", "linux"}, os) { + return fmt.Sprintf("Platform: %s", os), nil + } + return "", fmt.Errorf("Unsupported platform:\t%s", os) +} + +// checkupArch verifies that the current architecture is supported by launcher +func checkupArch(arch string) (string, error) { + if slices.Contains([]string{"386", "amd64", "arm64"}, arch) { + return fmt.Sprintf("Architecture: %s", arch), nil + } + return "", fmt.Errorf("Unsupported architecture:\t%s", arch) +} + +type launcherFile struct { + name string + found bool +} + +// checkupRootDir tests for the presence of important files in the launcher root directory +func checkupRootDir(filepaths []string) (string, error) { + importantFiles := []*launcherFile{ + { + name: "debug.json", + }, + { + name: "launcher.db", + }, + { + name: "osquery.db", + }, + } + + return checkupFilesPresent(filepaths, importantFiles) +} + +func checkupAppBinaries(filepaths []string) (string, error) { + importantFiles := []*launcherFile{ + { + name: windowsAddExe("launcher"), + }, + } + + return checkupFilesPresent(filepaths, importantFiles) +} + +// checkupOsquery tests for the presence of files important to osquery +func checkupOsquery(updateChannel, tufServerURL, osquerydPath string) (string, error) { + if osquerydPath == "" { + return "", fmt.Errorf("osqueryd path unknown") + } + + _, err := os.Stat(osquerydPath) + if err != nil { + return "", fmt.Errorf("osqueryd does not exist") + } + + osqueryArgs := []string{"--version"} + cmd := exec.CommandContext(context.TODO(), osquerydPath, osqueryArgs...) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("error occurred while querying osquery version output %s: err: %w", out, err) + } + + currentVersion := strings.TrimLeft(string(out), fmt.Sprintf("%s version ", windowsAddExe("osqueryd"))) + currentVersion = strings.TrimRight(currentVersion, "\n") + + info(fmt.Sprintf("Current version:\t%s", currentVersion)) + + // Query the TUF repo for what the target version of osquery is + targetVersion, err := tuf.GetChannelVersionFromTufServer("osqueryd", updateChannel, tufServerURL) + if err != nil { + return "", fmt.Errorf("failed to query TUF server: %w", err) + } + + info(fmt.Sprintf("Target version:\t%s", targetVersion)) + return "Osquery version checks complete", nil +} + +func checkupFilesPresent(filepaths []string, importantFiles []*launcherFile) (string, error) { + if filepaths != nil && len(filepaths) > 0 { + for _, fp := range filepaths { + for _, f := range importantFiles { + if filepath.Base(fp) == f.name { + f.found = true + } + } + } + } + + var failures int + for _, f := range importantFiles { + if f.found { + pass(f.name) + } else { + fail(f.name) + failures = failures + 1 + } + } + + if failures == 0 { + return "Files found", nil + } + + return "", fmt.Errorf("%d files not found", failures) +} + +// checkupConnectivity tests connections to Kolide cloud services +func checkupConnectivity(logger log.Logger, k types.Knapsack) (string, error) { + var failures int + checkpointer := checkpoint.New(logger, k) + connections := checkpointer.Connections() + for k, v := range connections { + if v != "successful tcp connection" { + fail(fmt.Sprintf("%s\t%s", k, v)) + failures = failures + 1 + continue + } + pass(fmt.Sprintf("%s\t%s", k, v)) + } + + ipLookups := checkpointer.IpLookups() + for k, v := range ipLookups { + valStrSlice, ok := v.([]string) + if !ok || len(valStrSlice) == 0 { + fail(fmt.Sprintf("%s\t%s", k, valStrSlice)) + failures = failures + 1 + continue + } + pass(fmt.Sprintf("%s\t%s", k, valStrSlice)) + } + + notaryVersions, err := checkpointer.NotaryVersions() + if err != nil { + fail(fmt.Errorf("could not fetch notary versions: %w", err)) + failures = failures + 1 + } + + for k, v := range notaryVersions { + // Check for failure if the notary version isn't a parsable integer + if _, err := strconv.ParseInt(v, 10, 32); err != nil { + fail(fmt.Sprintf("%s\t%s", k, v)) + failures = failures + 1 + continue + } + pass(fmt.Sprintf("%s\t%s", k, v)) + } + + if failures == 0 { + return "Successfully communicated with Kolide", nil + } + + return "", fmt.Errorf("%d failures encountered while attempting communication with Kolide", failures) +} + +// checkupVersion tests to see if the current launcher version is up to date +func checkupVersion(updateChannel, tufServerURL string, v version.Info) (string, error) { + info(fmt.Sprintf("Update Channel:\t%s", updateChannel)) + info(fmt.Sprintf("TUF Server:\t%s", tufServerURL)) + info(fmt.Sprintf("Current version:\t%s", v.Version)) + + // Query the TUF repo for what the target version of launcher is + targetVersion, err := tuf.GetChannelVersionFromTufServer("launcher", updateChannel, tufServerURL) + if err != nil { + return "", fmt.Errorf("Failed to query TUF server: %w", err) + } + + info(fmt.Sprintf("Target version:\t%s", targetVersion)) + return "Launcher version checks complete", nil +} + +// checkupConfigFile tests that the config file is valid and logs it's contents +func checkupConfigFile(filepath string) (string, error) { + file, err := os.Open(filepath) + if err != nil { + return "", fmt.Errorf("No config file found") + } + defer file.Close() + + // Parse the config file how launcher would + err = ff.PlainParser(file, func(name, value string) error { + info(fmt.Sprintf("%s\t%s", name, value)) + return nil + }) + + if err != nil { + return "", fmt.Errorf("Invalid config file") + } + return "Config file found", nil +} + +// checkupLogFiles checks to see if expected log files are present +func checkupLogFiles(filepaths []string) (string, error) { + var foundCurrentLogFile bool + for _, f := range filepaths { + filename := filepath.Base(f) + info(filename) + + if filename != "debug.json" { + continue + } + + foundCurrentLogFile = true + + fi, err := os.Stat(f) + if err != nil { + continue + } + + info("") + info(fmt.Sprintf("Most recent log file:\t%s", filename)) + info(fmt.Sprintf("Latest modification:\t%s", fi.ModTime().String())) + info(fmt.Sprintf("File size (B):\t%d", fi.Size())) + } + + if !foundCurrentLogFile { + return "", fmt.Errorf("No log file found") + } + + return "Log file found", nil + +} + +// checkupProcessReport finds processes that look like Kolide launcher/osquery processes +func checkupProcessReport() (string, error) { + ps, err := process.Processes() + if err != nil { + return "", fmt.Errorf("No processes found") + } + + var foundKolide bool + for _, p := range ps { + exe, _ := p.Exe() + + if strings.Contains(strings.ToLower(exe), "kolide") { + foundKolide = true + name, _ := p.Name() + args, _ := p.Cmdline() + user, _ := p.Username() + info(fmt.Sprintf("%s\t%d\t%s\t%s", user, p.Pid, name, args)) + } + } + + if !foundKolide { + return "", fmt.Errorf("No launcher processes found") + } + return "Launcher processes found", nil +} diff --git a/cmd/launcher/doctor_test.go b/cmd/launcher/doctor_test.go new file mode 100644 index 000000000..db1cecd34 --- /dev/null +++ b/cmd/launcher/doctor_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "errors" + "io" + "runtime" + "testing" + + "github.com/kolide/kit/version" + "github.com/kolide/launcher/pkg/autoupdate" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + // We don't care about the actual CLI output + doctorWriter = io.Discard +} + +func TestRunCheckups(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + checkups []*checkup + }{ + { + name: "successful checkups", + checkups: []*checkup{ + { + name: "do nothing", + check: func() (string, error) { + return "", nil + }, + }, + }, + }, + { + name: "failed checkup", + checkups: []*checkup{ + { + name: "do nothing", + check: func() (string, error) { + return "", nil + }, + }, + { + name: "return error", + check: func() (string, error) { + return "", errors.New("checkup error") + }, + }, + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runCheckups(tt.checkups) + }) + } +} + +func TestCheckupPlatform(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + os string + expectedErr bool + }{ + { + name: "supported", + os: runtime.GOOS, + expectedErr: false, + }, + { + name: "unsupported", + os: "not-an-os", + expectedErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := checkupPlatform(tt.os) + if tt.expectedErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCheckupArch(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + os string + expectedErr bool + }{ + { + name: "supported", + os: runtime.GOARCH, + expectedErr: false, + }, + { + name: "unsupported", + os: "not-an-arch", + expectedErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := checkupArch(tt.os) + if tt.expectedErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCheckupRootDir(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + filepaths []string + expectedErr bool + }{ + { + name: "present", + filepaths: []string{"debug.json", "launcher.db", "osquery.db"}, + expectedErr: false, + }, + { + name: "not present", + filepaths: []string{"not-an-important-file"}, + expectedErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := checkupRootDir(tt.filepaths) + if tt.expectedErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCheckupVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + updateChannel string + tufServerURL string + version version.Info + expectedErr bool + }{ + { + name: "happy path", + updateChannel: autoupdate.Stable.String(), + tufServerURL: "https://tuf.kolide.com", + version: version.Version(), + expectedErr: false, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := checkupVersion(tt.updateChannel, tt.tufServerURL, tt.version) + if tt.expectedErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/cmd/launcher/flare.go b/cmd/launcher/flare.go index 219a266d9..6fb7460b3 100644 --- a/cmd/launcher/flare.go +++ b/cmd/launcher/flare.go @@ -22,6 +22,8 @@ import ( "github.com/kolide/kit/ulid" "github.com/kolide/kit/version" "github.com/kolide/launcher/pkg/agent" + "github.com/kolide/launcher/pkg/agent/flags" + "github.com/kolide/launcher/pkg/agent/knapsack" "github.com/kolide/launcher/pkg/autoupdate" "github.com/kolide/launcher/pkg/osquery/runtime" "github.com/kolide/launcher/pkg/service" @@ -29,7 +31,17 @@ import ( ) func runFlare(args []string) error { - flagset := flag.NewFlagSet("launcher flare", flag.ExitOnError) + // Flare assumes a launcher installation (at least partially) exists + // Overriding some of the default values allows options to be parsed making this assumption + defaultKolideHosted = true + defaultAutoupdate = true + setDefaultPaths() + + opts, err := parseOptions("flare", args) + if err != nil { + return err + } + var ( flHostname = flag.String("hostname", "dababe.launcher.kolide.com:443", "") @@ -39,19 +51,19 @@ func runFlare(args []string) error { insecureTLS = env.Bool("KOLIDE_LAUNCHER_INSECURE", false) insecureTransport = env.Bool("KOLIDE_LAUNCHER_INSECURE_TRANSPORT", false) flareSocketPath = env.String("FLARE_SOCKET_PATH", agent.TempPath("flare.sock")) + tarDirPath = env.String("KOLIDE_LAUNCHER_FLARE_TAR_DIR_PATH", "") certPins [][]byte rootPool *x509.CertPool ) - flagset.Usage = commandUsage(flagset, "launcher flare") - if err := flagset.Parse(args); err != nil { - return err - } id := ulid.New() b := new(bytes.Buffer) reportName := fmt.Sprintf("kolide_launcher_flare_report_%s", id) - tarOut, err := os.Create(fmt.Sprintf("%s.tar.gz", reportName)) + reportPath := fmt.Sprintf("%s.tar.gz", filepath.Join(tarDirPath, reportName)) + output(b, stdout, fmt.Sprintf("Generating flare report file: %s\n", reportPath)) + + tarOut, err := os.Create(reportPath) if err != nil { fatal(b, err) } @@ -111,6 +123,16 @@ func runFlare(args []string) error { output(b, stdout, "%v\n", string(jsonVersion)) logger := log.NewLogfmtLogger(b) + fcOpts := []flags.Option{flags.WithCmdLineOpts(opts)} + flagController := flags.NewFlagController(logger, nil, fcOpts...) + k := knapsack.New(nil, flagController, nil) + + output(b, stdout, "\nStarting Launcher Doctor\n") + // Run doctor but disable color output since this is being directed to a file + os.Setenv("NO_COLOR", "1") + buildAndRunCheckups(logger, k, opts, b) + output(b, stdout, "\nEnd of Launcher Doctor\n") + err = reportGRPCNetwork( logger, serverURL, diff --git a/cmd/launcher/interactive.go b/cmd/launcher/interactive.go index 85df2506f..422cb4371 100644 --- a/cmd/launcher/interactive.go +++ b/cmd/launcher/interactive.go @@ -27,7 +27,7 @@ func runInteractive(args []string) error { flagset.Var(&flOsqueryFlags, "osquery_flag", "Flags to pass to osquery (possibly overriding Launcher defaults)") - flagset.Usage = commandUsage(flagset, "interactive") + flagset.Usage = commandUsage(flagset, "launcher interactive") if err := flagset.Parse(args); err != nil { return err } diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go index 78b31bc2a..9678d0101 100644 --- a/cmd/launcher/main.go +++ b/cmd/launcher/main.go @@ -81,7 +81,7 @@ func main() { os.Exit(0) } - opts, err := parseOptions(os.Args[1:]) + opts, err := parseOptions("", os.Args[1:]) if err != nil { level.Info(logger).Log("err", err) os.Exit(1) @@ -121,6 +121,8 @@ func runSubcommands() error { run = runSocket case "query": run = runQuery + case "doctor": + run = runDoctor case "flare": run = runFlare case "svc": diff --git a/cmd/launcher/options.go b/cmd/launcher/options.go index 11c371874..9d216c369 100644 --- a/cmd/launcher/options.go +++ b/cmd/launcher/options.go @@ -24,6 +24,17 @@ const ( skipEnvParse = runtime.GOOS == "windows" // skip environmental variable parsing on windows ) +var ( + // When launcher proper runs, it's expected that these defaults are their zero values + // However, special launcher subcommands such as launcher doctor can override these + defaultRootDirectoryPath string + defaultEtcDirectoryPath string + defaultBinDirectoryPath string + defaultConfigFilePath string + defaultKolideHosted bool + defaultAutoupdate bool +) + // Adapted from // https://stackoverflow.com/questions/28322997/how-to-get-a-list-of-values-into-a-flag-in-golang/28323276#28323276 type arrayFlags []string @@ -40,9 +51,17 @@ func (i *arrayFlags) Set(value string) error { // parseOptions parses the options that may be configured via command-line flags // and/or environment variables, determines order of precedence and returns a // typed struct of options for further application use -func parseOptions(args []string) (*launcher.Options, error) { - flagset := flag.NewFlagSet("launcher", flag.ExitOnError) - flagset.Usage = func() { usage(flagset) } +func parseOptions(subcommandName string, args []string) (*launcher.Options, error) { + flagsetName := "launcher" + if subcommandName != "" { + flagsetName = fmt.Sprintf("launcher %s", subcommandName) + } + flagset := flag.NewFlagSet(flagsetName, flag.ExitOnError) + if subcommandName != "" { + flagset.Usage = func() { usage(flagset) } + } else { + flagset.Usage = commandUsage(flagset, flagsetName) + } var ( // Primary options @@ -57,13 +76,13 @@ func parseOptions(args []string) (*launcher.Options, error) { flTransport = flagset.String("transport", "grpc", "The transport protocol that should be used to communicate with remote (default: grpc)") flLoggingInterval = flagset.Duration("logging_interval", 60*time.Second, "The interval at which logs should be flushed to the server") flOsquerydPath = flagset.String("osqueryd_path", "", "Path to the osqueryd binary to use (Default: find osqueryd in $PATH)") - flRootDirectory = flagset.String("root_directory", "", "The location of the local database, pidfiles, etc.") + flRootDirectory = flagset.String("root_directory", defaultRootDirectoryPath, "The location of the local database, pidfiles, etc.") flRootPEM = flagset.String("root_pem", "", "Path to PEM file including root certificates to verify against") flVersion = flagset.Bool("version", false, "Print Launcher version and exit") flLogMaxBytesPerBatch = flagset.Int("log_max_bytes_per_batch", 0, "Maximum size of a batch of logs. Recommend leaving unset, and launcher will determine") flOsqueryFlags arrayFlags // set below with flagset.Var flCompactDbMaxTx = flagset.Int64("compactdb-max-tx", 65536, "Maximum transaction size used when compacting the internal DB") - _ = flagset.String("config", "", "config file to parse options from (optional)") + flConfigFilePath = flagset.String("config", defaultConfigFilePath, "config file to parse options from (optional)") // osquery TLS endpoints flOsqTlsConfig = flagset.String("config_tls_endpoint", "", "Config endpoint for the osquery tls transport") @@ -73,7 +92,7 @@ func parseOptions(args []string) (*launcher.Options, error) { flOsqTlsDistWrite = flagset.String("distributed_tls_write_endpoint", "", "Distributed write endpoint for the osquery tls transport") // Autoupdate options - flAutoupdate = flagset.Bool("autoupdate", false, "Whether or not the osquery autoupdater is enabled (default: false)") + flAutoupdate = flagset.Bool("autoupdate", defaultAutoupdate, "Whether or not the osquery autoupdater is enabled (default: false)") flNotaryServerURL = flagset.String("notary_url", autoupdate.DefaultNotary, "The Notary update server (default: https://notary.kolide.co)") flTufServerURL = flagset.String("tuf_url", tuf.DefaultTufServer, "TUF update server (default: https://tuf.kolide.com)") flMirrorURL = flagset.String("mirror_url", autoupdate.DefaultMirror, "The mirror server for autoupdates (default: https://dl.kolide.co)") @@ -214,6 +233,7 @@ func parseOptions(args []string) (*launcher.Options, error) { AutoupdateInitialDelay: *flAutoupdateInitialDelay, CertPins: certPins, CompactDbMaxTx: *flCompactDbMaxTx, + ConfigFilePath: *flConfigFilePath, Control: false, ControlServerURL: controlServerURL, ControlRequestInterval: *flControlRequestInterval, diff --git a/cmd/launcher/options_test.go b/cmd/launcher/options_test.go index 2dd3dfcf8..d713fc963 100644 --- a/cmd/launcher/options_test.go +++ b/cmd/launcher/options_test.go @@ -28,7 +28,7 @@ func TestOptionsFromFlags(t *testing.T) { //nolint:paralleltest } } - opts, err := parseOptions(testFlags) + opts, err := parseOptions("", testFlags) require.NoError(t, err) require.Equal(t, expectedOpts, opts) } @@ -50,7 +50,7 @@ func TestOptionsFromEnv(t *testing.T) { //nolint:paralleltest name := fmt.Sprintf("KOLIDE_LAUNCHER_%s", strings.ToUpper(strings.TrimLeft(k, "-"))) require.NoError(t, os.Setenv(name, val)) } - opts, err := parseOptions([]string{}) + opts, err := parseOptions("", []string{}) require.NoError(t, err) require.Equal(t, expectedOpts, opts) } @@ -63,6 +63,7 @@ func TestOptionsFromFile(t *testing.T) { // nolint:paralleltest flagFile, err := os.CreateTemp("", "flag-file") require.NoError(t, err) defer os.Remove(flagFile.Name()) + expectedOpts.ConfigFilePath = flagFile.Name() for k, val := range testArgs { var err error @@ -81,7 +82,7 @@ func TestOptionsFromFile(t *testing.T) { // nolint:paralleltest require.NoError(t, flagFile.Close()) - opts, err := parseOptions([]string{"-config", flagFile.Name()}) + opts, err := parseOptions("", []string{"-config", flagFile.Name()}) require.NoError(t, err) require.Equal(t, expectedOpts, opts) } @@ -160,7 +161,7 @@ func TestOptionsSetControlServerHost(t *testing.T) { // nolint:paralleltest tt := tt os.Clearenv() t.Run(tt.testName, func(t *testing.T) { - opts, err := parseOptions(tt.testFlags) + opts, err := parseOptions("", tt.testFlags) require.NoError(t, err, "could not parse options") require.Equal(t, tt.expectedControlServer, opts.ControlServerURL, "incorrect control server") require.Equal(t, tt.expectedInsecureControlTLS, opts.InsecureControlTLS, "incorrect insecure TLS") @@ -205,11 +206,3 @@ func getArgsAndResponse() (map[string]string, *launcher.Options) { return args, opts } - -func windowsAddExe(in string) string { - if runtime.GOOS == "windows" { - return in + ".exe" - } - - return in -} diff --git a/cmd/launcher/paths.go b/cmd/launcher/paths.go new file mode 100644 index 000000000..7e250dab9 --- /dev/null +++ b/cmd/launcher/paths.go @@ -0,0 +1,69 @@ +package main + +import ( + "path/filepath" + "runtime" +) + +// setDefaultPaths populates the default file/dir paths +// call this before calling parseOptions if you want to assume these paths exist +func setDefaultPaths() { + switch runtime.GOOS { + case "darwin": + defaultRootDirectoryPath = "/var/kolide-k2/k2device.kolide.com/" + defaultEtcDirectoryPath = "/etc/kolide-k2/" + defaultBinDirectoryPath = "/usr/local/kolide-k2/" + defaultConfigFilePath = filepath.Join(defaultEtcDirectoryPath, "launcher.flags") + case "linux": + defaultRootDirectoryPath = "/var/kolide-k2/k2device.kolide.com/" + defaultEtcDirectoryPath = "/etc/kolide-k2/" + defaultBinDirectoryPath = "/usr/local/kolide-k2/" + defaultConfigFilePath = filepath.Join(defaultEtcDirectoryPath, "launcher.flags") + case "windows": + defaultRootDirectoryPath = "C:\\Program Files\\Kolide\\Launcher-kolide-k2\\data" + defaultEtcDirectoryPath = "" + defaultBinDirectoryPath = "C:\\Program Files\\Kolide\\Launcher-kolide-k2\\bin" + defaultConfigFilePath = filepath.Join("C:\\Program Files\\Kolide\\Launcher-kolide-k2\\conf", "launcher.flags") + } +} + +// getAppBinaryPaths returns the platform specific path where binaries are installed +func getAppBinaryPaths() []string { + var paths []string + switch runtime.GOOS { + case "darwin": + paths = []string{ + filepath.Join(defaultBinDirectoryPath, "Kolide.app", "Contents", "MacOS", "launcher"), + } + case "linux": + paths = []string{ + filepath.Join(defaultBinDirectoryPath, "launcher"), + } + case "windows": + paths = []string{ + filepath.Join(defaultBinDirectoryPath, "launcher.exe"), + } + } + return paths +} + +// getFilepaths returns a list of file paths matching the pattern +func getFilepaths(elem ...string) []string { + fileGlob := filepath.Join(elem...) + filepaths, err := filepath.Glob(fileGlob) + + if err == nil && len(filepaths) > 0 { + return filepaths + } + + return nil +} + +// windowsAddExe appends ".exe" to the input string when running on Windows +func windowsAddExe(in string) string { + if runtime.GOOS == "windows" { + return in + ".exe" + } + + return in +} diff --git a/cmd/launcher/run_compactdb.go b/cmd/launcher/run_compactdb.go index 7a7f62018..7b0156e9a 100644 --- a/cmd/launcher/run_compactdb.go +++ b/cmd/launcher/run_compactdb.go @@ -10,7 +10,7 @@ import ( ) func runCompactDb(args []string) error { - opts, err := parseOptions(args) + opts, err := parseOptions("compactdb", args) if err != nil { return err } diff --git a/cmd/launcher/svc_windows.go b/cmd/launcher/svc_windows.go index 40b99eaa1..87695f10b 100644 --- a/cmd/launcher/svc_windows.go +++ b/cmd/launcher/svc_windows.go @@ -45,7 +45,7 @@ func runWindowsSvc(args []string) error { "version", version.Version().Version, ) - opts, err := parseOptions(os.Args[2:]) + opts, err := parseOptions("", os.Args[2:]) if err != nil { level.Info(logger).Log("msg", "Error parsing options", "err", err) os.Exit(1) @@ -122,7 +122,7 @@ func runWindowsSvcForeground(args []string) error { logger := logutil.NewCLILogger(true) level.Debug(logger).Log("msg", "foreground service start requested (debug mode)") - opts, err := parseOptions(os.Args[2:]) + opts, err := parseOptions("", os.Args[2:]) if err != nil { level.Info(logger).Log("err", err) os.Exit(1) diff --git a/cmd/launcher/uninstall.go b/cmd/launcher/uninstall.go index 4f70b0bbc..fb529bb0b 100644 --- a/cmd/launcher/uninstall.go +++ b/cmd/launcher/uninstall.go @@ -32,6 +32,7 @@ func runUninstall(args []string) error { ff.WithEnvVarNoPrefix(), } + flagset.Usage = commandUsage(flagset, "launcher uninstall") if err := ff.Parse(flagset, args, ffOpts...); err != nil { return fmt.Errorf("parsing flags: %w", err) } diff --git a/go.mod b/go.mod index 6c36b331c..291a65e4d 100644 --- a/go.mod +++ b/go.mod @@ -55,6 +55,7 @@ require ( require ( github.com/apache/thrift v0.16.0 + github.com/fatih/color v1.15.0 github.com/kolide/systray v1.10.4 github.com/kolide/toast v1.0.0 github.com/shirou/gopsutil/v3 v3.23.3 @@ -87,6 +88,8 @@ require ( github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect github.com/miekg/pkcs11 v0.0.0-20180208123018-5f6e0d0dad6f // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/oklog/ulid v1.3.1 // indirect diff --git a/go.sum b/go.sum index db3ce700e..805bfe403 100644 --- a/go.sum +++ b/go.sum @@ -119,6 +119,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.m github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= +github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -327,7 +329,12 @@ github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPK github.com/mat/besticon v3.9.0+incompatible h1:SLaWKCE7ptsjWbQee8Sbx8F/WK4bw8b55tUV4mY0m/c= github.com/mat/besticon v3.9.0+incompatible/go.mod h1:mA1auQYHt6CW5e7L9HJLmqVQC8SzNk2gVwouO0AbiEU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o= github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= @@ -698,6 +705,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210629170331-7dc0b73dc9fb/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= diff --git a/pkg/agent/flags/flag_controller.go b/pkg/agent/flags/flag_controller.go index cafd37c84..633689877 100644 --- a/pkg/agent/flags/flag_controller.go +++ b/pkg/agent/flags/flag_controller.go @@ -1,6 +1,7 @@ package flags import ( + "errors" "sync" "time" @@ -43,6 +44,10 @@ func NewFlagController(logger log.Logger, agentFlagsStore types.KVStore, opts .. // getControlServerValue looks for a control-server-provided value for the key and returns it. // If a control server value is not found, nil is returned. func (fc *FlagController) getControlServerValue(key keys.FlagKey) []byte { + if fc == nil || fc.agentFlagsStore == nil { + return nil + } + value, err := fc.agentFlagsStore.Get([]byte(key)) if err != nil { level.Debug(fc.logger).Log("msg", "failed to get control server key", "key", key, "err", err) @@ -54,6 +59,10 @@ func (fc *FlagController) getControlServerValue(key keys.FlagKey) []byte { // setControlServerValue stores a control-server-provided value in the agent flags store. func (fc *FlagController) setControlServerValue(key keys.FlagKey, value []byte) error { + if fc == nil || fc.agentFlagsStore == nil { + return errors.New("agentFlagsStore is nil") + } + err := fc.agentFlagsStore.Set([]byte(key), value) if err != nil { level.Debug(fc.logger).Log("msg", "failed to set control server key", "key", key, "err", err) @@ -121,7 +130,7 @@ func (fc *FlagController) SetKolideHosted(hosted bool) error { return fc.setControlServerValue(keys.KolideHosted, boolToBytes(hosted)) } func (fc *FlagController) KolideHosted() bool { - return NewBoolFlagValue(WithDefaultBool(false)).get(fc.getControlServerValue(keys.KolideHosted)) + return NewBoolFlagValue(WithDefaultBool(fc.cmdLineOpts.KolideHosted)).get(fc.getControlServerValue(keys.KolideHosted)) } func (fc *FlagController) EnrollSecret() string { diff --git a/pkg/launcher/options.go b/pkg/launcher/options.go index 3a3a58758..bab8a4dfc 100644 --- a/pkg/launcher/options.go +++ b/pkg/launcher/options.go @@ -103,4 +103,7 @@ type Options struct { IAmBreakingEELicense bool // DelayStart allows for delaying launcher startup for a configurable amount of time DelayStart time.Duration + + // ConfigFilePath is the config file options were parsed from, if provided + ConfigFilePath string } diff --git a/pkg/log/checkpoint/checkpoint.go b/pkg/log/checkpoint/checkpoint.go index 4aec9e794..d4be60ac9 100644 --- a/pkg/log/checkpoint/checkpoint.go +++ b/pkg/log/checkpoint/checkpoint.go @@ -98,14 +98,24 @@ func (c *checkPointer) logCheckPoint() { c.logOsqueryInfo() c.logDbSize() c.logKolideServerVersion() - c.logConnections() - c.logIpLookups() - c.logNotaryVersions() + c.logger.Log("connections", c.Connections()) + c.logger.Log("ip look ups", c.IpLookups()) + notaryVersions, err := c.NotaryVersions() + if err != nil { + c.logger.Log("notary versions", err) + } else { + c.logger.Log("notary versions", notaryVersions) + } c.logServerProvidedData() } func (c *checkPointer) logDbSize() { - boltStats, err := agent.GetStats(c.knapsack.BboltDB()) + db := c.knapsack.BboltDB() + if db == nil { + return + } + + boltStats, err := agent.GetStats(db) if err != nil { c.logger.Log("bbolt db size", err.Error()) } else { @@ -128,29 +138,28 @@ func (c *checkPointer) logKolideServerVersion() { } } -func (c *checkPointer) logNotaryVersions() { +func (c *checkPointer) NotaryVersions() (map[string]string, error) { if !c.knapsack.KolideHosted() || !c.knapsack.Autoupdate() { - return + return nil, nil } httpClient := &http.Client{Timeout: requestTimeout} - notaryUrl, err := parseUrl(fmt.Sprintf("%s/v2/kolide/launcher/_trust/tuf/targets/releases.json", c.knapsack.NotaryServerURL()), c.knapsack) if err != nil { - c.logger.Log("notary versions", err) + return nil, err } else { - c.logger.Log("notary versions", fetchNotaryVersions(httpClient, notaryUrl)) + return fetchNotaryVersions(httpClient, notaryUrl), nil } } -func (c *checkPointer) logConnections() { +func (c *checkPointer) Connections() map[string]string { dialer := &net.Dialer{Timeout: requestTimeout} - c.logger.Log("connections", testConnections(dialer, urlsToTest(c.knapsack)...)) + return testConnections(dialer, urlsToTest(c.knapsack)...) } -func (c *checkPointer) logIpLookups() { +func (c *checkPointer) IpLookups() map[string]interface{} { ipLookuper := &net.Resolver{} - c.logger.Log("ip look ups", lookupHostsIpv4s(ipLookuper, urlsToTest(c.knapsack)...)) + return lookupHostsIpv4s(ipLookuper, urlsToTest(c.knapsack)...) } func urlsToTest(flags types.Flags) []*url.URL { diff --git a/pkg/log/checkpoint/server_data.go b/pkg/log/checkpoint/server_data.go index f01dfd68c..3c76e14ce 100644 --- a/pkg/log/checkpoint/server_data.go +++ b/pkg/log/checkpoint/server_data.go @@ -16,9 +16,14 @@ var serverProvidedDataKeys = []string{ // logServerProvidedData sends a subset of the server data into the checkpoint logs. This iterates over the // desired keys, as a way to handle missing values. func (c *checkPointer) logServerProvidedData() { + db := c.knapsack.BboltDB() + if db == nil { + return + } + data := make(map[string]string, len(serverProvidedDataKeys)) - if err := c.knapsack.BboltDB().View(func(tx *bbolt.Tx) error { + if err := db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte(storage.ServerProvidedDataStore)) if b == nil { return nil