Orbit remote management for flags (#7246)

Co-authored-by: Roberto Dip <dip.jesusr@gmail.com>
This commit is contained in:
Sharvil Shah 2022-09-24 00:30:23 +05:30 committed by GitHub
parent db7d1c5bf5
commit 7d4e2e2b4b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 843 additions and 11 deletions

View file

@ -0,0 +1 @@
* Added new endpoints for orbit to enroll and get startup flags

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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
}

View file

@ -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)
}

File diff suppressed because one or more lines are too long

View file

@ -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 {

View file

@ -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
///////////////////////////////////////////////////////////////////////////////

View file

@ -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.

View file

@ -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.
//

View file

@ -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 }

View file

@ -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)

View file

@ -114,6 +114,5 @@ func newBaseClient(addr string, insecureSkipVerify bool, rootCA, urlPrefix strin
insecureSkipVerify: insecureSkipVerify,
urlPrefix: urlPrefix,
}
return client, nil
}

View file

@ -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

View file

@ -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,

View file

@ -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").

144
server/service/orbit.go Normal file
View file

@ -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
}

View file

@ -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
}