diff --git a/changes/feature-6581-remote-flags-manaement-with-orbit b/changes/feature-6581-remote-flags-manaement-with-orbit new file mode 100644 index 0000000000..d063c83075 --- /dev/null +++ b/changes/feature-6581-remote-flags-manaement-with-orbit @@ -0,0 +1 @@ +* Added new endpoints for orbit to enroll and get startup flags diff --git a/orbit/changes/6581-remote-flags-management b/orbit/changes/6581-remote-flags-management new file mode 100644 index 0000000000..deca6d8ec6 --- /dev/null +++ b/orbit/changes/6581-remote-flags-management @@ -0,0 +1 @@ +* Orbit now enrolls with fleet using it's own orbit_node_key, and periodically checks for new flags (using the new API), and applies them to osquery diff --git a/orbit/cmd/orbit/orbit.go b/orbit/cmd/orbit/orbit.go index d674736f55..3ab2d97444 100644 --- a/orbit/cmd/orbit/orbit.go +++ b/orbit/cmd/orbit/orbit.go @@ -2,13 +2,17 @@ package main import ( "context" + "encoding/json" "errors" "fmt" + "github.com/fleetdm/fleet/v4/server/service" + "github.com/google/uuid" "io" "io/fs" "io/ioutil" "net/url" "os" + "os/exec" "path" "path/filepath" "runtime" @@ -26,7 +30,6 @@ import ( "github.com/fleetdm/fleet/v4/pkg/certificate" "github.com/fleetdm/fleet/v4/pkg/file" "github.com/fleetdm/fleet/v4/pkg/secure" - "github.com/google/uuid" "github.com/oklog/run" "github.com/rs/zerolog" "github.com/rs/zerolog/log" @@ -199,7 +202,7 @@ func main() { return errors.New("enroll-secret and enroll-secret-path may not be specified together") } - b, err := ioutil.ReadFile(c.String("enroll-secret-path")) + b, err := os.ReadFile(c.String("enroll-secret-path")) if err != nil { return fmt.Errorf("read enroll secret file: %w", err) } @@ -350,6 +353,13 @@ func main() { return fmt.Errorf("cleanup old files: %w", err) } + log.Debug().Msg("running single query (SELECT uuid FROM system_info)") + uuidStr, err := getUUID(osquerydPath) + if err != nil { + return fmt.Errorf("get UUID: %w", err) + } + log.Debug().Msg("UUID is " + uuidStr) + var options []osquery.Option options = append(options, osquery.WithDataPath(c.String("root-dir"))) options = append(options, osquery.WithLogPath(filepath.Join(c.String("root-dir"), "osquery_log"))) @@ -373,6 +383,7 @@ func main() { ) } + var certPath string if fleetURL != "https://" && c.Bool("insecure") { proxy, err := insecure.NewTLSProxy(fleetURL) if err != nil { @@ -401,10 +412,10 @@ func main() { return fmt.Errorf("there was a problem creating the proxy directory: %w", err) } - certPath := filepath.Join(proxyDirectory, "fleet.crt") + certPath = filepath.Join(proxyDirectory, "fleet.crt") // Write cert that proxy uses - err = ioutil.WriteFile(certPath, []byte(insecure.ServerCert), os.ModePerm) + err = os.WriteFile(certPath, []byte(insecure.ServerCert), os.ModePerm) if err != nil { return fmt.Errorf("write server cert: %w", err) } @@ -443,7 +454,7 @@ func main() { osquery.WithFlags(osquery.FleetFlags(parsedURL)), ) - if certPath := c.String("fleet-certificate"); certPath != "" { + if certPath = c.String("fleet-certificate"); certPath != "" { // Check and log if there are any errors with TLS connection. pool, err := certificate.LoadPEM(certPath) if err != nil { @@ -457,7 +468,7 @@ func main() { osquery.WithFlags([]string{"--tls_server_certs", certPath}), ) } else { - certPath := filepath.Join(c.String("root-dir"), "certs.pem") + certPath = filepath.Join(c.String("root-dir"), "certs.pem") if exists, err := file.Exists(certPath); err == nil && exists { _, err = certificate.LoadPEM(certPath) if err != nil { @@ -468,8 +479,35 @@ func main() { log.Info().Msg("No cert chain available. Relying on system store.") } } + } + orbitClient, err := service.NewOrbitClient(fleetURL, c.String("fleet-certificate"), c.Bool("insecure"), enrollSecret, uuidStr) + if err != nil { + return fmt.Errorf("error new orbit client: %w", err) + } + orbitNodeKey, err := getOrbitNodeKeyOrEnroll(orbitClient, c.String("root-dir")) + if err != nil { + return fmt.Errorf("error enroll: %w", err) + } + + const orbitFlagsUpdateInterval = 30 * time.Second + flagRunner, err := update.NewFlagRunner(orbitClient, update.FlagUpdateOptions{ + CheckInterval: orbitFlagsUpdateInterval, + RootDir: c.String("root-dir"), + OrbitNodeKey: orbitNodeKey, + }) + if err != nil { + return err + } + // do the initial flags update + _, err = flagRunner.DoFlagsUpdate() + if err != nil { + // just log, OK to continue, since we will retry + log.Info().Err(err).Msg("Initial flags update failed") + } + g.Add(flagRunner.Execute, flagRunner.Interrupt) + // --force is sometimes needed when an older osquery process has not // exited properly options = append(options, osquery.WithFlags([]string{"--force"})) @@ -651,6 +689,86 @@ func (d *desktopRunner) interrupt(err error) { } } +// shell out to osquery (on Linux and macOS) or to wmic (on Windows), and get the system uuid +func getUUID(osqueryPath string) (string, error) { + if runtime.GOOS == "windows" { + args := []string{"/C", "wmic csproduct get UUID"} + out, err := exec.Command("cmd", args...).Output() + if err != nil { + return "", err + } + uuidOutputStr := string(out) + if len(uuidOutputStr) == 0 { + return "", errors.New("get UUID: output from wmi is empty") + } + outputByLines := strings.Split(strings.TrimRight(uuidOutputStr, "\n"), "\n") + if len(outputByLines) < 2 { + return "", errors.New("get UUID: unexpected output") + } + return strings.TrimSpace(outputByLines[1]), nil + } + type UuidOutput struct { + UuidString string `json:"uuid"` + } + + args := []string{"-S", "--json", "select uuid from system_info"} + out, err := exec.Command(osqueryPath, args...).Output() + if err != nil { + return "", err + } + var uuids []UuidOutput + err = json.Unmarshal(out, &uuids) + if err != nil { + return "", err + } + + if len(uuids) != 1 { + return "", fmt.Errorf("invalid number of rows from system_info query: %d", len(uuids)) + } + return uuids[0].UuidString, nil + +} + +// getOrbitNodeKeyOrEnroll attempts to read the orbit node key if the file exists on disk +// otherwise it enrolls the host with Fleet and saves the node key to disk +func getOrbitNodeKeyOrEnroll(orbitClient *service.OrbitClient, rootDir string) (string, error) { + nodeKeyFilePath := filepath.Join(rootDir, "secret-orbit-node-key.txt") + orbitNodeKey, err := ioutil.ReadFile(nodeKeyFilePath) + switch { + case err == nil: + return string(orbitNodeKey), nil + case errors.Is(err, fs.ErrNotExist): + // OK + default: + return "", fmt.Errorf("read orbit node key file: %w", err) + } + const ( + orbitEnrollMaxRetries = 10 + orbitEnrollRetrySleep = 5 * time.Second + ) + for retries := 0; retries < orbitEnrollMaxRetries; retries++ { + orbitNodeKey, err := enrollAndWriteNodeKeyFile(orbitClient, nodeKeyFilePath) + if err != nil { + log.Info().Err(err).Msg("enroll failed, retrying") + time.Sleep(orbitEnrollRetrySleep) + continue + } + return orbitNodeKey, nil + } + return "", fmt.Errorf("orbit node key enroll failed, attempts=%d", orbitEnrollMaxRetries) +} + +func enrollAndWriteNodeKeyFile(orbitClient *service.OrbitClient, nodeKeyFilePath string) (string, error) { + orbitNodeKey, err := orbitClient.DoEnroll() + if err != nil { + return "", fmt.Errorf("enroll request: %w", err) + } + if err := os.WriteFile(nodeKeyFilePath, []byte(orbitNodeKey), constant.DefaultFileMode); err != nil { + return "", fmt.Errorf("write orbit node key file: %w", err) + } + return orbitNodeKey, nil +} + func loadOrGenerateToken(rootDir string) (string, error) { filePath := filepath.Join(rootDir, "identifier") id, err := ioutil.ReadFile(filePath) @@ -662,7 +780,7 @@ func loadOrGenerateToken(rootDir string) (string, error) { if err != nil { return "", fmt.Errorf("generate identifier: %w", err) } - if err := ioutil.WriteFile(filePath, []byte(id.String()), constant.DefaultFileMode); err != nil { + if err := os.WriteFile(filePath, []byte(id.String()), constant.DefaultFileMode); err != nil { return "", fmt.Errorf("write identifier file %q: %w", filePath, err) } return id.String(), nil diff --git a/orbit/pkg/update/flag_runner.go b/orbit/pkg/update/flag_runner.go new file mode 100644 index 0000000000..8b02f53ac4 --- /dev/null +++ b/orbit/pkg/update/flag_runner.go @@ -0,0 +1,190 @@ +package update + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/fleetdm/fleet/v4/orbit/pkg/constant" + "github.com/fleetdm/fleet/v4/server/service" + "github.com/rs/zerolog/log" + "os" + "path/filepath" + "reflect" + "strings" + "time" +) + +// FlagRunner is a specialized runner to periodically check and update flags from Fleet +// It is designed with Execute and Interrupt functions to be compatible with oklog/run +// +// It uses an OrbitClient, along with FlagUpdateOptions to connect to Fleet +type FlagRunner struct { + orbitClient *service.OrbitClient + opt FlagUpdateOptions + cancel chan struct{} +} + +// FlagUpdateOptions is options provided for the flag update runner +type FlagUpdateOptions struct { + // CheckInterval is the interval to check for updates + CheckInterval time.Duration + // RootDir is the root directory for orbit state + RootDir string + // OrbitNodeKey is the orbit node key for the enrolled host + OrbitNodeKey string +} + +// NewFlagRunner creates a new runner with provided options +// The runner must be started with Execute +func NewFlagRunner(orbitClient *service.OrbitClient, opt FlagUpdateOptions) (*FlagRunner, error) { + r := &FlagRunner{ + orbitClient: orbitClient, + opt: opt, + cancel: make(chan struct{}, 1), + } + return r, nil +} + +// Execute starts the loop checking for updates +func (r *FlagRunner) Execute() error { + log.Debug().Msg("starting flag updater") + + ticker := time.NewTicker(r.opt.CheckInterval) + defer ticker.Stop() + + // Run until cancel or returning an error + for { + select { + case <-r.cancel: + return nil + case <-ticker.C: + log.Info().Msg("calling flags update") + didUpdate, err := r.DoFlagsUpdate() + if err != nil { + log.Info().Err(err).Msg("flags updates failed") + } + if didUpdate { + log.Info().Msg("flags updated, exiting") + return nil + } + } + } +} + +// Interrupt is the oklog/run interrupt method that stops orbit when interrupt is received +func (r *FlagRunner) Interrupt(err error) { + close(r.cancel) + log.Debug().Err(err).Msg("interrupt for flags updater") +} + +// DoFlagsUpdate checks for update of flags from Fleet +// It gets the flags from the Fleet server, and compares them to locally stored flagfile (if it exists) +// If the flag comparison from disk and server are not equal, it writes the flags to disk, and returns true +func (r *FlagRunner) DoFlagsUpdate() (bool, error) { + flagFileExists := true + + // first off try and read osquery.flags from disk + osqueryFlagMapFromFile, err := readFlagFile(r.opt.RootDir) + if err != nil { + if !errors.Is(err, os.ErrNotExist) { + return false, err + } + // flag file may not exist on disk on first "boot" + flagFileExists = false + } + + // next GetConfig from Fleet API + flagsJSON, err := r.orbitClient.GetConfig(r.opt.OrbitNodeKey) + if err != nil { + return false, fmt.Errorf("error getting flags from fleet %w", err) + } + if len(flagsJSON) == 0 { + // command_line_flags not set in YAML, nothing to do + return false, nil + } + + osqueryFlagMapFromFleet, err := getFlagsFromJSON(flagsJSON) + if err != nil { + return false, fmt.Errorf("error parsing flags %w", err) + } + + // compare both flags, if they are equal, nothing to do + if flagFileExists && reflect.DeepEqual(osqueryFlagMapFromFile, osqueryFlagMapFromFleet) { + return false, nil + } + + // flags are not equal, write the fleet flags to disk + err = writeFlagFile(r.opt.RootDir, osqueryFlagMapFromFleet) + if err != nil { + return false, fmt.Errorf("error writing flags to disk %w", err) + } + return true, nil +} + +// getFlagsFromJSON converts the json of the type below +// {"number": 5, "string": "str", "boolean": true} +// to a map[string]string +// this map will get compared and written to the filesystem and passed to osquery +// this only supports simple key:value pairs and not nested structures +func getFlagsFromJSON(flags json.RawMessage) (map[string]string, error) { + result := make(map[string]string) + + var data map[string]interface{} + err := json.Unmarshal([]byte(flags), &data) + if err != nil { + return nil, err + } + + for k, v := range data { + result["--"+k] = fmt.Sprintf("%v", v) + } + return result, nil +} + +// writeFlagFile writes the contents of the data map as a osquery flagfile to disk +// given a map[string]string, of the form: {"--foo":"bar","--value":"5"} +// it writes the contents of key=value, one line per pair to the file +// this only supports simple key:value pairs and not nested structures +func writeFlagFile(rootDir string, data map[string]string) error { + flagfile := filepath.Join(rootDir, "osquery.flags") + var sb strings.Builder + for k, v := range data { + if k != "" && v != "" { + sb.WriteString(k + "=" + v + "\n") + } else if v == "" { + sb.WriteString(k + "\n") + } + } + if err := os.WriteFile(flagfile, []byte(sb.String()), constant.DefaultFileMode); err != nil { + return fmt.Errorf("writing flagfile %s failed: %w", flagfile, err) + } + return nil +} + +// readFlagFile reads and parses the osquery.flags file on disk +// and returns a map[string]string, of the form: +// {"--foo":"bar","--value":"5"} +// this only supports simple key:value pairs and not nested structures +func readFlagFile(rootDir string) (map[string]string, error) { + flagfile := filepath.Join(rootDir, "osquery.flags") + bytes, err := os.ReadFile(flagfile) + if err != nil { + return nil, fmt.Errorf("reading flagfile %s failed: %w", flagfile, err) + } + result := make(map[string]string) + lines := strings.Split(strings.TrimSpace(string(bytes)), "\n") + for _, line := range lines { + // skip line starting with "#" indicating that it's a comment + if !strings.HasPrefix(line, "#") { + // split each line by "=" + str := strings.Split(strings.TrimSpace(line), "=") + if len(str) == 2 { + result[str[0]] = str[1] + } + if len(str) == 1 { + result[str[0]] = "" + } + } + } + return result, nil +} diff --git a/orbit/pkg/update/flag_runner_test.go b/orbit/pkg/update/flag_runner_test.go new file mode 100644 index 0000000000..41e02d5ad1 --- /dev/null +++ b/orbit/pkg/update/flag_runner_test.go @@ -0,0 +1,59 @@ +package update + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/stretchr/testify/require" +) + +var rawJSONFlags = json.RawMessage(`{"verbose":true, "num":5, "hello":"world"}`) + +func TestGetFlagsFromJson(t *testing.T) { + flagsJson, err := getFlagsFromJSON(rawJSONFlags) + require.NoError(t, err) + + require.NotEmpty(t, flagsJson) + + value, ok := flagsJson["--verbose"] + if !ok { + t.Errorf(`key ""--verbose" expected but not found`) + } + if value != "true" { + t.Errorf(`expected "true", got %s`, value) + } + + value, ok = flagsJson["--num"] + if !ok { + t.Errorf(`key "--num" expected but not found`) + } + if value != "5" { + t.Errorf(`expected "5", got %s`, value) + } + + value, ok = flagsJson["--hello"] + if !ok { + t.Errorf(`key "--hello" expected but not found`) + } + if value != "world" { + t.Errorf(`expected "world", got %s`, value) + } +} + +func TestWriteFlagFile(t *testing.T) { + flags, err := getFlagsFromJSON(rawJSONFlags) + require.NoError(t, err) + + tempDir := t.TempDir() + err = writeFlagFile(tempDir, flags) + require.NoError(t, err) + + diskFlags, err := readFlagFile(tempDir) + require.NoError(t, err) + require.NotEmpty(t, diskFlags) + + if !reflect.DeepEqual(flags, diskFlags) { + t.Errorf("expected flags to be equal: %v, %v", flags, diskFlags) + } +} diff --git a/server/contexts/authz/authz.go b/server/contexts/authz/authz.go index 375d138da7..087b8ec07b 100644 --- a/server/contexts/authz/authz.go +++ b/server/contexts/authz/authz.go @@ -39,6 +39,10 @@ const ( // which only allows limited access to the device's own host information. // This authentication mode does not support granular authorization. AuthnDeviceToken + // AuthnOrbitToken is when authentication is done via the orbit host + // authentication token. This authentication mode does not support granular + // authorization. + AuthnOrbitToken ) // AuthorizationContext contains the context information used for the diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 5e2fd0592c..cea1c419fe 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -496,6 +496,7 @@ func (ds *Datastore) ListHosts(ctx context.Context, filter fleet.TeamFilter, opt h.team_id, h.policy_updated_at, h.public_ip, + h.orbit_node_key, COALESCE(hd.gigs_disk_space_available, 0) as gigs_disk_space_available, COALESCE(hd.percent_disk_space_available, 0) as percent_disk_space_available, COALESCE(hst.seen_time, h.created_at) AS seen_time, @@ -837,6 +838,60 @@ func shouldCleanTeamPolicies(currentTeamID, newTeamID *uint) bool { return *currentTeamID != *newTeamID } +func (ds *Datastore) EnrollOrbit(ctx context.Context, hardwareUUID string, orbitNodeKey string, teamID *uint) (*fleet.Host, error) { + if orbitNodeKey == "" { + return nil, ctxerr.New(ctx, "orbit node key is empty") + } + + if hardwareUUID == "" { + return nil, ctxerr.New(ctx, "hardware uuid is empty") + } + + var host fleet.Host + err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + err := sqlx.GetContext(ctx, tx, &host, `SELECT id FROM hosts WHERE uuid = ?`, hardwareUUID) + switch { + case err == nil: + sqlUpdate := `UPDATE hosts SET orbit_node_key = ? WHERE uuid = ? ` + _, err := tx.ExecContext(ctx, sqlUpdate, orbitNodeKey, hardwareUUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "orbit enroll error updating host details") + } + case errors.Is(err, sql.ErrNoRows): + zeroTime := time.Unix(0, 0).Add(24 * time.Hour) + // Create new host record. We always create newly enrolled hosts with refetch_requested = true + // so that the frontend automatically starts background checks to update the page whenever + // the refetch is completed. + // We are also initially setting node_key to be the same as orbit_node_key because node_key has a unique + // constraint + sqlInsert := ` + INSERT INTO hosts ( + last_enrolled_at, + detail_updated_at, + label_updated_at, + policy_updated_at, + osquery_host_id, + node_key, + team_id, + refetch_requested, + orbit_node_key + ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?) + ` + _, err := tx.ExecContext(ctx, sqlInsert, zeroTime, zeroTime, zeroTime, zeroTime, hardwareUUID, orbitNodeKey, teamID, orbitNodeKey) + if err != nil { + return ctxerr.Wrap(ctx, err, "orbit enroll error inserting host details") + } + default: + return ctxerr.Wrap(ctx, err, "orbit enroll error selecting host details") + } + return nil + }) + if err != nil { + return nil, err + } + return &host, nil +} + // EnrollHost enrolls a host func (ds *Datastore) EnrollHost(ctx context.Context, osqueryHostID, nodeKey string, teamID *uint, cooldown time.Duration) (*fleet.Host, error) { if osqueryHostID == "" { @@ -869,9 +924,11 @@ func (ds *Datastore) EnrollHost(ctx context.Context, osqueryHostID, nodeKey stri ` result, err := tx.ExecContext(ctx, sqlInsert, zeroTime, zeroTime, zeroTime, osqueryHostID, nodeKey, teamID) if err != nil { + level.Info(ds.logger).Log("hostIDError", err.Error()) return ctxerr.Wrap(ctx, err, "insert host") } hostID, _ = result.LastInsertId() + level.Info(ds.logger).Log("hostID", hostID) default: // Prevent hosts from enrolling too often with the same identifier. // Prior to adding this we saw many hosts (probably VMs) with the @@ -947,6 +1004,7 @@ func (ds *Datastore) EnrollHost(ctx context.Context, osqueryHostID, nodeKey stri h.team_id, h.policy_updated_at, h.public_ip, + h.orbit_node_key, COALESCE(hd.gigs_disk_space_available, 0) as gigs_disk_space_available, COALESCE(hd.percent_disk_space_available, 0) as percent_disk_space_available FROM @@ -1027,6 +1085,7 @@ func (ds *Datastore) LoadHostByNodeKey(ctx context.Context, nodeKey string) (*fl h.team_id, h.policy_updated_at, h.public_ip, + h.orbit_node_key, COALESCE(hd.gigs_disk_space_available, 0) as gigs_disk_space_available, COALESCE(hd.percent_disk_space_available, 0) as percent_disk_space_available FROM @@ -1046,6 +1105,22 @@ func (ds *Datastore) LoadHostByNodeKey(ctx context.Context, nodeKey string) (*fl } } +// LoadHostByOrbitNodeKey loads the whole host identified by the node key. +// If the node key is invalid it returns a NotFoundError. +func (ds *Datastore) LoadHostByOrbitNodeKey(ctx context.Context, nodeKey string) (*fleet.Host, error) { + query := `SELECT * FROM hosts WHERE orbit_node_key = ?` + + var host fleet.Host + switch err := ds.getContextTryStmt(ctx, &host, query, nodeKey); { + case err == nil: + return &host, nil + case errors.Is(err, sql.ErrNoRows): + return nil, ctxerr.Wrap(ctx, notFound("Host")) + default: + return nil, ctxerr.Wrap(ctx, err, "find host") + } +} + // LoadHostByDeviceAuthToken loads the whole host identified by the device auth token. // If the token is invalid it returns a NotFoundError. func (ds *Datastore) LoadHostByDeviceAuthToken(ctx context.Context, authToken string) (*fleet.Host, error) { @@ -1198,6 +1273,7 @@ func (ds *Datastore) SearchHosts(ctx context.Context, filter fleet.TeamFilter, m h.team_id, h.policy_updated_at, h.public_ip, + h.orbit_node_key, COALESCE(hd.gigs_disk_space_available, 0) as gigs_disk_space_available, COALESCE(hd.percent_disk_space_available, 0) as percent_disk_space_available, COALESCE(hst.seen_time, h.created_at) AS seen_time @@ -1299,6 +1375,7 @@ func (ds *Datastore) HostByIdentifier(ctx context.Context, identifier string) (* h.team_id, h.policy_updated_at, h.public_ip, + h.orbit_node_key, COALESCE(hd.gigs_disk_space_available, 0) as gigs_disk_space_available, COALESCE(hd.percent_disk_space_available, 0) as percent_disk_space_available, COALESCE(hst.seen_time, h.created_at) AS seen_time @@ -2553,7 +2630,8 @@ func (ds *Datastore) UpdateHost(ctx context.Context, host *fleet.Host) error { primary_ip = ?, primary_mac = ?, public_ip = ?, - refetch_requested = ? + refetch_requested = ?, + orbit_node_key = ? WHERE id = ? ` _, err := ds.writer.ExecContext(ctx, sqlStatement, @@ -2589,6 +2667,7 @@ func (ds *Datastore) UpdateHost(ctx context.Context, host *fleet.Host) error { host.PrimaryMac, host.PublicIP, host.RefetchRequested, + host.OrbitNodeKey, host.ID, ) if err != nil { diff --git a/server/datastore/mysql/migrations/tables/20220908181826_AddOrbitNodeKeyToHosts.go b/server/datastore/mysql/migrations/tables/20220908181826_AddOrbitNodeKeyToHosts.go new file mode 100644 index 0000000000..16b3324f1a --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20220908181826_AddOrbitNodeKeyToHosts.go @@ -0,0 +1,18 @@ +package tables + +import ( + "database/sql" +) + +func init() { + MigrationClient.AddMigration(Up_20220908181826, Down_20220908181826) +} + +func Up_20220908181826(tx *sql.Tx) error { + _, err := tx.Exec(`ALTER TABLE hosts ADD COLUMN orbit_node_key VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL;`) + return err +} + +func Down_20220908181826(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20220908181826_AddOrbitNodeKeyToHosts_test.go b/server/datastore/mysql/migrations/tables/20220908181826_AddOrbitNodeKeyToHosts_test.go new file mode 100644 index 0000000000..f76294b311 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20220908181826_AddOrbitNodeKeyToHosts_test.go @@ -0,0 +1,32 @@ +package tables + +import ( + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestUp_20220908181826(t *testing.T) { + db := applyUpToPrev(t) + + zeroTime := time.Unix(0, 0).Add(24 * time.Hour) + sqlInsert := ` + INSERT INTO hosts ( + detail_updated_at, + label_updated_at, + policy_updated_at, + osquery_host_id, + node_key, + team_id, + refetch_requested + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ` + _, err := db.Exec(sqlInsert, zeroTime, zeroTime, zeroTime, "host_id", "node_key", nil, 1) + require.NoError(t, err) + + applyNext(t, db) + sqlUpdate := `UPDATE hosts SET orbit_node_key = ? WHERE osquery_host_id = ?` + _, err = db.Exec(sqlUpdate, "orbit_node_key", "host_id") + require.NoError(t, err) + +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 2082a7ebf0..95f491405d 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -301,6 +301,7 @@ CREATE TABLE `hosts` ( `percent_disk_space_available` float NOT NULL DEFAULT '0', `policy_updated_at` timestamp NOT NULL DEFAULT '2000-01-01 00:00:00', `public_ip` varchar(45) NOT NULL DEFAULT '', + `orbit_node_key` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `idx_osquery_host_id` (`osquery_host_id`), UNIQUE KEY `idx_host_unique_nodekey` (`node_key`), @@ -403,9 +404,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=149 DEFAULT CHARSET=utf8mb4; +) ENGINE=InnoDB AUTO_INCREMENT=150 DEFAULT CHARSET=utf8mb4; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220914154915,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/fleet/agent_options.go b/server/fleet/agent_options.go index 75472fa69a..e801cf9ed6 100644 --- a/server/fleet/agent_options.go +++ b/server/fleet/agent_options.go @@ -14,6 +14,8 @@ type AgentOptions struct { Config json.RawMessage `json:"config"` // Overrides includes any platform-based overrides. Overrides AgentOptionsOverrides `json:"overrides,omitempty"` + // CommandLineStartUpFlags are the osquery CLI_FLAGS + CommandLineStartUpFlags json.RawMessage `json:"command_line_flags,omitempty"` } type AgentOptionsOverrides struct { diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 649346f4c1..2988f71967 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -519,6 +519,10 @@ type Datastore interface { // If the node key is invalid it returns a NotFoundError. LoadHostByNodeKey(ctx context.Context, nodeKey string) (*Host, error) + // LoadHostByOrbitNodeKey loads the whole host identified by the node key. + // If the node key is invalid it returns a NotFoundError. + LoadHostByOrbitNodeKey(ctx context.Context, nodeKey string) (*Host, error) + // HostLite will load the primary data of the host with the given id. // We define "primary data" as all host information except the // details (like cpu, memory, gigs_disk_space_available, etc.). @@ -600,6 +604,9 @@ type Datastore interface { // within the cooldown period. EnrollHost(ctx context.Context, osqueryHostId, nodeKey string, teamID *uint, cooldown time.Duration) (*Host, error) + // EnrollOrbit will enroll a new orbit host with the given uuid, setting the orbit node key + EnrollOrbit(ctx context.Context, hardwareUUID string, orbitNodeKey string, teamID *uint) (*Host, error) + SerialUpdateHost(ctx context.Context, host *Host) error /////////////////////////////////////////////////////////////////////////////// diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index b754141db2..0943597dd5 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -125,6 +125,7 @@ type Host struct { SeenTime time.Time `json:"seen_time" db:"seen_time" csv:"seen_time"` // Time that the host was last "seen" RefetchRequested bool `json:"refetch_requested" db:"refetch_requested" csv:"refetch_requested"` NodeKey string `json:"-" db:"node_key" csv:"-"` + OrbitNodeKey *string `json:"-" db:"orbit_node_key" csv:"-"` Hostname string `json:"hostname" db:"hostname" csv:"hostname"` // there is a fulltext index on this field UUID string `json:"uuid" db:"uuid" csv:"uuid"` // there is a fulltext index on this field // Platform is the host's platform as defined by osquery's os_version.platform. diff --git a/server/fleet/service.go b/server/fleet/service.go index 82f85fb48d..74b9edc39b 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -50,6 +50,13 @@ type OsqueryService interface { type Service interface { OsqueryService + // AuthenticateOrbitHost loads host identified by orbit's nodeKey. Returns an error if that nodeKey doesn't exist + AuthenticateOrbitHost(ctx context.Context, nodeKey string) (host *Host, debug bool, err error) + // EnrollOrbit enrolls orbit to Fleet by using the enrollSecret and returns the orbitNodeKey if successful + EnrollOrbit(ctx context.Context, hardwareUUID string, enrollSecret string) (orbitNodeKey string, err error) + // GetOrbitFlags returns team specific flags in agent options if the team id is not nil for host, otherwise it returns flags from global agent options + GetOrbitFlags(ctx context.Context) (flags json.RawMessage, err error) + // SetEnterpriseOverrides allows the enterprise service to override specific methods // that can't be easily overridden via embedding. // diff --git a/server/mock/datastore.go b/server/mock/datastore.go index 7b2cdca8d9..3a1bd8045f 100644 --- a/server/mock/datastore.go +++ b/server/mock/datastore.go @@ -15,6 +15,14 @@ type Store struct { DataStore } +func (m *Store) EnrollOrbit(ctx context.Context, hardwareUUID string, orbitNodeKey string, teamID *uint) (*fleet.Host, error) { + return nil, nil +} + +func (m *Store) LoadHostByOrbitNodeKey(ctx context.Context, orbitNodeKey string) (*fleet.Host, error) { + return nil, nil +} + func (m *Store) Drop() error { return nil } func (m *Store) MigrateTables(ctx context.Context) error { return nil } func (m *Store) MigrateData(ctx context.Context) error { return nil } diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 27d352a796..7e2b41d512 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -393,6 +393,8 @@ type UpdateQueryAggregatedStatsFunc func(ctx context.Context) error type LoadHostByNodeKeyFunc func(ctx context.Context, nodeKey string) (*fleet.Host, error) +type LoadHostByOrbitNodeKeyFunc func(ctx context.Context, nodeKey string) (*fleet.Host, error) + type HostLiteFunc func(ctx context.Context, hostID uint) (*fleet.Host, error) type UpdateHostOsqueryIntervalsFunc func(ctx context.Context, hostID uint, intervals fleet.HostOsqueryIntervals) error @@ -437,6 +439,8 @@ type VerifyEnrollSecretFunc func(ctx context.Context, secret string) (*fleet.Enr type EnrollHostFunc func(ctx context.Context, osqueryHostId string, nodeKey string, teamID *uint, cooldown time.Duration) (*fleet.Host, error) +type EnrollOrbitFunc func(ctx context.Context, hardwareUUID string, orbitNodeKey string, teamID *uint) (*fleet.Host, error) + type SerialUpdateHostFunc func(ctx context.Context, host *fleet.Host) error type NewJobFunc func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) @@ -1022,6 +1026,9 @@ type DataStore struct { LoadHostByNodeKeyFunc LoadHostByNodeKeyFunc LoadHostByNodeKeyFuncInvoked bool + LoadHostByOrbitNodeKeyFunc LoadHostByOrbitNodeKeyFunc + LoadHostByOrbitNodeKeyFuncInvoked bool + HostLiteFunc HostLiteFunc HostLiteFuncInvoked bool @@ -1088,6 +1095,9 @@ type DataStore struct { EnrollHostFunc EnrollHostFunc EnrollHostFuncInvoked bool + EnrollOrbitFunc EnrollOrbitFunc + EnrollOrbitFuncInvoked bool + SerialUpdateHostFunc SerialUpdateHostFunc SerialUpdateHostFuncInvoked bool @@ -2060,6 +2070,11 @@ func (s *DataStore) LoadHostByNodeKey(ctx context.Context, nodeKey string) (*fle return s.LoadHostByNodeKeyFunc(ctx, nodeKey) } +func (s *DataStore) LoadHostByOrbitNodeKey(ctx context.Context, nodeKey string) (*fleet.Host, error) { + s.LoadHostByOrbitNodeKeyFuncInvoked = true + return s.LoadHostByOrbitNodeKeyFunc(ctx, nodeKey) +} + func (s *DataStore) HostLite(ctx context.Context, hostID uint) (*fleet.Host, error) { s.HostLiteFuncInvoked = true return s.HostLiteFunc(ctx, hostID) @@ -2170,6 +2185,11 @@ func (s *DataStore) EnrollHost(ctx context.Context, osqueryHostId string, nodeKe return s.EnrollHostFunc(ctx, osqueryHostId, nodeKey, teamID, cooldown) } +func (s *DataStore) EnrollOrbit(ctx context.Context, hardwareUUID string, orbitNodeKey string, teamID *uint) (*fleet.Host, error) { + s.EnrollOrbitFuncInvoked = true + return s.EnrollOrbitFunc(ctx, hardwareUUID, orbitNodeKey, teamID) +} + func (s *DataStore) SerialUpdateHost(ctx context.Context, host *fleet.Host) error { s.SerialUpdateHostFuncInvoked = true return s.SerialUpdateHostFunc(ctx, host) diff --git a/server/service/base_client.go b/server/service/base_client.go index 4f2d02b1eb..c7d0ecb560 100644 --- a/server/service/base_client.go +++ b/server/service/base_client.go @@ -114,6 +114,5 @@ func newBaseClient(addr string, insecureSkipVerify bool, rootCA, urlPrefix strin insecureSkipVerify: insecureSkipVerify, urlPrefix: urlPrefix, } - return client, nil } diff --git a/server/service/endpoint_middleware.go b/server/service/endpoint_middleware.go index c51dbf36c7..8c0132886c 100644 --- a/server/service/endpoint_middleware.go +++ b/server/service/endpoint_middleware.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "github.com/fleetdm/fleet/v4/server/contexts/logging" @@ -121,6 +122,50 @@ func authenticatedHost(svc fleet.Service, logger log.Logger, next endpoint.Endpo return logged(authHostFunc) } +func authenticatedOrbitHost(svc fleet.Service, logger log.Logger, next endpoint.Endpoint) endpoint.Endpoint { + authHostFunc := func(ctx context.Context, request interface{}) (interface{}, error) { + nodeKey, err := getOrbitNodeKey(request) + if err != nil { + return nil, err + } + + host, debug, err := svc.AuthenticateOrbitHost(ctx, nodeKey) + if err != nil { + logging.WithErr(ctx, err) + return nil, err + } + + hlogger := log.With(logger, "host-id", host.ID) + if debug { + logJSON(hlogger, request, "request") + } + + ctx = hostctx.NewContext(ctx, host) + instrumentHostLogger(ctx) + if ac, ok := authz_ctx.FromContext(ctx); ok { + ac.SetAuthnMethod(authz_ctx.AuthnOrbitToken) + } + + resp, err := next(ctx, request) + if err != nil { + return nil, err + } + + if debug { + logJSON(hlogger, resp, "response") + } + return resp, nil + } + return logged(authHostFunc) +} + +func getOrbitNodeKey(r interface{}) (string, error) { + if onk, err := r.(interface{ orbitHostNodeKey() string }); err { + return onk.orbitHostNodeKey(), nil + } + return "", errors.New("error getting orbit node key") +} + func getNodeKey(r interface{}) (string, error) { if hnk, ok := r.(interface{ hostNodeKey() string }); ok { return hnk.hostNodeKey(), nil diff --git a/server/service/endpoint_utils.go b/server/service/endpoint_utils.go index 1673c1d25d..363733a57f 100644 --- a/server/service/endpoint_utils.go +++ b/server/service/endpoint_utils.go @@ -326,6 +326,19 @@ func newHostAuthenticatedEndpointer(svc fleet.Service, logger log.Logger, opts [ } } +func newOrbitAuthenticatedEndpointer(svc fleet.Service, logger log.Logger, opts []kithttp.ServerOption, r *mux.Router, versions ...string) *authEndpointer { + authFunc := func(svc fleet.Service, next endpoint.Endpoint) endpoint.Endpoint { + return authenticatedOrbitHost(svc, logger, next) + } + return &authEndpointer{ + svc: svc, + opts: opts, + r: r, + authFunc: authFunc, + versions: versions, + } +} + func newNoAuthEndpointer(svc fleet.Service, opts []kithttp.ServerOption, r *mux.Router, versions ...string) *authEndpointer { return &authEndpointer{ svc: svc, diff --git a/server/service/handler.go b/server/service/handler.go index d39bf98493..091ac59dbf 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -454,6 +454,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC he.WithAltPaths("/api/v1/osquery/log"). POST("/api/osquery/log", submitLogsEndpoint, submitLogsRequest{}) + // orbit authenticated endpoints + oe := newOrbitAuthenticatedEndpointer(svc, logger, opts, r, apiVersions...) + oe.POST("/api/fleet/orbit/config", getOrbitConfigEndpoint, orbitGetConfigRequest{}) + // unauthenticated endpoints - most of those are either login-related, // invite-related or host-enrolling. So they typically do some kind of // one-time authentication by verifying that a valid secret token is provided @@ -462,6 +466,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ne.WithAltPaths("/api/v1/osquery/enroll"). POST("/api/osquery/enroll", enrollAgentEndpoint, enrollAgentRequest{}) + ne.POST("/api/fleet/orbit/enroll", enrollOrbitEndpoint, enrollOrbitRequest{}) + // For some reason osquery does not provide a node key with the block data. // Instead the carve session ID should be verified in the service method. ne.WithAltPaths("/api/v1/osquery/carve/block"). diff --git a/server/service/orbit.go b/server/service/orbit.go new file mode 100644 index 0000000000..4524018b87 --- /dev/null +++ b/server/service/orbit.go @@ -0,0 +1,144 @@ +package service + +import ( + "context" + "encoding/json" + "github.com/fleetdm/fleet/v4/server" + "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + hostctx "github.com/fleetdm/fleet/v4/server/contexts/host" + "github.com/fleetdm/fleet/v4/server/contexts/logging" + "github.com/fleetdm/fleet/v4/server/fleet" +) + +type orbitError struct { + message string +} + +type enrollOrbitRequest struct { + EnrollSecret string `json:"enroll_secret"` + HardwareUUID string `json:"hardware_uuid"` +} + +type enrollOrbitResponse struct { + OrbitNodeKey string `json:"orbit_node_key,omitempty"` + Err error `json:"error,omitempty"` +} + +type orbitGetConfigRequest struct { + OrbitNodeKey string `json:"orbit_node_key"` +} + +func (r *orbitGetConfigRequest) orbitHostNodeKey() string { + return r.OrbitNodeKey +} + +type orbitGetConfigResponse struct { + Flags json.RawMessage `json:"command_line_startup_flags,omitempty"` + Err error `json:"error,omitempty"` +} + +func (e orbitError) Error() string { + return e.message +} + +func (r enrollOrbitResponse) error() error { return r.Err } + +func enrollOrbitEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) { + req := request.(*enrollOrbitRequest) + nodeKey, err := svc.EnrollOrbit(ctx, req.HardwareUUID, req.EnrollSecret) + if err != nil { + return enrollOrbitResponse{Err: err}, nil + } + return enrollOrbitResponse{OrbitNodeKey: nodeKey}, nil +} + +func (svc *Service) AuthenticateOrbitHost(ctx context.Context, orbitNodeKey string) (*fleet.Host, bool, error) { + svc.authz.SkipAuthorization(ctx) + + if orbitNodeKey == "" { + return nil, false, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("authentication error: missing orbit node key")) + } + + host, err := svc.ds.LoadHostByOrbitNodeKey(ctx, orbitNodeKey) + switch { + case err == nil: + // OK + case fleet.IsNotFound(err): + return nil, false, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("authentication error: invalid orbit node key")) + default: + return nil, false, ctxerr.Wrap(ctx, err, "authentication error orbit") + } + + return host, svc.debugEnabledForHost(ctx, host.ID), nil +} + +// EnrollOrbit returns an orbit nodeKey on successful enroll +func (svc *Service) EnrollOrbit(ctx context.Context, hardwareUUID string, enrollSecret string) (string, error) { + // this is not a user-authenticated endpoint + svc.authz.SkipAuthorization(ctx) + logging.WithExtras(ctx, "hardware_uuid", hardwareUUID) + + secret, err := svc.ds.VerifyEnrollSecret(ctx, enrollSecret) + if err != nil { + return "", orbitError{message: "orbit enroll failed: " + err.Error()} + } + + orbitNodeKey, err := server.GenerateRandomText(svc.config.Osquery.NodeKeySize) + if err != nil { + return "", orbitError{message: "failed to generate orbit node key: " + err.Error()} + } + + _, err = svc.ds.EnrollOrbit(ctx, hardwareUUID, orbitNodeKey, secret.TeamID) + if err != nil { + return "", orbitError{message: "failed to enroll " + err.Error()} + } + + return orbitNodeKey, nil +} + +func getOrbitConfigEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) { + opts, err := svc.GetOrbitFlags(ctx) + if err != nil { + return orbitGetConfigResponse{Err: err}, nil + } + return orbitGetConfigResponse{Flags: opts}, nil +} + +func (svc *Service) GetOrbitFlags(ctx context.Context) (json.RawMessage, error) { + // this is not a user-authenticated endpoint + svc.authz.SkipAuthorization(ctx) + + host, ok := hostctx.FromContext(ctx) + if !ok { + return nil, orbitError{message: "internal error: missing host from request context"} + } + + // team ID is not nil, get team specific flags and options + if host.TeamID != nil { + teamAgentOptions, err := svc.ds.TeamAgentOptions(ctx, *host.TeamID) + if err != nil { + return nil, err + } + + if teamAgentOptions != nil && len(*teamAgentOptions) > 0 { + var opts fleet.AgentOptions + if err := json.Unmarshal(*teamAgentOptions, &opts); err != nil { + return nil, err + } + return opts.CommandLineStartUpFlags, nil + } + } + + // team ID is nil, get global flags and options + config, err := svc.ds.AppConfig(ctx) + if err != nil { + return nil, err + } + var opts fleet.AgentOptions + if config.AgentOptions != nil { + if err := json.Unmarshal(*config.AgentOptions, &opts); err != nil { + return nil, err + } + } + return opts.CommandLineStartUpFlags, nil +} diff --git a/server/service/orbit_client.go b/server/service/orbit_client.go new file mode 100644 index 0000000000..d4054d8ccc --- /dev/null +++ b/server/service/orbit_client.go @@ -0,0 +1,77 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" +) + +type OrbitClient struct { + *baseClient + enrollSecret string + hardwareUUID string +} + +func (oc *OrbitClient) request(verb string, path string, params interface{}, resp interface{}) error { + var bodyBytes []byte + var err error + if params != nil { + bodyBytes, err = json.Marshal(params) + if err != nil { + return fmt.Errorf("making requst json marshalling : %w", err) + } + } + + request, err := http.NewRequest( + verb, + oc.url(path, "").String(), + bytes.NewBuffer(bodyBytes), + ) + if err != nil { + return err + } + response, err := oc.http.Do(request) + if err != nil { + return fmt.Errorf("%s %s: %w", verb, path, err) + } + defer response.Body.Close() + + return oc.parseResponse(verb, path, response, resp) +} + +func NewOrbitClient(addr string, rootCA string, insecureSkipVerify bool, enrollSecret, hardwareUUID string) (*OrbitClient, error) { + bc, err := newBaseClient(addr, insecureSkipVerify, rootCA, "") + if err != nil { + return nil, err + } + + return &OrbitClient{ + baseClient: bc, + enrollSecret: enrollSecret, + hardwareUUID: hardwareUUID, + }, nil +} + +func (oc *OrbitClient) DoEnroll() (string, error) { + verb, path := "POST", "/api/fleet/orbit/enroll" + params := enrollOrbitRequest{EnrollSecret: oc.enrollSecret, HardwareUUID: oc.hardwareUUID} + var resp enrollOrbitResponse + err := oc.request(verb, path, params, &resp) + if err != nil { + return "", err + } + return resp.OrbitNodeKey, nil +} + +func (oc *OrbitClient) GetConfig(orbitNodeKey string) (json.RawMessage, error) { + verb, path := "POST", "/api/fleet/orbit/config" + params := orbitGetConfigRequest{OrbitNodeKey: orbitNodeKey} + var resp orbitGetConfigResponse + err := oc.request(verb, path, params, &resp) + if err != nil { + return nil, err + } + + return resp.Flags, nil +}