"}}
+}
+
+func TestUAServiceReject(t *testing.T) {
+ store := &fauxStore{}
+ s := NewUAService(store, false)
+ _, err := s.UserAuthenticate(newMDMReq(), &mdm.UserAuthenticate{})
+ var httpErr *service.HTTPStatusError
+ if !errors.As(err, &httpErr) {
+ // should be returning a HTTPStatusError (to deny management)
+ t.Fatalf("no error or incorrect error type")
+ }
+ if httpErr.Status != 410 {
+ // if we've kept the "send-empty" false this needs to return a 410
+ // i.e. decline management of the user.
+ t.Error("status not 410")
+ }
+}
+
+func TestUAService(t *testing.T) {
+ store := &fauxStore{}
+ s := NewUAService(store, true)
+ ret, err := s.UserAuthenticate(newMDMReq(), &mdm.UserAuthenticate{})
+ if err != nil {
+ // should be no error
+ t.Fatal(err)
+ }
+ if !bytes.Equal(ret, emptyDigestChallengeBytes) {
+ t.Error("response bytes not equal")
+ }
+ // second request with DigestResponse
+ ret, err = s.UserAuthenticate(newMDMReq(), &mdm.UserAuthenticate{DigestResponse: "test"})
+ if err != nil {
+ // should be no error
+ t.Fatal(err)
+ }
+ if ret != nil {
+ t.Error("response bytes not empty")
+ }
+
+}
diff --git a/server/mdm/nanomdm/service/request.go b/server/mdm/nanomdm/service/request.go
index e80fb6c3ae..cc90033ada 100644
--- a/server/mdm/nanomdm/service/request.go
+++ b/server/mdm/nanomdm/service/request.go
@@ -93,6 +93,22 @@ func CheckinRequest(svc Checkin, r *mdm.Request, bodyBytes []byte) ([]byte, erro
if err != nil {
err = fmt.Errorf("declarativemanagement service: %w", err)
}
+ case *mdm.GetToken:
+ if err := m.Validate(); err != nil {
+ return nil, fmt.Errorf("gettoken validate: %w", err)
+ }
+ resp, err := svc.GetToken(r, m)
+ if err != nil {
+ return nil, fmt.Errorf("gettoken service: %w", err)
+ }
+ if resp == nil {
+ return nil, errors.New("gettoken service: no response")
+ }
+ respBytes, err = plist.Marshal(resp)
+ if err != nil {
+ return nil, fmt.Errorf("gettoken marshal: %w", err)
+ }
+ return respBytes, nil
default:
return nil, errors.New("unhandled check-in request type")
}
diff --git a/server/mdm/nanomdm/service/service.go b/server/mdm/nanomdm/service/service.go
index ebd1e47853..cb0f16ccc6 100644
--- a/server/mdm/nanomdm/service/service.go
+++ b/server/mdm/nanomdm/service/service.go
@@ -11,6 +11,17 @@ type DeclarativeManagement interface {
DeclarativeManagement(*mdm.Request, *mdm.DeclarativeManagement) ([]byte, error)
}
+// UserAuthenticate is an interface for processing the UserAuthenticate MDM check-in message.
+type UserAuthenticate interface {
+ UserAuthenticate(*mdm.Request, *mdm.UserAuthenticate) ([]byte, error)
+}
+
+// GetToken is the interface for handling a GetToken check-in message.
+// See https://developer.apple.com/documentation/devicemanagement/get_token
+type GetToken interface {
+ GetToken(*mdm.Request, *mdm.GetToken) (*mdm.GetTokenResponse, error)
+}
+
// Checkin represents the various check-in requests.
// See https://developer.apple.com/documentation/devicemanagement/check-in
type Checkin interface {
@@ -19,8 +30,9 @@ type Checkin interface {
CheckOut(*mdm.Request, *mdm.CheckOut) error
SetBootstrapToken(*mdm.Request, *mdm.SetBootstrapToken) error
GetBootstrapToken(*mdm.Request, *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error)
- UserAuthenticate(*mdm.Request, *mdm.UserAuthenticate) ([]byte, error)
+ UserAuthenticate
DeclarativeManagement
+ GetToken
}
// CommandAndReportResults represents the command report and next-command request.
diff --git a/server/mdm/nanomdm/storage/all.go b/server/mdm/nanomdm/storage/all.go
index bb6985191d..10b48c059c 100644
--- a/server/mdm/nanomdm/storage/all.go
+++ b/server/mdm/nanomdm/storage/all.go
@@ -7,6 +7,7 @@ type AllStorage interface {
PushCertStore
CommandEnqueuer
CertAuthStore
+ CertAuthRetriever
StoreMigrator
TokenUpdateTallyStore
}
diff --git a/server/mdm/nanomdm/storage/allmulti/allmulti.go b/server/mdm/nanomdm/storage/allmulti/allmulti.go
index 068c2d8d7a..8334fe3dcf 100644
--- a/server/mdm/nanomdm/storage/allmulti/allmulti.go
+++ b/server/mdm/nanomdm/storage/allmulti/allmulti.go
@@ -3,10 +3,11 @@ package allmulti
import (
"context"
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log"
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
+
+ "github.com/micromdm/nanolib/log"
+ "github.com/micromdm/nanolib/log/ctxlog"
)
// MultiAllStorage dispatches to multiple AllStorage instances.
diff --git a/server/mdm/nanomdm/storage/allmulti/certauth.go b/server/mdm/nanomdm/storage/allmulti/certauth.go
index 242f771f70..3fcec098b2 100644
--- a/server/mdm/nanomdm/storage/allmulti/certauth.go
+++ b/server/mdm/nanomdm/storage/allmulti/certauth.go
@@ -1,6 +1,7 @@
package allmulti
import (
+ "context"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
@@ -34,3 +35,10 @@ func (ms *MultiAllStorage) AssociateCertHash(r *mdm.Request, hash string, certNo
})
return err
}
+
+func (ms *MultiAllStorage) EnrollmentFromHash(ctx context.Context, hash string) (string, error) {
+ val, err := ms.execStores(ctx, func(s storage.AllStorage) (interface{}, error) {
+ return s.EnrollmentFromHash(ctx, hash)
+ })
+ return val.(string), err
+}
diff --git a/server/mdm/nanomdm/storage/allmulti/migrate.go b/server/mdm/nanomdm/storage/allmulti/migrate.go
index 3945fbadc1..469bf80547 100644
--- a/server/mdm/nanomdm/storage/allmulti/migrate.go
+++ b/server/mdm/nanomdm/storage/allmulti/migrate.go
@@ -3,7 +3,7 @@ package allmulti
import (
"context"
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog"
+ "github.com/micromdm/nanolib/log/ctxlog"
)
func (ms *MultiAllStorage) RetrieveMigrationCheckins(ctx context.Context, c chan<- interface{}) error {
diff --git a/server/mdm/nanomdm/storage/file/bstoken.go b/server/mdm/nanomdm/storage/file/bstoken.go
index 0e1d55c9ea..2c9a938df4 100644
--- a/server/mdm/nanomdm/storage/file/bstoken.go
+++ b/server/mdm/nanomdm/storage/file/bstoken.go
@@ -18,10 +18,15 @@ func (s *FileStorage) StoreBootstrapToken(r *mdm.Request, msg *mdm.SetBootstrapT
return nil
}
+// RetrieveBootstrapToken reads the BootstrapToken from disk and returns it.
+// If no token yet exists a nil token and no error are returned.
func (s *FileStorage) RetrieveBootstrapToken(r *mdm.Request, _ *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error) {
e := s.newEnrollment(r.ID)
bsTokenRaw, err := e.readFile(BootstrapTokenFile)
- if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ // mute the error if we haven't escrowed a token yet.
+ return nil, nil
+ } else if err != nil {
return nil, err
}
bsToken := &mdm.BootstrapToken{
diff --git a/server/mdm/nanomdm/storage/file/certauth.go b/server/mdm/nanomdm/storage/file/certauth.go
index 2957d6529c..b3e33a0f8d 100644
--- a/server/mdm/nanomdm/storage/file/certauth.go
+++ b/server/mdm/nanomdm/storage/file/certauth.go
@@ -2,6 +2,7 @@ package file
import (
"bufio"
+ "context"
"errors"
"os"
"path"
@@ -69,3 +70,23 @@ func (s *FileStorage) AssociateCertHash(r *mdm.Request, hash string, _ time.Time
e := s.newEnrollment(r.ID)
return e.writeFile(CertAuthFilename, []byte(hash))
}
+
+func (s *FileStorage) EnrollmentFromHash(_ context.Context, hash string) (string, error) {
+ f, err := os.Open(path.Join(s.path, CertAuthAssociationsFilename))
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ scanner := bufio.NewScanner(f)
+ for scanner.Scan() {
+ text := scanner.Text()
+ if strings.Contains(text, hash) {
+ split := strings.Split(text, ",")
+ if len(split) < 2 {
+ return "", errors.New("hash and enrollment id not present on line")
+ }
+ return split[0], nil
+ }
+ }
+ return "", nil
+}
diff --git a/server/mdm/nanomdm/storage/file/file.go b/server/mdm/nanomdm/storage/file/file.go
index 52535ee00b..2790d4140f 100644
--- a/server/mdm/nanomdm/storage/file/file.go
+++ b/server/mdm/nanomdm/storage/file/file.go
@@ -166,6 +166,14 @@ func (s *FileStorage) StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) e
return err
}
}
+ if err := e.resetNumericFile(TokenUpdateTallyFilename); err != nil {
+ return err
+ }
+ // remove the BootstrapToken when we receive an Authenticate message
+ // BS tokens are only valid when a new one is escrowed after enrollment.
+ if err := os.Remove(e.dirPrefix(BootstrapTokenFile)); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
return e.writeFile(AuthenticateFilename, msg.Raw)
}
@@ -229,9 +237,6 @@ func (s *FileStorage) Disable(r *mdm.Request) error {
if err := e.writeFile(DisabledFilename, nil); err != nil {
return err
}
- if err := e.resetNumericFile(TokenUpdateTallyFilename); err != nil {
- return err
- }
}
return e.removeSubEnrollments()
}
diff --git a/server/mdm/nanomdm/storage/file/file_test.go b/server/mdm/nanomdm/storage/file/file_test.go
new file mode 100644
index 0000000000..245886e208
--- /dev/null
+++ b/server/mdm/nanomdm/storage/file/file_test.go
@@ -0,0 +1,17 @@
+package file
+
+import (
+ "context"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/test/e2e"
+)
+
+func TestFileStorage(t *testing.T) {
+ s, err := New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("e2e", func(t *testing.T) { e2e.TestE2E(t, context.Background(), s) })
+}
diff --git a/server/mdm/nanomdm/storage/file/push.go b/server/mdm/nanomdm/storage/file/push.go
index f599d1d6c2..5f8d520184 100644
--- a/server/mdm/nanomdm/storage/file/push.go
+++ b/server/mdm/nanomdm/storage/file/push.go
@@ -3,6 +3,7 @@ package file
import (
"context"
"errors"
+ "os"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
)
@@ -13,7 +14,11 @@ func (s *FileStorage) RetrievePushInfo(_ context.Context, ids []string) (map[str
for _, id := range ids {
e := s.newEnrollment(id)
tokenUpdate, err := e.readFile(TokenUpdateFilename)
- if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ // TokenUpdate file missing could be a non-existent or
+ // incomplete enrollment which should not trigger an error.
+ continue
+ } else if err != nil {
return nil, err
}
msg, err := mdm.DecodeCheckin(tokenUpdate)
diff --git a/server/mdm/nanomdm/storage/file/queue_test.go b/server/mdm/nanomdm/storage/file/queue_test.go
deleted file mode 100644
index 55d314a164..0000000000
--- a/server/mdm/nanomdm/storage/file/queue_test.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package file
-
-import (
- "os"
- "testing"
-
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/internal/test"
-)
-
-func TestQueue(t *testing.T) {
- storage, err := New("test-db")
- if err != nil {
- t.Fatal(err)
- }
- test.TestQueue(t, "EA4E19F1-7F8B-493D-BEAB-264B33BCF4E6", storage)
- os.RemoveAll("test-db")
-}
diff --git a/server/mdm/nanomdm/storage/internal/test/queue.go b/server/mdm/nanomdm/storage/internal/test/queue.go
deleted file mode 100644
index 55e358fe4b..0000000000
--- a/server/mdm/nanomdm/storage/internal/test/queue.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package test
-
-import (
- "context"
- "testing"
-
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
- "github.com/groob/plist"
-)
-
-// QueueInterfaces are the storage interfaces needed for testing queue operations.
-type QueueInterfaces interface {
- storage.CommandEnqueuer
- storage.CommandAndReportResultsStore
-}
-
-// newCommand assembles a fake command including the plist raw value
-func newCommand(cmd string) (*mdm.Command, error) {
- // assemble a fake struct just for marshalling to plist
- fCmd := &struct {
- CommandUUID string
- Command struct {
- RequestType string
- }
- }{
- CommandUUID: cmd,
- Command: struct{ RequestType string }{cmd},
- }
- // marshal it to plist
- rawBytes, err := plist.Marshal(fCmd)
- if err != nil {
- return nil, err
- }
- // return a real *mdm.Command which includes the marshalled JSON
- return &mdm.Command{
- CommandUUID: fCmd.CommandUUID,
- Command: fCmd.Command,
- Raw: rawBytes,
- }, nil
-}
-
-// enqueue queues a new command
-func enqueue(t *testing.T, q QueueInterfaces, ctx context.Context, id, cmdStr string) {
- cmd, err := newCommand(cmdStr)
- if err != nil {
- t.Fatal(err)
- }
- res, err := q.EnqueueCommand(ctx, []string{id}, cmd)
- if err != nil {
- t.Fatal(err)
- }
- for k, v := range res {
- t.Fatalf("enqueuing to ID %s: %v", k, v)
- }
-}
-
-// compareCommand compares makes sure cmd looks similar to newCommand(cmdStr)
-func compareCommand(t *testing.T, cmdStr string, cmd *mdm.Command) {
- if cmdStr != "" && cmd == nil {
- t.Errorf("expected next command, but got empty response. wanted: %q", cmdStr)
- return
- }
- if cmdStr == "" && cmd != nil {
- t.Errorf("expected empty next command, but got: %q", cmd.CommandUUID)
- }
- if cmd == nil {
- return
- }
- if cmd.CommandUUID != cmdStr {
- t.Errorf("mismatched command UUID. want: %q, have: %q", cmdStr, cmd.CommandUUID)
- }
- if cmd.Command.RequestType != cmdStr {
- t.Errorf("mismatched command RequestType. want: %q, have: %q", cmdStr, cmd.Command.RequestType)
- }
-}
-
-// retrieve retrieves the next command from the backend
-func retrieve(t *testing.T, q QueueInterfaces, r *mdm.Request, cmdStr string, skipNotNow bool) {
- retCmd, err := q.RetrieveNextCommand(r, skipNotNow)
- if err != nil {
- t.Fatal(err)
- }
- compareCommand(t, cmdStr, retCmd)
-}
-
-// report fakes a command result and reports it to the backend
-func report(t *testing.T, q QueueInterfaces, r *mdm.Request, cmdStr, status string) {
- fReport := &struct {
- CommandUUID string `plist:",omitempty"`
- Status string
- RequestType string `plist:",omitempty"`
- }{CommandUUID: cmdStr, Status: status, RequestType: cmdStr}
- rawBytes, err := plist.Marshal(fReport)
- if err != nil {
- t.Fatal(err)
- }
- results := &mdm.CommandResults{
- CommandUUID: fReport.CommandUUID,
- Status: fReport.Status,
- RequestType: fReport.RequestType,
- Raw: rawBytes,
- }
- err = q.StoreCommandReport(r, results)
- if err != nil {
- t.Error(err)
- }
-}
-
-// reportRetrieve behaves similarly to an MDM client: it first reports
-// the results and then retrieves the next command.
-func reportRetrieve(t *testing.T, q QueueInterfaces, r *mdm.Request, reportCmd, reportStatus, expectedCmd string) {
- report(t, q, r, reportCmd, reportStatus)
- skipNotNow := false
- if reportStatus == "NotNow" {
- skipNotNow = true
- }
- retrieve(t, q, r, expectedCmd, skipNotNow)
-}
-
-// TestQueue performs basic testing of the storage queue
-func TestQueue(t *testing.T, id string, q QueueInterfaces) {
- ctx := context.Background()
-
- // build a fake MDM request object
- r := &mdm.Request{
- EnrollID: &mdm.EnrollID{
- Type: mdm.Device,
- ID: id,
- ParentID: "",
- },
- Context: ctx,
- }
-
- t.Run("basic", func(t *testing.T) {
- reportRetrieve(t, q, r, "", "Idle", "")
- enqueue(t, q, ctx, id, "CMD1")
- enqueue(t, q, ctx, id, "CMD2")
- reportRetrieve(t, q, r, "", "Idle", "CMD1")
- reportRetrieve(t, q, r, "CMD1", "Acknowledged", "CMD2")
- reportRetrieve(t, q, r, "CMD2", "Acknowledged", "")
- reportRetrieve(t, q, r, "", "Idle", "")
- })
-
- t.Run("notnow", func(t *testing.T) {
- reportRetrieve(t, q, r, "", "Idle", "")
- enqueue(t, q, ctx, id, "CMD3")
- reportRetrieve(t, q, r, "", "Idle", "CMD3")
- reportRetrieve(t, q, r, "CMD3", "NotNow", "")
- reportRetrieve(t, q, r, "", "Idle", "CMD3")
- reportRetrieve(t, q, r, "CMD3", "Acknowledged", "")
- reportRetrieve(t, q, r, "", "Idle", "")
- })
-}
diff --git a/server/mdm/nanomdm/storage/mysql/async.go b/server/mdm/nanomdm/storage/mysql/async.go
new file mode 100644
index 0000000000..aa6f4f9629
--- /dev/null
+++ b/server/mdm/nanomdm/storage/mysql/async.go
@@ -0,0 +1,101 @@
+package mysql
+
+import (
+ "cmp"
+ "context"
+ "slices"
+ "sync"
+ "time"
+)
+
+type asyncLastSeen struct {
+ flushInterval time.Duration
+ flushCap int
+ set *seenSet[string]
+ fn func(ctx context.Context, ids []string)
+}
+
+func newAsyncLastSeen(flushInterval time.Duration, flushCap int, fn func(ctx context.Context, ids []string)) *asyncLastSeen {
+ return &asyncLastSeen{
+ flushInterval: flushInterval,
+ flushCap: flushCap,
+ set: &seenSet[string]{},
+ fn: fn,
+ }
+}
+
+func (a *asyncLastSeen) markHostSeen(ctx context.Context, id string) {
+ ids, flush := a.set.add(id, a.flushCap)
+ if flush && len(ids) > 0 {
+ a.fn(ctx, ids)
+ }
+}
+
+func (a *asyncLastSeen) runFlushLoop(ctx context.Context) {
+ tickCh := time.Tick(a.flushInterval)
+ for {
+ select {
+ case <-tickCh:
+ ids := a.set.getAndClear()
+ if len(ids) > 0 {
+ a.fn(ctx, ids)
+ }
+
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
+// TODO: this could replace the seenHostSet in server/service/async package,
+// but I did not want to introduce a dependency between nanomdm and our
+// internal code at this point.
+
+// seenSet implements synchronized storage for the set of seen identifiers.
+type seenSet[T cmp.Ordered] struct {
+ mutex sync.Mutex
+ seenIDs map[T]struct{}
+}
+
+// add adds the identifier to the set and returns the list of unique IDs -
+// clearing the set - if the cap is reached, returning true as the second
+// value. Otherwise it returns nil and false. Essentially, if the number of
+// unique IDs >= cap, it acts as if getAndClear was called after adding the new
+// id. A cap <= 0 is ignored.
+func (s *seenSet[T]) add(id T, cap int) ([]T, bool) {
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ if s.seenIDs == nil {
+ s.seenIDs = make(map[T]struct{})
+ }
+ s.seenIDs[id] = struct{}{}
+
+ if cap > 0 && len(s.seenIDs) >= cap {
+ return s.getAndClearLocked(), true
+ }
+ return nil, false
+}
+
+// getAndClear gets the list of unique IDs from the set and empties it.
+func (s *seenSet[T]) getAndClear() []T {
+ s.mutex.Lock()
+ defer s.mutex.Unlock()
+
+ return s.getAndClearLocked()
+}
+
+// getAndClearLocked is identical to getAndClear but must only be called when
+// the s.mutex lock is held.
+func (s *seenSet[T]) getAndClearLocked() []T {
+ var ids []T
+ for id := range s.seenIDs {
+ ids = append(ids, id)
+ }
+ // clear the set
+ s.seenIDs = make(map[T]struct{})
+
+ // sort to help prevent deadlocks when processing the batch SQL statement
+ slices.Sort(ids)
+ return ids
+}
diff --git a/server/mdm/nanomdm/storage/mysql/async_test.go b/server/mdm/nanomdm/storage/mysql/async_test.go
new file mode 100644
index 0000000000..1c1d5859c9
--- /dev/null
+++ b/server/mdm/nanomdm/storage/mysql/async_test.go
@@ -0,0 +1,147 @@
+package mysql
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestAsyncLastSeen(t *testing.T) {
+ t.Parallel()
+
+ runLoopAndWait := func(t *testing.T, als *asyncLastSeen) (ctx context.Context, stop func()) {
+ ctx, cancel := context.WithCancel(context.Background())
+
+ done := make(chan struct{})
+ go func() {
+ als.runFlushLoop(ctx)
+ // runFlushLoop should return once the context is closed
+ close(done)
+ }()
+
+ return ctx, func() {
+ cancel()
+ select {
+ case <-done:
+ // ok
+ case <-time.After(100 * time.Millisecond):
+ t.Fatal("runFlushLoop did not return")
+ }
+ }
+ }
+
+ t.Run("always empty", func(t *testing.T) {
+ t.Parallel()
+
+ als := newAsyncLastSeen(time.Millisecond, 1, func(ctx context.Context, ids []string) {
+ t.Fatal("unexpected call to fn")
+ })
+ _, stop := runLoopAndWait(t, als)
+
+ time.Sleep(100 * time.Millisecond)
+ stop()
+ })
+
+ t.Run("timed flush", func(t *testing.T) {
+ t.Parallel()
+
+ var mu sync.Mutex
+ var gotIDs []string
+ als := newAsyncLastSeen(10*time.Millisecond, 10, func(ctx context.Context, ids []string) {
+ mu.Lock()
+ defer mu.Unlock()
+
+ // always add a "|" between calls
+ if len(gotIDs) > 0 {
+ gotIDs = append(gotIDs, "|")
+ }
+ gotIDs = append(gotIDs, ids...)
+ })
+ ctx, stop := runLoopAndWait(t, als)
+
+ als.markHostSeen(ctx, "1")
+ als.markHostSeen(ctx, "2")
+ time.Sleep(100 * time.Millisecond) // oversleep to avoid slow timers issues on CI
+ als.markHostSeen(ctx, "3")
+ time.Sleep(100 * time.Millisecond) // oversleep to avoid slow timers issues on CI
+ als.markHostSeen(ctx, "4")
+ als.markHostSeen(ctx, "5")
+ als.markHostSeen(ctx, "6")
+ time.Sleep(100 * time.Millisecond) // oversleep to avoid slow timers issues on CI
+
+ stop()
+
+ mu.Lock()
+ defer mu.Unlock()
+ require.Equal(t, "12|3|456", strings.Join(gotIDs, ""))
+ })
+
+ t.Run("cap flush", func(t *testing.T) {
+ t.Parallel()
+
+ var mu sync.Mutex
+ var gotIDs []string
+ als := newAsyncLastSeen(100*time.Millisecond, 2, func(ctx context.Context, ids []string) {
+ mu.Lock()
+ defer mu.Unlock()
+
+ // always add a "|" between calls
+ if len(gotIDs) > 0 {
+ gotIDs = append(gotIDs, "|")
+ }
+ gotIDs = append(gotIDs, ids...)
+ })
+ ctx, stop := runLoopAndWait(t, als)
+
+ als.markHostSeen(ctx, "1")
+ als.markHostSeen(ctx, "2")
+ als.markHostSeen(ctx, "3")
+ als.markHostSeen(ctx, "4")
+ als.markHostSeen(ctx, "5")
+ als.markHostSeen(ctx, "6")
+
+ stop()
+
+ mu.Lock()
+ defer mu.Unlock()
+ require.Equal(t, "12|34|56", strings.Join(gotIDs, ""))
+ })
+
+ t.Run("cap and timed flush", func(t *testing.T) {
+ t.Parallel()
+
+ var mu sync.Mutex
+ var gotIDs []string
+ als := newAsyncLastSeen(10*time.Millisecond, 3, func(ctx context.Context, ids []string) {
+ mu.Lock()
+ defer mu.Unlock()
+
+ // always add a "|" between calls
+ if len(gotIDs) > 0 {
+ gotIDs = append(gotIDs, "|")
+ }
+ gotIDs = append(gotIDs, ids...)
+ })
+ ctx, stop := runLoopAndWait(t, als)
+
+ als.markHostSeen(ctx, "1")
+ als.markHostSeen(ctx, "2")
+ als.markHostSeen(ctx, "3")
+ als.markHostSeen(ctx, "4")
+ time.Sleep(100 * time.Millisecond) // oversleep to avoid slow timers issues on CI
+ als.markHostSeen(ctx, "5")
+ time.Sleep(100 * time.Millisecond) // oversleep to avoid slow timers issues on CI
+ als.markHostSeen(ctx, "6")
+ time.Sleep(100 * time.Millisecond) // oversleep to avoid slow timers issues on CI
+
+ stop()
+
+ mu.Lock()
+ defer mu.Unlock()
+ require.Equal(t, "123|4|5|6", strings.Join(gotIDs, ""))
+ })
+}
diff --git a/server/mdm/nanomdm/storage/mysql/bstoken_test.go b/server/mdm/nanomdm/storage/mysql/bstoken_test.go
deleted file mode 100644
index 395221d033..0000000000
--- a/server/mdm/nanomdm/storage/mysql/bstoken_test.go
+++ /dev/null
@@ -1,60 +0,0 @@
-//go:build integration
-// +build integration
-
-package mysql
-
-import (
- "bytes"
- "context"
- "encoding/base64"
- "testing"
-
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
-)
-
-func TestBSToken(t *testing.T) {
- if *flDSN == "" {
- t.Fatal("MySQL DSN flag not provided to test")
- }
-
- storage, err := New(WithDSN(*flDSN), WithDeleteCommands())
- if err != nil {
- t.Fatal(err)
- }
-
- var d Device
- d, err = enrollTestDevice(storage)
- if err != nil {
- t.Fatal(err)
- }
-
- ctx := context.Background()
-
- t.Run("BSToken nil", func(t *testing.T) {
- tok, err := storage.RetrieveBootstrapToken(&mdm.Request{Context: ctx, EnrollID: d.EnrollID()}, nil)
- if err != nil {
- t.Fatal(err)
- }
- if tok != nil {
- t.Fatal("Token for new device was nonnull")
- }
- })
- t.Run("BSToken set/get", func(t *testing.T) {
- data := []byte("test token")
- bsToken := mdm.BootstrapToken{BootstrapToken: make([]byte, base64.StdEncoding.EncodedLen(len(data)))}
- base64.StdEncoding.Encode(bsToken.BootstrapToken, data)
- testReq := &mdm.Request{Context: ctx, EnrollID: d.EnrollID()}
- err := storage.StoreBootstrapToken(testReq, &mdm.SetBootstrapToken{BootstrapToken: bsToken})
- if err != nil {
- t.Fatal(err)
- }
-
- tok, err := storage.RetrieveBootstrapToken(testReq, nil)
- if err != nil {
- t.Fatal(err)
- }
- if !bytes.Equal(bsToken.BootstrapToken, tok.BootstrapToken) {
- t.Fatalf("Bootstap tokens disequal after roundtrip: %v!=%v", bsToken, tok)
- }
- })
-}
diff --git a/server/mdm/nanomdm/storage/mysql/certauth.go b/server/mdm/nanomdm/storage/mysql/certauth.go
index 7e852df061..c64cb1bb69 100644
--- a/server/mdm/nanomdm/storage/mysql/certauth.go
+++ b/server/mdm/nanomdm/storage/mysql/certauth.go
@@ -2,6 +2,8 @@ package mysql
import (
"context"
+ "database/sql"
+ "errors"
"strings"
"time"
@@ -53,3 +55,16 @@ UPDATE
)
return err
}
+
+func (s *MySQLStorage) EnrollmentFromHash(ctx context.Context, hash string) (string, error) {
+ var id string
+ err := s.db.QueryRowContext(
+ ctx,
+ `SELECT id FROM cert_auth_associations WHERE sha256 = ? LIMIT 1;`,
+ hash,
+ ).Scan(&id)
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", nil
+ }
+ return id, err
+}
diff --git a/server/mdm/nanomdm/storage/mysql/common_test.go b/server/mdm/nanomdm/storage/mysql/common_test.go
deleted file mode 100644
index 84e9a898b5..0000000000
--- a/server/mdm/nanomdm/storage/mysql/common_test.go
+++ /dev/null
@@ -1,8 +0,0 @@
-//go:build integration
-// +build integration
-
-package mysql
-
-import "flag"
-
-var flDSN = flag.String("dsn", "", "DSN of test MySQL instance")
diff --git a/server/mdm/nanomdm/storage/mysql/device_test.go b/server/mdm/nanomdm/storage/mysql/device_test.go
deleted file mode 100644
index 905e069c6d..0000000000
--- a/server/mdm/nanomdm/storage/mysql/device_test.go
+++ /dev/null
@@ -1,89 +0,0 @@
-//go:build integration
-// +build integration
-
-package mysql
-
-import (
- "context"
- "errors"
- "io/ioutil"
-
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
-)
-
-type DeviceInterfaces interface {
- storage.CheckinStore
-}
-
-type Device struct {
- UDID string
-}
-
-func (d *Device) EnrollID() *mdm.EnrollID {
- return &mdm.EnrollID{Type: mdm.Device, ID: d.UDID}
-}
-
-func loadAuthMsg() (*mdm.Authenticate, Device, error) {
- var d Device
- b, err := ioutil.ReadFile("../../mdm/testdata/Authenticate.2.plist")
- if err != nil {
- return nil, d, err
- }
- r, err := mdm.DecodeCheckin(b)
- if err != nil {
- return nil, d, err
- }
- a, ok := r.(*mdm.Authenticate)
- if !ok {
- return nil, d, errors.New("not an Authenticate message")
- }
- d = Device{UDID: a.UDID}
- return a, d, nil
-}
-
-func loadTokenMsg() (*mdm.TokenUpdate, error) {
- b, err := ioutil.ReadFile("../../mdm/testdata/TokenUpdate.2.plist")
- if err != nil {
- return nil, err
- }
- r, err := mdm.DecodeCheckin(b)
- if err != nil {
- return nil, err
- }
- a, ok := r.(*mdm.TokenUpdate)
- if !ok {
- return nil, errors.New("not a TokenUpdate message")
- }
- return a, nil
-}
-
-func (d *Device) newMdmReq() *mdm.Request {
- return &mdm.Request{
- Context: context.Background(),
- EnrollID: &mdm.EnrollID{
- Type: mdm.Device,
- ID: d.UDID,
- },
- }
-}
-
-func enrollTestDevice(storage DeviceInterfaces) (Device, error) {
- authMsg, d, err := loadAuthMsg()
- if err != nil {
- return d, err
- }
- err = storage.StoreAuthenticate(d.newMdmReq(), authMsg)
- if err != nil {
- return d, err
- }
- tokenMsg, err := loadTokenMsg()
- if err != nil {
- return d, err
- }
- err = storage.StoreTokenUpdate(d.newMdmReq(), tokenMsg)
- if err != nil {
- return d, err
- }
- return d, nil
-}
diff --git a/server/mdm/nanomdm/storage/mysql/mysql.go b/server/mdm/nanomdm/storage/mysql/mysql.go
index 6ce4a78ca1..a6ec352507 100644
--- a/server/mdm/nanomdm/storage/mysql/mysql.go
+++ b/server/mdm/nanomdm/storage/mysql/mysql.go
@@ -7,11 +7,14 @@ import (
_ "embed"
"errors"
"fmt"
+ "os"
+ "time"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log"
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
+ "github.com/jmoiron/sqlx"
+ "github.com/micromdm/nanolib/log"
+ "github.com/micromdm/nanolib/log/ctxlog"
)
// Schema holds the schema for the NanoMDM MySQL storage.
@@ -22,17 +25,20 @@ var Schema string
var ErrNoCert = errors.New("no certificate in MDM Request")
type MySQLStorage struct {
- logger log.Logger
- db *sql.DB
- rm bool
+ logger log.Logger
+ db *sql.DB
+ rm bool
+ asyncLastSeen *asyncLastSeen
}
type config struct {
- driver string
- dsn string
- db *sql.DB
- logger log.Logger
- rm bool
+ driver string
+ dsn string
+ db *sql.DB
+ logger log.Logger
+ rm bool
+ asyncCap int
+ asyncInterval time.Duration
}
type Option func(*config)
@@ -67,8 +73,20 @@ func WithDeleteCommands() Option {
}
}
+func WithAsyncLastSeen(cap int, interval time.Duration) Option {
+ return func(c *config) {
+ c.asyncCap = cap
+ c.asyncInterval = interval
+ }
+}
+
func New(opts ...Option) (*MySQLStorage, error) {
- cfg := &config{logger: log.NopLogger, driver: "mysql"}
+ const (
+ asyncLastSeenFlushInterval = 2 * time.Second
+ asyncLastSeenCap = 1000
+ )
+
+ cfg := &config{logger: log.NopLogger, driver: "mysql", asyncCap: asyncLastSeenCap, asyncInterval: asyncLastSeenFlushInterval}
for _, opt := range opts {
opt(cfg)
}
@@ -82,7 +100,17 @@ func New(opts ...Option) (*MySQLStorage, error) {
if err = cfg.db.Ping(); err != nil {
return nil, err
}
- return &MySQLStorage{db: cfg.db, logger: cfg.logger, rm: cfg.rm}, nil
+
+ mysqlStore := &MySQLStorage{db: cfg.db, logger: cfg.logger, rm: cfg.rm}
+
+ if v := os.Getenv("FLEET_DISABLE_ASYNC_NANO_LAST_SEEN"); v != "1" {
+ asyncLastSeen := newAsyncLastSeen(cfg.asyncInterval, cfg.asyncCap, mysqlStore.updateLastSeenBatch)
+ mysqlStore.asyncLastSeen = asyncLastSeen
+
+ go asyncLastSeen.runFlushLoop(context.Background())
+ }
+
+ return mysqlStore, nil
}
// nullEmptyString returns a NULL string if s is empty.
@@ -108,6 +136,8 @@ ON DUPLICATE KEY
UPDATE
identity_cert = VALUES(identity_cert),
serial_number = VALUES(serial_number),
+ bootstrap_token_b64 = NULL,
+ bootstrap_token_at = NULL,
authenticate = VALUES(authenticate),
authenticate_at = CURRENT_TIMESTAMP;`,
r.ID, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw,
@@ -264,6 +294,11 @@ func (s *MySQLStorage) Disable(r *mdm.Request) error {
}
func (s *MySQLStorage) updateLastSeen(r *mdm.Request) (err error) {
+ if s.asyncLastSeen != nil {
+ s.asyncLastSeen.markHostSeen(r.Context, r.ID)
+ return nil
+ }
+
_, err = s.db.ExecContext(
r.Context,
`UPDATE nano_enrollments SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`,
@@ -274,3 +309,20 @@ func (s *MySQLStorage) updateLastSeen(r *mdm.Request) (err error) {
}
return
}
+
+func (s *MySQLStorage) updateLastSeenBatch(ctx context.Context, ids []string) {
+ if len(ids) == 0 {
+ return
+ }
+
+ stmt, args, err := sqlx.In(`UPDATE nano_enrollments SET last_seen_at = CURRENT_TIMESTAMP WHERE id IN (?)`, ids)
+ if err != nil {
+ s.logger.Info("msg", "error building nano_enrollments.last_seen_at sql", "err", err)
+ return
+ }
+
+ _, err = s.db.ExecContext(ctx, stmt, args...)
+ if err != nil {
+ s.logger.Info("msg", "error batch updating nano_enrollments.last_seen_at", "err", err)
+ }
+}
diff --git a/server/mdm/nanomdm/storage/mysql/mysql_test.go b/server/mdm/nanomdm/storage/mysql/mysql_test.go
new file mode 100644
index 0000000000..db9e074cd4
--- /dev/null
+++ b/server/mdm/nanomdm/storage/mysql/mysql_test.go
@@ -0,0 +1,31 @@
+package mysql
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/test/e2e"
+ _ "github.com/go-sql-driver/mysql"
+)
+
+func TestMySQL(t *testing.T) {
+ testDSN := os.Getenv("NANOMDM_MYSQL_STORAGE_TEST_DSN")
+ if testDSN == "" {
+ t.Skip("NANOMDM_MYSQL_STORAGE_TEST_DSN not set")
+ }
+
+ s, err := New(WithDSN(testDSN), WithDeleteCommands())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("e2e-WithDeleteCommands()", func(t *testing.T) { e2e.TestE2E(t, context.Background(), s) })
+
+ s, err = New(WithDSN(testDSN))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("e2e", func(t *testing.T) { e2e.TestE2E(t, context.Background(), s) })
+}
diff --git a/server/mdm/nanomdm/storage/mysql/queue.go b/server/mdm/nanomdm/storage/mysql/queue.go
index a67262e4f8..28a2c058b4 100644
--- a/server/mdm/nanomdm/storage/mysql/queue.go
+++ b/server/mdm/nanomdm/storage/mysql/queue.go
@@ -52,7 +52,7 @@ func (m *MySQLStorage) deleteCommand(ctx context.Context, tx *sql.Tx, id, uuid s
// trying to each delete it do not race
_, err := tx.ExecContext(
ctx, `
-SELECT command_uuid FROM commands WHERE command_uuid = ? FOR UPDATE;
+SELECT command_uuid FROM nano_commands WHERE command_uuid = ? FOR UPDATE;
`,
uuid,
)
diff --git a/server/mdm/nanomdm/storage/mysql/queue_test.go b/server/mdm/nanomdm/storage/mysql/queue_test.go
deleted file mode 100644
index 1b29de5b2d..0000000000
--- a/server/mdm/nanomdm/storage/mysql/queue_test.go
+++ /dev/null
@@ -1,41 +0,0 @@
-//go:build integration
-// +build integration
-
-package mysql
-
-import (
- "testing"
-
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/internal/test"
-
- _ "github.com/go-sql-driver/mysql"
-)
-
-func TestQueue(t *testing.T) {
- if *flDSN == "" {
- t.Fatal("MySQL DSN flag not provided to test")
- }
-
- storage, err := New(WithDSN(*flDSN), WithDeleteCommands())
- if err != nil {
- t.Fatal(err)
- }
-
- d, err := enrollTestDevice(storage)
- if err != nil {
- t.Fatal(err)
- }
-
- t.Run("WithDeleteCommands()", func(t *testing.T) {
- test.TestQueue(t, d.UDID, storage)
- })
-
- storage, err = New(WithDSN(*flDSN))
- if err != nil {
- t.Fatal(err)
- }
-
- t.Run("normal", func(t *testing.T) {
- test.TestQueue(t, d.UDID, storage)
- })
-}
diff --git a/server/mdm/nanomdm/storage/mysql/schema.sql b/server/mdm/nanomdm/storage/mysql/schema.sql
index 617a570d57..d1f420391d 100644
--- a/server/mdm/nanomdm/storage/mysql/schema.sql
+++ b/server/mdm/nanomdm/storage/mysql/schema.sql
@@ -274,6 +274,7 @@ CREATE TABLE nano_cert_auth_associations (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ cert_not_valid_after timestamp NULL DEFAULT NULL,
PRIMARY KEY (id, sha256),
diff --git a/server/mdm/nanomdm/storage/pgsql/bstoken.go b/server/mdm/nanomdm/storage/pgsql/bstoken.go
deleted file mode 100644
index 167723703d..0000000000
--- a/server/mdm/nanomdm/storage/pgsql/bstoken.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package pgsql
-
-import (
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
-)
-
-func (s *PgSQLStorage) StoreBootstrapToken(r *mdm.Request, msg *mdm.SetBootstrapToken) error {
- _, err := s.db.ExecContext(
- r.Context,
- `UPDATE devices SET bootstrap_token_b64 = $1, bootstrap_token_at = CURRENT_TIMESTAMP WHERE id = $2;`,
- nullEmptyString(msg.BootstrapToken.BootstrapToken.String()),
- r.ID,
- )
- if err != nil {
- return err
- }
- return s.updateLastSeen(r)
-}
-
-func (s *PgSQLStorage) RetrieveBootstrapToken(r *mdm.Request, _ *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error) {
- var tokenB64 string
- err := s.db.QueryRowContext(
- r.Context,
- `SELECT bootstrap_token_b64 FROM devices WHERE id = $1;`,
- r.ID,
- ).Scan(&tokenB64)
- if err != nil {
- return nil, err
- }
- bsToken := new(mdm.BootstrapToken)
- err = bsToken.SetTokenString(tokenB64)
- if err == nil {
- err = s.updateLastSeen(r)
- }
- return bsToken, err
-}
diff --git a/server/mdm/nanomdm/storage/pgsql/certauth.go b/server/mdm/nanomdm/storage/pgsql/certauth.go
deleted file mode 100644
index e474319b85..0000000000
--- a/server/mdm/nanomdm/storage/pgsql/certauth.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package pgsql
-
-import (
- "context"
- "strings"
- "time"
-
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
-)
-
-// Executes SQL statements that return a single COUNT(*) of rows.
-func (s *PgSQLStorage) queryRowContextRowExists(ctx context.Context, query string, args ...interface{}) (bool, error) {
- var ct int
- err := s.db.QueryRowContext(ctx, query, args...).Scan(&ct)
- return ct > 0, err
-}
-
-func (s *PgSQLStorage) EnrollmentHasCertHash(r *mdm.Request, _ string) (bool, error) {
- return s.queryRowContextRowExists(
- r.Context,
- `SELECT COUNT(*) FROM cert_auth_associations WHERE id = $1;`,
- r.ID,
- )
-}
-
-func (s *PgSQLStorage) HasCertHash(r *mdm.Request, hash string) (bool, error) {
- return s.queryRowContextRowExists(
- r.Context,
- `SELECT COUNT(*) FROM cert_auth_associations WHERE sha256 = $1;`,
- strings.ToLower(hash),
- )
-}
-
-func (s *PgSQLStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool, error) {
- return s.queryRowContextRowExists(
- r.Context,
- `SELECT COUNT(*) FROM cert_auth_associations WHERE id = $1 AND sha256 = $2;`,
- r.ID, strings.ToLower(hash),
- )
-}
-
-// AssociateCertHash "DO NOTHING" on duplicated keys
-func (s *PgSQLStorage) AssociateCertHash(r *mdm.Request, hash string, _ time.Time) error {
- _, err := s.db.ExecContext(
- r.Context, `
-INSERT INTO cert_auth_associations (id, sha256)
-VALUES ($1, $2)
-ON CONFLICT ON CONSTRAINT cert_auth_associations_pkey DO UPDATE SET updated_at=now();`,
- r.ID,
- strings.ToLower(hash),
- )
- return err
-}
diff --git a/server/mdm/nanomdm/storage/pgsql/migrate.go b/server/mdm/nanomdm/storage/pgsql/migrate.go
deleted file mode 100644
index 08b9a1f963..0000000000
--- a/server/mdm/nanomdm/storage/pgsql/migrate.go
+++ /dev/null
@@ -1,61 +0,0 @@
-package pgsql
-
-import (
- "context"
-
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
-)
-
-func (s *PgSQLStorage) RetrieveMigrationCheckins(ctx context.Context, c chan<- interface{}) error {
- // TODO: if a TokenUpdate does not include the latest UnlockToken
- // then we should synthesize a TokenUpdate to transfer it over.
- deviceRows, err := s.db.QueryContext(
- ctx,
- `SELECT authenticate, token_update FROM devices;`,
- )
- if err != nil {
- return err
- }
- defer deviceRows.Close()
- for deviceRows.Next() {
- var authBytes, tokenBytes []byte
- if err := deviceRows.Scan(&authBytes, &tokenBytes); err != nil {
- return err
- }
- for _, msgBytes := range [][]byte{authBytes, tokenBytes} {
- msg, err := mdm.DecodeCheckin(msgBytes)
- if err != nil {
- c <- err
- } else {
- c <- msg
- }
- }
- }
- if err = deviceRows.Err(); err != nil {
- return err
- }
- userRows, err := s.db.QueryContext(
- ctx,
- `SELECT token_update FROM users;`,
- )
- if err != nil {
- return err
- }
- defer userRows.Close()
- for userRows.Next() {
- var msgBytes []byte
- if err := userRows.Scan(&msgBytes); err != nil {
- return err
- }
- msg, err := mdm.DecodeCheckin(msgBytes)
- if err != nil {
- c <- err
- } else {
- c <- msg
- }
- }
- if err = userRows.Err(); err != nil {
- return err
- }
- return nil
-}
diff --git a/server/mdm/nanomdm/storage/pgsql/postgresql.go b/server/mdm/nanomdm/storage/pgsql/postgresql.go
deleted file mode 100644
index 123258e57a..0000000000
--- a/server/mdm/nanomdm/storage/pgsql/postgresql.go
+++ /dev/null
@@ -1,277 +0,0 @@
-// Package pgsql stores and retrieves MDM data from PostgresSQL
-package pgsql
-
-import (
- "context"
- "database/sql"
- _ "embed"
- "errors"
- "fmt"
-
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log"
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/ctxlog"
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
-)
-
-// Schema holds the schema for the NanoMDM PostgresSQL storage.
-//
-//go:embed schema.sql
-var Schema string
-
-var ErrNoCert = errors.New("no certificate in MDM Request")
-
-type PgSQLStorage struct {
- logger log.Logger
- db *sql.DB
- rm bool
-}
-
-type config struct {
- driver string
- dsn string
- db *sql.DB
- logger log.Logger
- rm bool
-}
-
-type Option func(*config)
-
-func WithLogger(logger log.Logger) Option {
- return func(c *config) {
- c.logger = logger
- }
-}
-
-func WithDSN(dsn string) Option {
- return func(c *config) {
- c.dsn = dsn
- }
-}
-
-func WithDriver(driver string) Option {
- return func(c *config) {
- c.driver = driver
- }
-}
-
-func WithDB(db *sql.DB) Option {
- return func(c *config) {
- c.db = db
- }
-}
-
-func WithDeleteCommands() Option {
- return func(c *config) {
- c.rm = true
- }
-}
-
-func New(opts ...Option) (*PgSQLStorage, error) {
- cfg := &config{logger: log.NopLogger, driver: "postgres"}
- for _, opt := range opts {
- opt(cfg)
- }
- var err error
- if cfg.db == nil {
- cfg.db, err = sql.Open(cfg.driver, cfg.dsn)
- if err != nil {
- return nil, err
- }
- }
- if err = cfg.db.Ping(); err != nil {
- return nil, err
- }
- return &PgSQLStorage{db: cfg.db, logger: cfg.logger, rm: cfg.rm}, nil
-}
-
-// nullEmptyString returns a NULL string if s is empty.
-func nullEmptyString(s string) sql.NullString {
- return sql.NullString{
- String: s,
- Valid: s != "",
- }
-}
-
-func (s *PgSQLStorage) StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) error {
- var pemCert []byte
- if r.Certificate != nil {
- pemCert = cryptoutil.PEMCertificate(r.Certificate.Raw)
- }
- _, err := s.db.ExecContext(
- r.Context, `
-INSERT INTO devices
- (id, identity_cert, serial_number, authenticate, authenticate_at)
-VALUES
- ($1, $2, $3, $4, CURRENT_TIMESTAMP)
-ON CONFLICT ON CONSTRAINT devices_pkey DO
-UPDATE SET
- identity_cert = EXCLUDED.identity_cert,
- serial_number = EXCLUDED.serial_number,
- authenticate = EXCLUDED.authenticate,
- authenticate_at = CURRENT_TIMESTAMP;`,
- r.ID, nullEmptyString(string(pemCert)), nullEmptyString(msg.SerialNumber), msg.Raw,
- )
- return err
-}
-
-func (s *PgSQLStorage) storeDeviceTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error {
- query := `UPDATE devices SET token_update = $1, token_update_at = CURRENT_TIMESTAMP`
- where := ` WHERE id = $2;`
- args := []interface{}{msg.Raw}
- // separately store the Unlock Token per MDM spec
- if len(msg.UnlockToken) > 0 {
- query += `, unlock_token = $2, unlock_token_at = CURRENT_TIMESTAMP `
- args = append(args, msg.UnlockToken)
- where = ` WHERE id = $3;`
- }
- args = append(args, r.ID)
- _, err := s.db.ExecContext(r.Context, query+where, args...)
- return err
-}
-
-func (s *PgSQLStorage) storeUserTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error {
- // there shouldn't be an Unlock Token on the user channel, but
- // complain if there is to warn an admin
- if len(msg.UnlockToken) > 0 {
- ctxlog.Logger(r.Context, s.logger).Info(
- "msg", "Unlock Token on user channel not stored",
- )
- }
- _, err := s.db.ExecContext(
- r.Context, `
-INSERT INTO users
- (id, device_id, user_short_name, user_long_name, token_update, token_update_at)
-VALUES
- ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
-ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE
-SET
- device_id = EXCLUDED.device_id,
- user_short_name = EXCLUDED.user_short_name,
- user_long_name = EXCLUDED.user_long_name,
- token_update = EXCLUDED.token_update,
- token_update_at = CURRENT_TIMESTAMP;`,
- r.ID,
- r.ParentID,
- nullEmptyString(msg.UserShortName),
- nullEmptyString(msg.UserLongName),
- msg.Raw,
- )
- return err
-}
-
-func (s *PgSQLStorage) StoreTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error {
- var err error
- var deviceId, userId string
- resolved := (&msg.Enrollment).Resolved()
- if err = resolved.Validate(); err != nil {
- return err
- }
- if resolved.IsUserChannel {
- deviceId = r.ParentID
- userId = r.ID
- err = s.storeUserTokenUpdate(r, msg)
- } else {
- deviceId = r.ID
- err = s.storeDeviceTokenUpdate(r, msg)
- }
- if err != nil {
- return err
- }
- _, err = s.db.ExecContext(
- r.Context, `
-INSERT INTO enrollments
- (id, device_id, user_id, type, topic, push_magic, token_hex, last_seen_at, token_update_tally)
-VALUES
- ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP, 1)
-ON CONFLICT ON CONSTRAINT enrollments_pkey DO UPDATE
-SET
- device_id = EXCLUDED.device_id,
- user_id = EXCLUDED.user_id,
- type = EXCLUDED.type,
- topic = EXCLUDED.topic,
- push_magic = EXCLUDED.push_magic,
- token_hex = EXCLUDED.token_hex,
- enabled = TRUE,
- last_seen_at = CURRENT_TIMESTAMP,
- token_update_tally = enrollments.token_update_tally + 1;`,
- r.ID,
- deviceId,
- nullEmptyString(userId),
- r.Type.String(),
- msg.Topic,
- msg.PushMagic,
- msg.Token.String(),
- )
- return err
-}
-
-func (s *PgSQLStorage) RetrieveTokenUpdateTally(ctx context.Context, id string) (int, error) {
- var tally int
- err := s.db.QueryRowContext(
- ctx,
- `SELECT token_update_tally FROM enrollments WHERE id = $1;`,
- id,
- ).Scan(&tally)
- return tally, err
-}
-
-func (s *PgSQLStorage) StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthenticate) error {
- colName := "user_authenticate"
- colAtName := "user_authenticate_at"
- // if the DigestResponse is empty then this is the first (of two)
- // UserAuthenticate messages depending on our response
- if msg.DigestResponse != "" {
- colName = "user_authenticate_digest"
- colAtName = "user_authenticate_digest_at"
- }
- _, err := s.db.ExecContext(
- //nolint:gosec
- r.Context, `
-INSERT INTO users
- (id, device_id, user_short_name, user_long_name, `+colName+`, `+colAtName+`)
-VALUES
- ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
-ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE
-SET
- device_id = EXCLUDED.device_id,
- user_short_name = EXCLUDED.user_short_name,
- user_long_name = EXCLUDED.user_long_name,
- `+colName+` = EXCLUDED.`+colName+`,
- `+colAtName+` = EXCLUDED.`+colAtName+`;`,
- r.ID,
- r.ParentID,
- nullEmptyString(msg.UserShortName),
- nullEmptyString(msg.UserLongName),
- msg.Raw,
- )
- if err != nil {
- return err
- }
- return s.updateLastSeen(r)
-}
-
-// Disable can be called for an Authenticate or CheckOut message
-func (s *PgSQLStorage) Disable(r *mdm.Request) error {
- if r.ParentID != "" {
- return errors.New("can only disable a device channel")
- }
- _, err := s.db.ExecContext(
- r.Context,
- `UPDATE enrollments SET enabled = FALSE, token_update_tally = 0, last_seen_at = CURRENT_TIMESTAMP WHERE device_id = $1 AND enabled = TRUE;`,
- r.ID,
- )
- return err
-}
-
-func (s *PgSQLStorage) updateLastSeen(r *mdm.Request) (err error) {
- _, err = s.db.ExecContext(
- r.Context,
- `UPDATE enrollments SET last_seen_at = CURRENT_TIMESTAMP WHERE id = $1`,
- r.ID,
- )
- if err != nil {
- err = fmt.Errorf("updating last seen: %w", err)
- }
- return
-}
diff --git a/server/mdm/nanomdm/storage/pgsql/push.go b/server/mdm/nanomdm/storage/pgsql/push.go
deleted file mode 100644
index de14a6306a..0000000000
--- a/server/mdm/nanomdm/storage/pgsql/push.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package pgsql
-
-import (
- "context"
- "errors"
- "strconv"
- "strings"
-
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
-)
-
-// RetrievePushInfo retreives push info for identifiers ids.
-//
-// Note that we may return fewer results than input. The user of this
-// method needs to reconcile that with their requested ids.
-func (s *PgSQLStorage) RetrievePushInfo(ctx context.Context, ids []string) (map[string]*mdm.Push, error) {
- if len(ids) < 1 {
- return nil, errors.New("no ids provided")
- }
-
- // previous: `SELECT id, topic, push_magic, token_hex FROM enrollments WHERE id IN (`+qs+`);`,
- // refactor all strings concatenations with strings.Builder which is more efficient
- var qs strings.Builder
-
- qs.WriteString(`SELECT id, topic, push_magic, token_hex FROM enrollments WHERE id IN (`)
- args := make([]interface{}, len(ids))
- for i, v := range ids {
- args[i] = v
- if i > 0 {
- qs.WriteString(",")
- }
- // can be a bit faster than fmt.Fprintf(&qs, "$%d", i+1)
- qs.WriteString("$")
- qs.WriteString(strconv.Itoa(i + 1))
- }
- qs.WriteString(`);`)
-
- rows, err := s.db.QueryContext(ctx, qs.String(), args...)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- pushInfos := make(map[string]*mdm.Push)
- for rows.Next() {
- push := new(mdm.Push)
- var id, token string
- if err := rows.Scan(&id, &push.Topic, &push.PushMagic, &token); err != nil {
- return nil, err
- }
- // convert from hex
- if err := push.SetTokenString(token); err != nil {
- return nil, err
- }
- pushInfos[id] = push
- }
- return pushInfos, rows.Err()
-}
diff --git a/server/mdm/nanomdm/storage/pgsql/pushcert.go b/server/mdm/nanomdm/storage/pgsql/pushcert.go
deleted file mode 100644
index 660dbfef1f..0000000000
--- a/server/mdm/nanomdm/storage/pgsql/pushcert.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package pgsql
-
-import (
- "context"
- "crypto/tls"
- "strconv"
-
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
-)
-
-func (s *PgSQLStorage) RetrievePushCert(ctx context.Context, topic string) (*tls.Certificate, string, error) {
- var certPEM, keyPEM []byte
- var staleToken int
- err := s.db.QueryRowContext(
- ctx,
- `SELECT cert_pem, key_pem, stale_token FROM push_certs WHERE topic = $1;`,
- topic,
- ).Scan(&certPEM, &keyPEM, &staleToken)
- if err != nil {
- return nil, "", err
- }
- cert, err := tls.X509KeyPair(certPEM, keyPEM)
- if err != nil {
- return nil, "", err
- }
- return &cert, strconv.Itoa(staleToken), err
-}
-
-func (s *PgSQLStorage) IsPushCertStale(ctx context.Context, topic, staleToken string) (bool, error) {
- var staleTokenInt, dbStaleToken int
- staleTokenInt, err := strconv.Atoi(staleToken)
- if err != nil {
- return true, err
- }
- err = s.db.QueryRowContext(
- ctx,
- `SELECT stale_token FROM push_certs WHERE topic = $1;`,
- topic,
- ).Scan(&dbStaleToken)
- return dbStaleToken != staleTokenInt, err
-}
-
-func (s *PgSQLStorage) StorePushCert(ctx context.Context, pemCert, pemKey []byte) error {
- topic, err := cryptoutil.TopicFromPEMCert(pemCert)
- if err != nil {
- return err
- }
- _, err = s.db.ExecContext(
- ctx, `
-INSERT INTO push_certs
- (topic, cert_pem, key_pem, stale_token)
-VALUES
- ($1, $2, $3, 0)
-ON CONFLICT (topic) DO
-UPDATE SET
- cert_pem = EXCLUDED.cert_pem,
- key_pem = EXCLUDED.key_pem,
- stale_token = push_certs.stale_token + 1;`,
- topic, pemCert, pemKey,
- )
- return err
-}
diff --git a/server/mdm/nanomdm/storage/pgsql/queue.go b/server/mdm/nanomdm/storage/pgsql/queue.go
deleted file mode 100644
index 2ee1e88bbc..0000000000
--- a/server/mdm/nanomdm/storage/pgsql/queue.go
+++ /dev/null
@@ -1,200 +0,0 @@
-package pgsql
-
-import (
- "context"
- "database/sql"
- "errors"
- "fmt"
- "strconv"
- "strings"
-
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
-)
-
-func enqueue(ctx context.Context, tx *sql.Tx, ids []string, cmd *mdm.Command) error {
- if len(ids) < 1 {
- return errors.New("no id(s) supplied to queue command to")
- }
- _, err := tx.ExecContext(
- ctx,
- `INSERT INTO commands (command_uuid, request_type, command) VALUES ($1, $2, $3);`,
- cmd.CommandUUID, cmd.Command.RequestType, cmd.Raw,
- )
- if err != nil {
- return err
- }
-
- var query strings.Builder
-
- query.WriteString(`INSERT INTO enrollment_queue (id, command_uuid) VALUES `)
- args := make([]interface{}, len(ids)*2)
- for i, id := range ids {
- if i > 0 {
- query.WriteString(",")
- }
- ind := i * 2
-
- //previous: query += fmt.Sprintf("($%d, $%d)", ind+1, ind+2)
- query.WriteString("($")
- query.WriteString(strconv.Itoa(ind + 1))
- query.WriteString(", $")
- query.WriteString(strconv.Itoa(ind + 2))
- query.WriteString(")")
-
- args[ind] = id
- args[ind+1] = cmd.CommandUUID
- }
- query.WriteString(";")
-
- _, err = tx.ExecContext(ctx, query.String(), args...)
- return err
-}
-
-func (s *PgSQLStorage) EnqueueCommand(ctx context.Context, ids []string, cmd *mdm.Command) (map[string]error, error) {
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return nil, err
- }
- if err = enqueue(ctx, tx, ids, cmd); err != nil {
- if rbErr := tx.Rollback(); rbErr != nil {
- return nil, fmt.Errorf("rollback error: %w; while trying to handle error: %v", rbErr, err)
- }
- return nil, err
- }
- return nil, tx.Commit()
-}
-
-func (s *PgSQLStorage) deleteCommand(ctx context.Context, tx *sql.Tx, id, uuid string) error {
- _, err := tx.ExecContext(ctx, `
-DELETE FROM enrollment_queue
-WHERE id =$1 AND command_uuid =$2;`, id, uuid)
- if err != nil {
- return err
- }
- // delete command result (i.e. NotNows) and this queued command
- _, err = tx.ExecContext(ctx, `
-DELETE FROM command_results
-WHERE id =$1 AND command_uuid =$2;`, id, uuid)
- if err != nil {
- return err
- }
-
- // now delete the actual command if no enrollments have it queued
- // nor are there any results for it.
- _, err = tx.ExecContext(
- ctx, `
-DELETE FROM commands
-USING
- commands AS c
- LEFT JOIN enrollment_queue AS q
- ON q.command_uuid = c.command_uuid
- LEFT JOIN command_results AS r
- ON r.command_uuid = c.command_uuid
-WHERE
- c.command_uuid =$1 AND
- q.command_uuid IS NULL AND
- r.command_uuid IS NULL AND
- commands.command_uuid = c.command_uuid;
-`,
- uuid,
- )
- return err
-}
-
-func (s *PgSQLStorage) deleteCommandTx(r *mdm.Request, result *mdm.CommandResults) error {
- tx, err := s.db.BeginTx(r.Context, nil)
- if err != nil {
- return err
- }
- if err = s.deleteCommand(r.Context, tx, r.ID, result.CommandUUID); err != nil {
- if rbErr := tx.Rollback(); rbErr != nil {
- return fmt.Errorf("rollback error: %w; while trying to handle error: %v", rbErr, err)
- }
- return err
- }
- return tx.Commit()
-}
-
-func (s *PgSQLStorage) StoreCommandReport(r *mdm.Request, result *mdm.CommandResults) error {
- if err := s.updateLastSeen(r); err != nil {
- return err
- }
- if result.Status == "Idle" {
- return nil
- }
- if s.rm && result.Status != "NotNow" {
- return s.deleteCommandTx(r, result)
- }
- notNowConstants := "NULL, 0"
- notNowBumpTallySQL := ""
- // note that due to the "ON CONFLICT ON CONSTRAINT command_results_pkey" we don't UPDATE the
- // not_now_at field. thus it will only represent the first NotNow.
- if result.Status == "NotNow" {
- notNowConstants = "CURRENT_TIMESTAMP, 1"
- notNowBumpTallySQL = `, not_now_tally = command_results.not_now_tally + 1`
- }
- _, err := s.db.ExecContext(
- //nolint:gosec
- r.Context, `
-INSERT INTO command_results
- (id, command_uuid, status, result, not_now_at, not_now_tally)
-VALUES
- ($1, $2, $3, $4, `+notNowConstants+`)
-ON CONFLICT ON CONSTRAINT command_results_pkey DO UPDATE
-SET
- status = EXCLUDED.status,
- result = EXCLUDED.result`+notNowBumpTallySQL+`;`,
- r.ID,
- result.CommandUUID,
- result.Status,
- result.Raw,
- )
- return err
-}
-
-func (s *PgSQLStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.Command, error) {
- statusWhere := "status IS NULL"
- if !skipNotNow {
- statusWhere = `(` + statusWhere + ` OR status = 'NotNow')`
- }
- command := new(mdm.Command)
- err := s.db.QueryRowContext(
- r.Context,
- `SELECT command_uuid, request_type, command FROM view_queue WHERE id = $1 AND active = TRUE AND `+statusWhere+` LIMIT 1;`,
- r.ID,
- ).Scan(&command.CommandUUID, &command.Command.RequestType, &command.Raw)
- if err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return nil, nil
- }
- return nil, err
- }
- return command, nil
-}
-
-func (s *PgSQLStorage) ClearQueue(r *mdm.Request) error {
- if r.ParentID != "" {
- return errors.New("can only clear a device channel queue")
- }
- // PostgreSQL UPDATE differs from MySQL, uses "FROM" specific
- // to pgsql extension
- _, err := s.db.ExecContext(
- r.Context,
- `
-UPDATE enrollment_queue
-SET active = FALSE
-FROM enrollment_queue AS q
- INNER JOIN enrollments AS e
- ON q.id = e.id
- INNER JOIN commands AS c
- ON q.command_uuid = c.command_uuid
- LEFT JOIN command_results r
- ON r.command_uuid = q.command_uuid AND r.id = q.id
-WHERE
- e.device_id = $1 AND
- enrollment_queue.active = TRUE AND
- (r.status IS NULL OR r.status = 'NotNow') AND
- enrollment_queue.id = q.id;`,
- r.ID)
- return err
-}
diff --git a/server/mdm/nanomdm/storage/pgsql/queue_test.go b/server/mdm/nanomdm/storage/pgsql/queue_test.go
deleted file mode 100644
index 4aa9e2c7b7..0000000000
--- a/server/mdm/nanomdm/storage/pgsql/queue_test.go
+++ /dev/null
@@ -1,111 +0,0 @@
-//go:build integration
-// +build integration
-
-package pgsql
-
-import (
- "context"
- "errors"
- "flag"
- "io/ioutil"
- "testing"
-
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/internal/test"
- _ "github.com/lib/pq"
-)
-
-var flDSN = flag.String("dsn", "", "DSN of test PostgreSQL instance")
-
-func loadAuthMsg() (*mdm.Authenticate, error) {
- b, err := ioutil.ReadFile("../../mdm/testdata/Authenticate.2.plist")
- if err != nil {
- return nil, err
- }
- r, err := mdm.DecodeCheckin(b)
- if err != nil {
- return nil, err
- }
- a, ok := r.(*mdm.Authenticate)
- if !ok {
- return nil, errors.New("not an Authenticate message")
- }
- return a, nil
-}
-
-func loadTokenMsg() (*mdm.TokenUpdate, error) {
- b, err := ioutil.ReadFile("../../mdm/testdata/TokenUpdate.2.plist")
- if err != nil {
- return nil, err
- }
- r, err := mdm.DecodeCheckin(b)
- if err != nil {
- return nil, err
- }
- a, ok := r.(*mdm.TokenUpdate)
- if !ok {
- return nil, errors.New("not a TokenUpdate message")
- }
- return a, nil
-}
-
-const deviceUDID = "66ADE930-5FDF-5EC4-8429-15640684C489"
-
-func newMdmReq() *mdm.Request {
- return &mdm.Request{
- Context: context.Background(),
- EnrollID: &mdm.EnrollID{
- Type: mdm.Device,
- ID: deviceUDID,
- },
- }
-}
-
-func enrollTestDevice(storage *PgSQLStorage) error {
- authMsg, err := loadAuthMsg()
- if err != nil {
- return err
- }
- err = storage.StoreAuthenticate(newMdmReq(), authMsg)
- if err != nil {
- return err
- }
- tokenMsg, err := loadTokenMsg()
- if err != nil {
- return err
- }
- err = storage.StoreTokenUpdate(newMdmReq(), tokenMsg)
- if err != nil {
- return err
- }
- return nil
-}
-
-func TestQueue(t *testing.T) {
- if *flDSN == "" {
- t.Fatal("PostgreSQL DSN flag not provided to test")
- }
-
- storage, err := New(WithDSN(*flDSN), WithDeleteCommands())
- if err != nil {
- t.Fatal(err)
- }
-
- err = enrollTestDevice(storage)
- if err != nil {
- t.Fatal(err)
- }
-
- t.Run("WithDeleteCommands()", func(t *testing.T) {
- test.TestQueue(t, deviceUDID, storage)
- })
-
- storage, err = New(WithDSN(*flDSN))
- if err != nil {
- t.Fatal(err)
- }
-
- t.Run("normal", func(t *testing.T) {
- test.TestQueue(t, deviceUDID, storage)
- })
-}
diff --git a/server/mdm/nanomdm/storage/pgsql/schema.sql b/server/mdm/nanomdm/storage/pgsql/schema.sql
deleted file mode 100644
index e4bc723344..0000000000
--- a/server/mdm/nanomdm/storage/pgsql/schema.sql
+++ /dev/null
@@ -1,317 +0,0 @@
-/* Requires PostgreSQL 9.5 or later.
- * From PostgreSQL documentation: ON CONFLICT clause is only available from PostgreSQL 9.5
- */
-
-CREATE TABLE devices
-(
- id VARCHAR(255) NOT NULL,
-
- identity_cert TEXT NULL,
-
- serial_number VARCHAR(127) NULL,
-
- -- If the (iOS, iPadOS) device sent an UnlockToken in the TokenUpdate
- -- TODO: Consider using a TEXT field and encoding the binary
- unlock_token BYTEA NULL,
- unlock_token_at TIMESTAMP NULL,
-
- -- The last raw Authenticate for this device
- authenticate TEXT NOT NULL,
- authenticate_at TIMESTAMP NOT NULL,
- -- The last raw TokenUpdate for this device
- token_update TEXT NULL,
- token_update_at TIMESTAMP NULL,
-
- bootstrap_token_b64 TEXT NULL,
- bootstrap_token_at TIMESTAMP NULL,
-
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- trigger
-
- PRIMARY KEY (id),
-
- CHECK (identity_cert IS NULL OR SUBSTRING(identity_cert FROM 1 FOR 27) = '-----BEGIN CERTIFICATE-----'),
- CHECK (serial_number IS NULL OR serial_number != ''),
- CHECK (unlock_token IS NULL OR LENGTH(unlock_token) > 0),
- CHECK (authenticate != ''),
- CHECK (token_update IS NULL OR token_update != ''),
- CHECK (bootstrap_token_b64 IS NULL OR bootstrap_token_b64 != '')
-);
-CREATE INDEX serial_number ON devices (serial_number);
-
-CREATE TABLE users
-(
- id VARCHAR(255) NOT NULL,
- device_id VARCHAR(255) NOT NULL,
-
- user_short_name VARCHAR(255) NULL,
- user_long_name VARCHAR(255) NULL,
-
- -- The last raw TokenUpdate for this user
- token_update TEXT NULL,
- token_update_at TIMESTAMP NULL,
-
- -- The last raw UserAuthenticate (and optional digest) for this user
- user_authenticate TEXT NULL,
- user_authenticate_at TIMESTAMP NULL,
- user_authenticate_digest TEXT NULL,
- user_authenticate_digest_at TIMESTAMP NULL,
-
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- trigger
-
- PRIMARY KEY (id, device_id),
- UNIQUE (id),
-
- FOREIGN KEY (device_id)
- REFERENCES devices (id)
- ON DELETE CASCADE ON UPDATE CASCADE,
-
- CHECK (user_short_name IS NULL OR user_short_name != ''),
- CHECK (user_long_name IS NULL OR user_long_name != ''),
- CHECK (token_update IS NULL OR token_update != ''),
- CHECK (user_authenticate IS NULL OR user_authenticate != ''),
- CHECK (user_authenticate_digest IS NULL OR user_authenticate_digest != '')
-);
-
-/* This table represents enrollments which are an amalgamation of
- * both device and user enrollments.
- */
-CREATE TABLE enrollments
-(
- -- The enrollment ID of this enrollment
- id VARCHAR(255) NOT NULL,
- -- The "device" enrollment ID of this enrollment. This will be
- -- the same as the `id` field in the case of a "device" enrollment,
- -- or will be the "parent" enrollment for a "user" enrollment.
- device_id VARCHAR(255) NOT NULL,
- -- The "user" enrollment ID of this enrollment. This will be the
- -- same as the `id` field in the case of a "user" enrollment or
- -- NULL in the case of a device enrollment.
- user_id VARCHAR(255) NULL,
-
- -- Textual representation of the type of device enrollment.
- type VARCHAR(31) NOT NULL,
-
- -- The MDM APNs push trifecta.
- topic VARCHAR(255) NOT NULL,
- push_magic VARCHAR(127) NOT NULL,
- token_hex VARCHAR(255) NOT NULL, -- TODO: Perhaps just CHAR(64)?
-
- enabled BOOLEAN NOT NULL DEFAULT TRUE,
- token_update_tally INTEGER NOT NULL DEFAULT 1,
-
- last_seen_at TIMESTAMP NOT NULL, -- TODO: additional tests with real device and integration tests.
-
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-
- PRIMARY KEY (id),
- CHECK (id != ''),
-
- FOREIGN KEY (device_id)
- REFERENCES devices (id)
- ON DELETE CASCADE ON UPDATE CASCADE,
-
- FOREIGN KEY (user_id)
- REFERENCES users (id)
- ON DELETE CASCADE ON UPDATE CASCADE,
- UNIQUE (user_id),
-
- CHECK (type != ''),
- CHECK (topic != ''),
- CHECK (push_magic != ''),
- CHECK (token_hex != '')
-);
-CREATE INDEX idx_type ON enrollments (type);
-
-/* Commands stand alone. By themselves they aren't associated with
- * a device, a result (response), etc. Joining other tables is required
- * for more context.
- */
-CREATE TABLE commands
-(
- command_uuid VARCHAR(127) NOT NULL,
- request_type VARCHAR(63) NOT NULL,
- -- Raw command Plist
- command TEXT NOT NULL,
-
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-
- PRIMARY KEY (command_uuid),
-
- CHECK (command_uuid != ''),
- CHECK (request_type != ''),
- CHECK (SUBSTRING(command FROM 1 FOR 5) = ' 0 {
+ cmd = new(mdm.Command)
+ if err = plist.Unmarshal(body, cmd); err != nil {
+ return nil, fmt.Errorf("decoding command body: %w", err)
+ }
+ }
+
+ return cmd, nil
+}
diff --git a/server/mdm/nanomdm/test/e2e/e2e.go b/server/mdm/nanomdm/test/e2e/e2e.go
new file mode 100644
index 0000000000..c6478b9751
--- /dev/null
+++ b/server/mdm/nanomdm/test/e2e/e2e.go
@@ -0,0 +1,119 @@
+package e2e
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
+ httpapi "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http/api"
+ httpmdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http/mdm"
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service"
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/certauth"
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/nanomdm"
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
+ "github.com/micromdm/nanolib/log"
+ "github.com/micromdm/nanolib/log/stdlogfmt"
+)
+
+const (
+ serverURL = "/mdm"
+ enqueueURL = "/api/enq/"
+)
+
+// setupNanoMDM configures normal-ish NanoMDM HTTP server handlers for testing.
+func setupNanoMDM(logger log.Logger, store storage.AllStorage) (http.Handler, error) {
+ // begin with the primary NanoMDM service
+ var svc service.CheckinAndCommandService = nanomdm.New(store, nanomdm.WithLogger(logger))
+
+ // chain the certificate auth middleware
+ svc = certauth.New(svc, store)
+
+ // setup MDM (check-in and command) handlers
+ var mdmHandler http.Handler = httpmdm.CheckinAndCommandHandler(svc, logger.With("handler", "mdm"))
+ // mdmHandler = httpmdm.CertVerifyMiddleware(mdmHandler, , logger.With("handler", "verify"))
+ mdmHandler = httpmdm.CertExtractMdmSignatureMiddleware(mdmHandler, httpmdm.MdmSignatureVerifierFunc(cryptoutil.VerifyMdmSignature))
+
+ // setup API handlers
+ var enqueueHandler http.Handler = httpapi.RawCommandEnqueueHandler(store, nil, logger.With("handler", enqueueURL))
+ enqueueHandler = http.StripPrefix(enqueueURL, enqueueHandler)
+
+ // create a mux for them
+ mux := http.NewServeMux()
+ mux.Handle(serverURL, mdmHandler)
+ mux.Handle(enqueueURL, enqueueHandler)
+
+ return mux, nil
+}
+
+type NanoMDMAPI interface {
+ // RawCommandEnqueue enqueues cmd to ids. An APNs push is omitted if nopush is true.
+ RawCommandEnqueue(ctx context.Context, ids []string, cmd *mdm.Command, nopush bool) error
+}
+
+type IDer interface {
+ ID() string
+}
+
+func TestE2E(t *testing.T, ctx context.Context, store storage.AllStorage) {
+ var logger log.Logger = stdlogfmt.New(stdlogfmt.WithDebugFlag(true))
+
+ mux, err := setupNanoMDM(logger, store)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // create a fake HTTP client that dispatches to our raw handlers
+ c := NewHandlerClient(mux)
+
+ // create our new device for testing
+ d, err := newDeviceFromCheckins(
+ c,
+ serverURL,
+ "../../mdm/testdata/Authenticate.2.plist",
+ "../../mdm/testdata/TokenUpdate.2.plist",
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ t.Run("certauth", func(t *testing.T) { certAuth(t, ctx, store) })
+ t.Run("certauth-retro", func(t *testing.T) { certAuthRetro(t, ctx, store) })
+
+ // regression test for retrieving push info of missing devices.
+ t.Run("invalid-pushinfo", func(t *testing.T) {
+ _, err := store.RetrievePushInfo(ctx, []string{"INVALID"})
+ if err != nil {
+ // should NOT recieve a "global" error for an enrollment that
+ // is merely invalid (or not enrolled yet, or not fully enrolled)
+ t.Errorf("should NOT have errored: %v", err)
+ }
+ })
+
+ t.Run("enroll", func(t *testing.T) { enroll(t, ctx, d, store) })
+
+ t.Run("tally", func(t *testing.T) { tally(t, ctx, d, store, 1) })
+
+ t.Run("bstoken", func(t *testing.T) { bstoken(t, ctx, d.Enrollment) })
+
+ // re-enroll device
+ // this is to try and catch any leftover crud that a storage backend didn't
+ // clean up (like the tally count, BS token, etc.)
+ err = d.DoEnroll(ctx)
+ if err != nil {
+ t.Fatal(fmt.Errorf("re-enrolling device %s: %w", d.ID(), err))
+ }
+
+ t.Run("tally-after-reenroll", func(t *testing.T) { tally(t, ctx, d, store, 1) })
+
+ t.Run("bstoken-after-reenroll", func(t *testing.T) { bstoken(t, ctx, d.Enrollment) })
+
+ err = store.ClearQueue(d.NewMDMRequest(ctx))
+ if err != nil {
+ t.Fatal()
+ }
+
+ t.Run("queue", func(t *testing.T) { queue(t, ctx, d, &api{doer: c}) })
+}
diff --git a/server/mdm/nanomdm/test/e2e/enroll.go b/server/mdm/nanomdm/test/e2e/enroll.go
new file mode 100644
index 0000000000..5f59608f1c
--- /dev/null
+++ b/server/mdm/nanomdm/test/e2e/enroll.go
@@ -0,0 +1,39 @@
+package e2e
+
+import (
+ "context"
+ "reflect"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
+)
+
+type enrollDevice interface {
+ IDer
+ DoEnroll(context.Context) error
+ GetPush() *mdm.Push
+}
+
+func enroll(t *testing.T, ctx context.Context, d enrollDevice, store storage.PushStore) {
+ // enroll it
+ err := d.DoEnroll(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // extract the push info for the given id
+ pushInfos, err := store.RetrievePushInfo(ctx, []string{d.ID()})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // test that we got the right push data data back
+ if want, have := 1, len(pushInfos); want != have {
+ t.Fatalf("len(pushInfos): want: %v, have: %v", want, have)
+ }
+ push := d.GetPush()
+ if !reflect.DeepEqual(pushInfos[d.ID()], push) {
+ t.Errorf("pushInfo have: %v, want: %v", pushInfos[d.ID()], push)
+ }
+}
diff --git a/server/mdm/nanomdm/test/e2e/queue.go b/server/mdm/nanomdm/test/e2e/queue.go
new file mode 100644
index 0000000000..63b528fc94
--- /dev/null
+++ b/server/mdm/nanomdm/test/e2e/queue.go
@@ -0,0 +1,96 @@
+package e2e
+
+import (
+ "context"
+ "fmt"
+ "reflect"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
+)
+
+type queueDevice interface {
+ CMDDoReportAndFetch(ctx context.Context, cmd *mdm.CommandResults) (*mdm.Command, error)
+ NewCommandReport(uuid, status string, errors []mdm.ErrorChain) *mdm.CommandResults
+ IDer
+}
+
+// enqueue enqueues cmd to id using a.
+func enqueue(t *testing.T, ctx context.Context, a NanoMDMAPI, id string, cmd *mdm.Command) {
+ err := a.RawCommandEnqueue(ctx, []string{id}, cmd, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+// simpleCmd makes a command with a CommandUUID and RequestType the same string.
+func simpleCmd(cmdID string) *mdm.Command {
+ return newCommand(cmdID, cmdID)
+}
+
+// sendReportExpectCommandReply send a command report and expect a certain command reply.
+func sendReportExpectCommandReply(t *testing.T, ctx context.Context, d queueDevice, reportCmd, reportStatus, expectedCmd string) {
+ cr := d.NewCommandReport(reportCmd, reportStatus, nil)
+ cmd, err := d.CMDDoReportAndFetch(ctx, cr)
+ if err != nil {
+ t.Fatal(fmt.Errorf("reporting cmd=%s status=%s: %w", reportCmd, reportStatus, err))
+ }
+
+ // make sure the command we expect was received
+ if have, want := cmd, simpleCmd(expectedCmd); !reflect.DeepEqual(have, want) {
+ t.Errorf("command: have: %v, want: %v", have, want)
+ }
+}
+
+// enqueueSimple enqueues cmd to a for d.
+func enqueueSimple(t *testing.T, ctx context.Context, d queueDevice, a NanoMDMAPI, cmd string) {
+ // we're assuming the UDID is all we need here.
+ enqueue(t, ctx, a, d.ID(), simpleCmd(cmd))
+}
+
+func queue(t *testing.T, ctx context.Context, d queueDevice, a NanoMDMAPI) {
+ t.Run("basic", func(t *testing.T) {
+ // report Idle.
+ // expect no command (empty queue for this id).
+ sendReportExpectCommandReply(t, ctx, d, "", "Idle", "")
+ // enqueue a couple commands.
+ enqueueSimple(t, ctx, d, a, "CMD1")
+ enqueueSimple(t, ctx, d, a, "CMD2")
+ // report Idle.
+ // but now expect the CMD1 result (first on the queue).
+ sendReportExpectCommandReply(t, ctx, d, "", "Idle", "CMD1")
+ // ack CMD1.
+ // expect CMD2.
+ sendReportExpectCommandReply(t, ctx, d, "CMD1", "Acknowledged", "CMD2")
+ // ack CMD2 (effectively clearning the queue).
+ // expect no command (only two commands queued).
+ sendReportExpectCommandReply(t, ctx, d, "CMD2", "Acknowledged", "")
+ // report Idle.
+ // expect no command (empty queue).
+ sendReportExpectCommandReply(t, ctx, d, "", "Idle", "")
+ })
+ t.Run("notnow", func(t *testing.T) {
+ // report Idle.
+ // expect no command (empty queue).
+ sendReportExpectCommandReply(t, ctx, d, "", "Idle", "")
+ // enqueue CMD3.
+ enqueueSimple(t, ctx, d, a, "CMD3")
+ // report Idle.
+ // expect CMD3.
+ sendReportExpectCommandReply(t, ctx, d, "", "Idle", "CMD3")
+ // report NotNow for CMD3.
+ // expect no command (only NotNow commands in queue).
+ sendReportExpectCommandReply(t, ctx, d, "CMD3", "NotNow", "")
+ // report Idle.
+ // this could be considered as "resetting" NotNow for CMD3.
+ // expect CMD3 (the NotNow'd command).
+ sendReportExpectCommandReply(t, ctx, d, "", "Idle", "CMD3")
+ // ack CMD3.
+ // expect no command (empty queue).
+ sendReportExpectCommandReply(t, ctx, d, "CMD3", "Acknowledged", "")
+ // report Idle.
+ // expect no command (empty queue).
+ sendReportExpectCommandReply(t, ctx, d, "", "Idle", "")
+ })
+
+}
diff --git a/server/mdm/nanomdm/test/e2e/tally.go b/server/mdm/nanomdm/test/e2e/tally.go
new file mode 100644
index 0000000000..9b9a9c4de4
--- /dev/null
+++ b/server/mdm/nanomdm/test/e2e/tally.go
@@ -0,0 +1,44 @@
+package e2e
+
+import (
+ "context"
+ "testing"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
+)
+
+type tokenTallyDevice interface {
+ DoTokenUpdate(context.Context) error
+ IDer
+}
+
+// tally tests to make sure the TokenUpdate tally functions nominally.
+func tally(t *testing.T, ctx context.Context, d tokenTallyDevice, store storage.TokenUpdateTallyStore, initial int) {
+ // retrieve the tally
+ tally, err := store.RetrieveTokenUpdateTally(ctx, d.ID())
+ if err != nil {
+ t.Fatal()
+ }
+
+ // make sure it's what we want
+ if have, want := tally, initial; have != want {
+ t.Errorf("token update tally: have: %v, want: %v", have, want)
+ }
+
+ // perform a TokenUpdate (should increase the tally)
+ err = d.DoTokenUpdate(ctx)
+ if err != nil {
+ t.Fatal()
+ }
+
+ // retrieve the tally again
+ tally, err = store.RetrieveTokenUpdateTally(ctx, d.ID())
+ if err != nil {
+ t.Fatal()
+ }
+
+ // make sure it's what we want (+1)
+ if have, want := tally, initial+1; have != want {
+ t.Errorf("token update tally (2nd): have: %v, want: %v", have, want)
+ }
+}
diff --git a/server/mdm/nanomdm/test/enrollment/enrollment.go b/server/mdm/nanomdm/test/enrollment/enrollment.go
new file mode 100644
index 0000000000..25aeae038c
--- /dev/null
+++ b/server/mdm/nanomdm/test/enrollment/enrollment.go
@@ -0,0 +1,362 @@
+package enrollment
+
+import (
+ "context"
+ "crypto"
+ "crypto/rand"
+ "crypto/x509"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "sync"
+
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/test"
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/test/protocol"
+ "github.com/groob/plist"
+)
+
+var ErrAlreadyEnrolled = errors.New("already enrolled")
+
+type Transport interface {
+ // DoCheckIn performs an HTTP MDM check-in to the CheckInURL (or ServerURL).
+ // The caller is responsible for closing the response body.
+ DoCheckIn(context.Context, io.Reader) (*http.Response, error)
+
+ // DoReportResultsAndFetchNext sends an HTTP MDM report-results-and-retrieve-next-command request to the ServerURL.
+ // The caller is responsible for closing the response body.
+ DoReportResultsAndFetchNext(ctx context.Context, report io.Reader) (*http.Response, error)
+}
+
+// Enrollment emulates an MDM enrollment.
+// Currently it mostly emulates device channel enrollments.
+type Enrollment struct {
+ enrollID mdm.EnrollID
+ enrollment mdm.Enrollment
+ push mdm.Push
+
+ cert *x509.Certificate
+ key crypto.PrivateKey
+
+ serialNumber string
+ unlockToken []byte
+
+ transport Transport
+
+ enrolled bool
+ enrollM sync.Mutex
+}
+
+func loadAuthTokUpd(authPath, tokUpdPath string) (*mdm.Authenticate, *mdm.TokenUpdate, error) {
+ authBytes, err := os.ReadFile(authPath)
+ if err != nil {
+ return nil, nil, err
+ }
+ msg, err := mdm.DecodeCheckin(authBytes)
+ if err != nil {
+ return nil, nil, err
+ }
+ auth, ok := msg.(*mdm.Authenticate)
+ if !ok {
+ return auth, nil, errors.New("not an Authenticate message")
+ }
+ tokUpdBytes, err := os.ReadFile(tokUpdPath)
+ if err != nil {
+ return auth, nil, err
+ }
+ msg, err = mdm.DecodeCheckin(tokUpdBytes)
+ if err != nil {
+ return auth, nil, err
+ }
+ tokUpd, ok := msg.(*mdm.TokenUpdate)
+ if !ok {
+ return auth, tokUpd, errors.New("not a TokenUpdate message")
+ }
+ return auth, tokUpd, nil
+}
+
+// NewFromCheckins loads device information from authenticate and tokenupdate files on disk.
+func NewFromCheckins(doer protocol.Doer, serverURL, checkInURL, authenticatePath, tokenUpdatePath string) (*Enrollment, error) {
+ auth, tokUpd, err := loadAuthTokUpd(authenticatePath, tokenUpdatePath)
+ if err != nil {
+ return nil, err
+ }
+
+ e := &Enrollment{
+ enrollment: auth.Enrollment,
+ push: tokUpd.Push,
+ serialNumber: auth.SerialNumber,
+
+ // we're assuming the IDs here are devices
+ enrollID: mdm.EnrollID{Type: mdm.Device, ID: auth.UDID},
+ }
+ e.key, e.cert, err = test.SimpleSelfSignedRSAKeypair("TESTDEVICE", 2)
+
+ e.transport = protocol.NewTransport(
+ protocol.WithSignMessage(),
+ protocol.WithIdentityProvider(e.GetIdentity),
+ protocol.WithMDMURLs(serverURL, checkInURL),
+ protocol.WithClient(doer),
+ )
+
+ return e, err
+}
+
+// ReplaceIdentityRandom changes the certificate private key to a random certificate and key.
+func ReplaceIdentityRandom(e *Enrollment) error {
+ var err error
+ e.key, e.cert, err = test.SimpleSelfSignedRSAKeypair("TESTDEVICE", 2)
+ return err
+}
+
+// NewRandomDeviceEnrollment creates a new randomly identified MDM enrollment.
+func NewRandomDeviceEnrollment(doer protocol.Doer, topic, serverURL, checkInURL string) (*Enrollment, error) {
+ udid := randString(32)
+ e := &Enrollment{
+ enrollment: mdm.Enrollment{UDID: udid},
+ push: mdm.Push{
+ Topic: topic,
+ PushMagic: randString(32),
+ // Token: []byte(randString(32)), // Token is populated in DoTokenUpdate()
+ },
+ serialNumber: randString(8),
+ // unlockToken: ,
+ enrollID: mdm.EnrollID{Type: mdm.Device, ID: udid},
+ }
+ var err error
+ e.key, e.cert, err = test.SimpleSelfSignedRSAKeypair("TESTDEVICE", 2)
+
+ e.transport = protocol.NewTransport(
+ protocol.WithSignMessage(),
+ protocol.WithIdentityProvider(e.GetIdentity),
+ protocol.WithMDMURLs(serverURL, checkInURL),
+ protocol.WithClient(doer),
+ )
+
+ return e, err
+}
+
+// GetIdentity supplies the identity certificate and key of this enrollment.
+func (e *Enrollment) GetIdentity(context.Context) (*x509.Certificate, crypto.PrivateKey, error) {
+ return e.cert, e.key, nil
+}
+
+// GenAuthenticate creates an XML Plist Authenticate check-in message.
+func (e *Enrollment) GenAuthenticate() (io.Reader, error) {
+ a := &mdm.Authenticate{
+ Enrollment: e.enrollment,
+ MessageType: mdm.MessageType{MessageType: "Authenticate"},
+ Topic: e.push.Topic,
+ SerialNumber: e.serialNumber,
+ }
+ return test.PlistReader(a)
+}
+
+// GenTokenUpdate creates an XML Plist TokenUpdate check-in message.
+func (e *Enrollment) GenTokenUpdate() (io.Reader, error) {
+ t := &mdm.TokenUpdate{
+ Enrollment: e.enrollment,
+ MessageType: mdm.MessageType{MessageType: "TokenUpdate"},
+ Push: e.push,
+ UnlockToken: e.unlockToken,
+ }
+ return test.PlistReader(t)
+}
+
+// doAuthenticate sends an Authenticate check-in message to the MDM server.
+func (e *Enrollment) doAuthenticate(ctx context.Context) error {
+ e.enrolled = false
+
+ // generate Authenticate check-in message
+ auth, err := e.GenAuthenticate()
+ if err != nil {
+ return err
+ }
+
+ // send it to the MDM server
+ authResp, err := e.transport.DoCheckIn(ctx, auth)
+ if err != nil {
+ return err
+ }
+ defer authResp.Body.Close()
+
+ // check for any errors
+ return HTTPErrors(authResp)
+}
+
+// DoAuthenticate sends an Authenticate check-in message to the MDM server.
+func (e *Enrollment) DoAuthenticate(ctx context.Context) error {
+ e.enrollM.Lock()
+ defer e.enrollM.Unlock()
+ return e.doAuthenticate(ctx)
+}
+
+// doTokenUpdate sends a TokenUpdate check-in message to the MDM server.
+// A new random push token is generated for the device.
+func (e *Enrollment) doTokenUpdate(ctx context.Context) error {
+ // generate new random push token.
+ // the token comes from Apple's APNs service. so we'll simulate this
+ // by re-generating the token every time we do a TokenUpdate.
+ e.push.Token = []byte(randString(32))
+
+ // generate TokenUpdate check-in message
+ msg, err := e.GenTokenUpdate()
+ if err != nil {
+ return err
+ }
+
+ // send it to the MDM server
+ resp, err := e.transport.DoCheckIn(ctx, msg)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ // check for errors
+ return HTTPErrors(resp)
+}
+
+// DoTokenUpdate sends a TokenUpdate check-in message to the MDM server.
+// A new random push token is generated for the device.
+func (e *Enrollment) DoTokenUpdate(ctx context.Context) error {
+ e.enrollM.Lock()
+ defer e.enrollM.Unlock()
+ return e.doTokenUpdate(ctx)
+}
+
+// DoEnroll enrolls (or re-enrolls) this enrollment into MDM.
+// Authenticate and TokenUpdate check-in messages are sent via the
+// transport to the MDM server.
+func (e *Enrollment) DoEnroll(ctx context.Context) error {
+ e.enrollM.Lock()
+ defer e.enrollM.Unlock()
+
+ err := e.doAuthenticate(ctx)
+ if err != nil {
+ return fmt.Errorf("authenticate check-in: %w", err)
+ }
+
+ err = e.doTokenUpdate(ctx)
+ if err != nil {
+ return fmt.Errorf("tokenupdate check-in: %w", err)
+ }
+
+ e.enrolled = true
+
+ return nil
+}
+
+// GetEnrollment returns the enrollment identifier data.
+func (e *Enrollment) GetEnrollment() *mdm.Enrollment {
+ return &e.enrollment
+}
+
+// ID returns the NanoMDM "normalized" enrollment ID.
+func (e *Enrollment) ID() string {
+ // we know we're only dealing with device IDs at this point.
+ // make that assumption of the UDID for the normalized ID.
+ return e.enrollment.UDID
+}
+
+// EnrollID returns the NanoMDM enroll ID.
+func (e *Enrollment) EnrollID() *mdm.EnrollID {
+ return &e.enrollID
+}
+
+func (e *Enrollment) NewMDMRequest(ctx context.Context) *mdm.Request {
+ return &mdm.Request{
+ Context: ctx,
+ EnrollID: e.EnrollID(),
+ Certificate: e.cert,
+ }
+}
+
+// GetPush returns the enrollment push info data.
+func (e *Enrollment) GetPush() *mdm.Push {
+ return &e.push
+}
+
+// DoReportAndFetch sends report to the MDM server.
+// Any new command delivered will be in the response.
+// The caller is responsible for closing the response body.
+func (e *Enrollment) DoReportAndFetch(ctx context.Context, report io.Reader) (*http.Response, error) {
+ return e.transport.DoReportResultsAndFetchNext(ctx, report)
+}
+
+// genSetBootstrapToken creates an XML Plist SetBootstrapToken check-in message.
+func (e *Enrollment) genSetBootstrapToken(token []byte) (io.Reader, error) {
+ b64Token := base64.StdEncoding.EncodeToString(token)
+ msg := &mdm.SetBootstrapToken{
+ Enrollment: e.enrollment,
+ MessageType: mdm.MessageType{MessageType: "SetBootstrapToken"},
+ BootstrapToken: mdm.BootstrapToken{BootstrapToken: []byte(b64Token)},
+ }
+ return test.PlistReader(msg)
+}
+
+// DoEscrowBootstrapToken sends the Bootstrap Token to the MDM server.
+func (e *Enrollment) DoEscrowBootstrapToken(ctx context.Context, token []byte) error {
+ r, err := e.genSetBootstrapToken(token)
+ if err != nil {
+ return err
+ }
+
+ // send it to the MDM server
+ resp, err := e.transport.DoCheckIn(ctx, r)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ // check for errors
+ return HTTPErrors(resp)
+}
+
+// genGetBootstrapToken creates an XML Plist GetBootstrapToken check-in message.
+func (e *Enrollment) genGetBootstrapToken() (io.Reader, error) {
+ msg := &mdm.GetBootstrapToken{
+ Enrollment: e.enrollment,
+ MessageType: mdm.MessageType{MessageType: "GetBootstrapToken"},
+ }
+ return test.PlistReader(msg)
+}
+
+// DoGetBootstrapToken retrieves the Bootstrap Token from the MDM erver.
+func (e *Enrollment) DoGetBootstrapToken(ctx context.Context) (*mdm.BootstrapToken, error) {
+ r, err := e.genGetBootstrapToken()
+ if err != nil {
+ return nil, err
+ }
+
+ // send it to the MDM server
+ resp, err := e.transport.DoCheckIn(ctx, r)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, Limit10KiB))
+ if err != nil {
+ return nil, err
+ }
+
+ if resp.StatusCode != 200 {
+ return nil, NewHTTPError(resp, body)
+ }
+
+ var tok *mdm.BootstrapToken
+ if len(body) > 0 {
+ tok = new(mdm.BootstrapToken)
+ err = plist.Unmarshal(body, tok)
+ }
+ return tok, err
+}
+
+func randString(n int) string {
+ b := make([]byte, n)
+ rand.Read(b) // nolint:errcheck
+ return fmt.Sprintf("%x", b)
+}
diff --git a/server/mdm/nanomdm/test/enrollment/utils.go b/server/mdm/nanomdm/test/enrollment/utils.go
new file mode 100644
index 0000000000..5b487cd488
--- /dev/null
+++ b/server/mdm/nanomdm/test/enrollment/utils.go
@@ -0,0 +1,66 @@
+package enrollment
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+)
+
+// HTTPError contains the body and status details.
+type HTTPError struct {
+ Body []byte
+ Status string
+ StatusCode int
+}
+
+func NewHTTPError(response *http.Response, body []byte) *HTTPError {
+ if response == nil {
+ response = &http.Response{}
+ }
+ return &HTTPError{
+ Body: body,
+ Status: response.Status,
+ StatusCode: response.StatusCode,
+ }
+}
+
+// Error returns strings for HTTP errors that may include body and status.
+func (e *HTTPError) Error() (err string) {
+ err = "HTTP error"
+ if e == nil {
+ return
+ }
+ if e.Status != "" {
+ err += ": " + e.Status
+ } else {
+ err += ": " + strconv.Itoa(e.StatusCode)
+ }
+ if len(e.Body) > 0 {
+ err += ": " + string(e.Body)
+ }
+ return
+}
+
+const Limit10KiB = 10 * 1024
+
+// HTTPErrors reports an HTTP error for a non-200 HTTP response.
+// The first 10KiB of the body is read for non-200 response.
+// For a 200 response nil is returned.
+// Caller is responsible for closing response body.
+func HTTPErrors(r *http.Response) error {
+ if r == nil {
+ return errors.New("nil response")
+ }
+
+ if r.StatusCode != 200 {
+ body, err := io.ReadAll(io.LimitReader(r.Body, Limit10KiB))
+ if err != nil {
+ return fmt.Errorf("error reading body of non-200 response: %w", err)
+ }
+ return NewHTTPError(r, body)
+ }
+
+ return nil
+}
diff --git a/server/mdm/nanomdm/service/certauth/helpers_test.go b/server/mdm/nanomdm/test/helpers.go
similarity index 94%
rename from server/mdm/nanomdm/service/certauth/helpers_test.go
rename to server/mdm/nanomdm/test/helpers.go
index 46e9a4c196..9be326a264 100644
--- a/server/mdm/nanomdm/service/certauth/helpers_test.go
+++ b/server/mdm/nanomdm/test/helpers.go
@@ -1,4 +1,4 @@
-package certauth
+package test
import (
"crypto/rand"
@@ -81,6 +81,10 @@ func (s *NopService) DeclarativeManagement(r *mdm.Request, m *mdm.DeclarativeMan
return nil, nil
}
+func (s *NopService) GetToken(r *mdm.Request, m *mdm.GetToken) (*mdm.GetTokenResponse, error) {
+ return nil, nil
+}
+
func (s *NopService) CommandAndReportResults(r *mdm.Request, results *mdm.CommandResults) (*mdm.Command, error) {
return nil, nil
}
diff --git a/server/mdm/nanomdm/test/plist.go b/server/mdm/nanomdm/test/plist.go
new file mode 100644
index 0000000000..8280f6a9c8
--- /dev/null
+++ b/server/mdm/nanomdm/test/plist.go
@@ -0,0 +1,16 @@
+package test
+
+import (
+ "bytes"
+ "io"
+
+ "github.com/groob/plist"
+)
+
+// PlistReader encodes v to XML Plist.
+func PlistReader(v interface{}) (io.Reader, error) {
+ buf := new(bytes.Buffer)
+ enc := plist.NewEncoder(buf)
+ enc.Indent("\t")
+ return buf, enc.Encode(v)
+}
diff --git a/server/mdm/nanomdm/test/protocol/transport.go b/server/mdm/nanomdm/test/protocol/transport.go
new file mode 100644
index 0000000000..91aa100766
--- /dev/null
+++ b/server/mdm/nanomdm/test/protocol/transport.go
@@ -0,0 +1,164 @@
+// Package protocol implements primitives and interfaces of the base Apple MDM protocol.
+package protocol
+
+import (
+ "bytes"
+ "context"
+ "crypto"
+ "crypto/x509"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/smallstep/pkcs7"
+)
+
+const (
+ // CheckInMIMEType is the HTTP MIME type of Apple MDM check-in messages.
+ CheckInMIMEType = "application/x-apple-aspen-mdm-checkin"
+
+ // MDMSignatureHeader is the HTTP header name for the in-message
+ // signature checking.
+ MDMSignatureHeader = "Mdm-Signature"
+)
+
+var (
+ ErrMissingDeviceIdentity = errors.New("missing device identity")
+ ErrNilTransport = errors.New("nil transport")
+)
+
+// Doer executes an HTTP request.
+type Doer interface {
+ Do(*http.Request) (*http.Response, error)
+}
+
+type IdentityProvider func(context.Context) (*x509.Certificate, crypto.PrivateKey, error)
+
+// Transport encapsulates the MDM enrollment underlying MDM transport.
+// The MDM channels utilize this transport to communicate with the host.
+type Transport struct {
+ checkInURL string
+ serverURL string
+ signMessage bool
+ provider IdentityProvider
+ doer Doer
+}
+
+type TransportOption func(*Transport)
+
+// WithClient configures the HTTP client for this transport.
+func WithClient(doer Doer) TransportOption {
+ return func(t *Transport) {
+ t.doer = doer
+ }
+}
+
+// WithIdentityProvider configures the certificate and private key provider for this transport.
+func WithIdentityProvider(f IdentityProvider) TransportOption {
+ return func(t *Transport) {
+ t.provider = f
+ }
+}
+
+// WithMDMURLs supplies the ServerURL and CheckInURLs to the transport.
+// Per MDM spec checkInURL is optional.
+func WithMDMURLs(serverURL, checkInURL string) TransportOption {
+ return func(t *Transport) {
+ t.serverURL = serverURL
+ t.checkInURL = checkInURL
+ }
+}
+
+// WithSignMessage include the signed message header.
+func WithSignMessage() TransportOption {
+ return func(t *Transport) {
+ t.signMessage = true
+ }
+}
+
+func NewTransport(opts ...TransportOption) *Transport {
+ t := &Transport{
+ doer: http.DefaultClient,
+ }
+ for _, opt := range opts {
+ opt(t)
+ }
+ return t
+}
+
+// SignMessage generates the CMS detached signature encoded as Base64.
+func (t *Transport) SignMessage(ctx context.Context, body []byte) (string, error) {
+ if t.provider == nil {
+ return "", ErrMissingDeviceIdentity
+ }
+ cert, key, err := t.provider(ctx)
+ if err != nil {
+ return "", err
+ }
+ if cert == nil || key == nil {
+ return "", ErrMissingDeviceIdentity
+ }
+ sd, err := pkcs7.NewSignedData(body)
+ if err != nil {
+ return "", err
+ }
+ err = sd.AddSigner(cert, key, pkcs7.SignerInfoConfig{})
+ if err != nil {
+ return "", err
+ }
+ sd.Detach()
+ sig, err := sd.Finish()
+ return base64.StdEncoding.EncodeToString(sig), err
+}
+
+func (t *Transport) doRequest(ctx context.Context, body io.Reader, checkin bool) (*http.Response, error) {
+ if t == nil {
+ return nil, ErrNilTransport
+ }
+ var bodyBuf *bytes.Buffer
+ if t.signMessage {
+ bodyBuf = new(bytes.Buffer)
+ if _, err := bodyBuf.ReadFrom(body); err != nil {
+ return nil, fmt.Errorf("reading body into buffer: %w", err)
+ }
+ body = bodyBuf
+ }
+
+ url := t.serverURL
+ if checkin && t.checkInURL != "" {
+ url = t.checkInURL
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, body)
+ if err != nil {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+
+ if checkin {
+ req.Header.Set("Content-Type", CheckInMIMEType)
+ }
+
+ if t.signMessage {
+ sig, err := t.SignMessage(ctx, bodyBuf.Bytes())
+ if err != nil {
+ return nil, fmt.Errorf("generating mdm-signature: %w", err)
+ }
+ req.Header.Set(MDMSignatureHeader, sig)
+ }
+
+ return t.doer.Do(req)
+}
+
+// DoCheckIn executes a check-in request with body.
+// The caller is responsible for closing the response body.
+func (t *Transport) DoCheckIn(ctx context.Context, body io.Reader) (*http.Response, error) {
+ return t.doRequest(ctx, body, true)
+}
+
+// DoReportResultsAndFetchNext executes a report and fetch request with body.
+// The caller is responsible for closing the response body.
+func (t *Transport) DoReportResultsAndFetchNext(ctx context.Context, body io.Reader) (*http.Response, error) {
+ return t.doRequest(ctx, body, false)
+}
diff --git a/server/mdm/nanomdm/tools/cmdr.py b/server/mdm/nanomdm/tools/cmdr.py
index 27fc549b22..2f9d264cee 100755
--- a/server/mdm/nanomdm/tools/cmdr.py
+++ b/server/mdm/nanomdm/tools/cmdr.py
@@ -127,7 +127,9 @@ def sched_update_subparser(parser):
def dev_info_subparser(parser):
dev_info_parser = parser.add_parser(
- "DeviceInformation", help="DeviceInformation MDM command"
+ "DeviceInformation",
+ help="DeviceInformation MDM command",
+ aliases=["DeviceInfo", "DevInfo"],
)
dev_info_parser.add_argument(
"query",
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index ad49d7a6aa..6d8fbd8856 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -639,12 +639,24 @@ type SetOrUpdateHostDisksEncryptionFunc func(ctx context.Context, hostID uint, e
type SetOrUpdateHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint, encryptedBase64Key string, clientError string, decryptable *bool) error
+type SaveLUKSDataFunc func(ctx context.Context, hostID uint, encryptedBase64Passphrase string, encryptedBase64Salt string, keySlot uint) error
+
type GetUnverifiedDiskEncryptionKeysFunc func(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error)
-type SetHostsDiskEncryptionKeyStatusFunc func(ctx context.Context, hostIDs []uint, encryptable bool, threshold time.Time) error
+type SetHostsDiskEncryptionKeyStatusFunc func(ctx context.Context, hostIDs []uint, decryptable bool, threshold time.Time) error
type GetHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error)
+type IsHostPendingEscrowFunc func(ctx context.Context, hostID uint) bool
+
+type ClearPendingEscrowFunc func(ctx context.Context, hostID uint) error
+
+type ReportEscrowErrorFunc func(ctx context.Context, hostID uint, err string) error
+
+type QueueEscrowFunc func(ctx context.Context, hostID uint) error
+
+type AssertHasNoEncryptionKeyStoredFunc func(ctx context.Context, hostID uint) error
+
type GetHostCertAssociationsToExpireFunc func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error)
type SetCommandForPendingSCEPRenewalFunc func(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error
@@ -975,6 +987,8 @@ type ResendHostMDMProfileFunc func(ctx context.Context, hostUUID string, profile
type GetHostMDMProfileInstallStatusFunc func(ctx context.Context, hostUUID string, profileUUID string) (fleet.MDMDeliveryStatus, error)
+type GetLinuxDiskEncryptionSummaryFunc func(ctx context.Context, teamID *uint) (fleet.MDMLinuxDiskEncryptionSummary, error)
+
type GetMDMCommandPlatformFunc func(ctx context.Context, commandUUID string) (string, error)
type ListMDMCommandsFunc func(ctx context.Context, tmFilter fleet.TeamFilter, listOpts *fleet.MDMCommandListOptions) ([]*fleet.MDMCommand, error)
@@ -1127,6 +1141,8 @@ type EnqueueSetupExperienceItemsFunc func(ctx context.Context, hostUUID string,
type GetSetupExperienceScriptFunc func(ctx context.Context, teamID *uint) (*fleet.Script, error)
+type GetSetupExperienceScriptByIDFunc func(ctx context.Context, scriptID uint) (*fleet.Script, error)
+
type SetSetupExperienceScriptFunc func(ctx context.Context, script *fleet.Script) error
type DeleteSetupExperienceScriptFunc func(ctx context.Context, teamID *uint) error
@@ -2077,6 +2093,9 @@ type DataStore struct {
SetOrUpdateHostDiskEncryptionKeyFunc SetOrUpdateHostDiskEncryptionKeyFunc
SetOrUpdateHostDiskEncryptionKeyFuncInvoked bool
+ SaveLUKSDataFunc SaveLUKSDataFunc
+ SaveLUKSDataFuncInvoked bool
+
GetUnverifiedDiskEncryptionKeysFunc GetUnverifiedDiskEncryptionKeysFunc
GetUnverifiedDiskEncryptionKeysFuncInvoked bool
@@ -2086,6 +2105,21 @@ type DataStore struct {
GetHostDiskEncryptionKeyFunc GetHostDiskEncryptionKeyFunc
GetHostDiskEncryptionKeyFuncInvoked bool
+ IsHostPendingEscrowFunc IsHostPendingEscrowFunc
+ IsHostPendingEscrowFuncInvoked bool
+
+ ClearPendingEscrowFunc ClearPendingEscrowFunc
+ ClearPendingEscrowFuncInvoked bool
+
+ ReportEscrowErrorFunc ReportEscrowErrorFunc
+ ReportEscrowErrorFuncInvoked bool
+
+ QueueEscrowFunc QueueEscrowFunc
+ QueueEscrowFuncInvoked bool
+
+ AssertHasNoEncryptionKeyStoredFunc AssertHasNoEncryptionKeyStoredFunc
+ AssertHasNoEncryptionKeyStoredFuncInvoked bool
+
GetHostCertAssociationsToExpireFunc GetHostCertAssociationsToExpireFunc
GetHostCertAssociationsToExpireFuncInvoked bool
@@ -2581,6 +2615,9 @@ type DataStore struct {
GetHostMDMProfileInstallStatusFunc GetHostMDMProfileInstallStatusFunc
GetHostMDMProfileInstallStatusFuncInvoked bool
+ GetLinuxDiskEncryptionSummaryFunc GetLinuxDiskEncryptionSummaryFunc
+ GetLinuxDiskEncryptionSummaryFuncInvoked bool
+
GetMDMCommandPlatformFunc GetMDMCommandPlatformFunc
GetMDMCommandPlatformFuncInvoked bool
@@ -2809,6 +2846,9 @@ type DataStore struct {
GetSetupExperienceScriptFunc GetSetupExperienceScriptFunc
GetSetupExperienceScriptFuncInvoked bool
+ GetSetupExperienceScriptByIDFunc GetSetupExperienceScriptByIDFunc
+ GetSetupExperienceScriptByIDFuncInvoked bool
+
SetSetupExperienceScriptFunc SetSetupExperienceScriptFunc
SetSetupExperienceScriptFuncInvoked bool
@@ -5008,6 +5048,13 @@ func (s *DataStore) SetOrUpdateHostDiskEncryptionKey(ctx context.Context, hostID
return s.SetOrUpdateHostDiskEncryptionKeyFunc(ctx, hostID, encryptedBase64Key, clientError, decryptable)
}
+func (s *DataStore) SaveLUKSData(ctx context.Context, hostID uint, encryptedBase64Passphrase string, encryptedBase64Salt string, keySlot uint) error {
+ s.mu.Lock()
+ s.SaveLUKSDataFuncInvoked = true
+ s.mu.Unlock()
+ return s.SaveLUKSDataFunc(ctx, hostID, encryptedBase64Passphrase, encryptedBase64Salt, keySlot)
+}
+
func (s *DataStore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]fleet.HostDiskEncryptionKey, error) {
s.mu.Lock()
s.GetUnverifiedDiskEncryptionKeysFuncInvoked = true
@@ -5015,11 +5062,11 @@ func (s *DataStore) GetUnverifiedDiskEncryptionKeys(ctx context.Context) ([]flee
return s.GetUnverifiedDiskEncryptionKeysFunc(ctx)
}
-func (s *DataStore) SetHostsDiskEncryptionKeyStatus(ctx context.Context, hostIDs []uint, encryptable bool, threshold time.Time) error {
+func (s *DataStore) SetHostsDiskEncryptionKeyStatus(ctx context.Context, hostIDs []uint, decryptable bool, threshold time.Time) error {
s.mu.Lock()
s.SetHostsDiskEncryptionKeyStatusFuncInvoked = true
s.mu.Unlock()
- return s.SetHostsDiskEncryptionKeyStatusFunc(ctx, hostIDs, encryptable, threshold)
+ return s.SetHostsDiskEncryptionKeyStatusFunc(ctx, hostIDs, decryptable, threshold)
}
func (s *DataStore) GetHostDiskEncryptionKey(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) {
@@ -5029,6 +5076,41 @@ func (s *DataStore) GetHostDiskEncryptionKey(ctx context.Context, hostID uint) (
return s.GetHostDiskEncryptionKeyFunc(ctx, hostID)
}
+func (s *DataStore) IsHostPendingEscrow(ctx context.Context, hostID uint) bool {
+ s.mu.Lock()
+ s.IsHostPendingEscrowFuncInvoked = true
+ s.mu.Unlock()
+ return s.IsHostPendingEscrowFunc(ctx, hostID)
+}
+
+func (s *DataStore) ClearPendingEscrow(ctx context.Context, hostID uint) error {
+ s.mu.Lock()
+ s.ClearPendingEscrowFuncInvoked = true
+ s.mu.Unlock()
+ return s.ClearPendingEscrowFunc(ctx, hostID)
+}
+
+func (s *DataStore) ReportEscrowError(ctx context.Context, hostID uint, err string) error {
+ s.mu.Lock()
+ s.ReportEscrowErrorFuncInvoked = true
+ s.mu.Unlock()
+ return s.ReportEscrowErrorFunc(ctx, hostID, err)
+}
+
+func (s *DataStore) QueueEscrow(ctx context.Context, hostID uint) error {
+ s.mu.Lock()
+ s.QueueEscrowFuncInvoked = true
+ s.mu.Unlock()
+ return s.QueueEscrowFunc(ctx, hostID)
+}
+
+func (s *DataStore) AssertHasNoEncryptionKeyStored(ctx context.Context, hostID uint) error {
+ s.mu.Lock()
+ s.AssertHasNoEncryptionKeyStoredFuncInvoked = true
+ s.mu.Unlock()
+ return s.AssertHasNoEncryptionKeyStoredFunc(ctx, hostID)
+}
+
func (s *DataStore) GetHostCertAssociationsToExpire(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
s.mu.Lock()
s.GetHostCertAssociationsToExpireFuncInvoked = true
@@ -6184,6 +6266,13 @@ func (s *DataStore) GetHostMDMProfileInstallStatus(ctx context.Context, hostUUID
return s.GetHostMDMProfileInstallStatusFunc(ctx, hostUUID, profileUUID)
}
+func (s *DataStore) GetLinuxDiskEncryptionSummary(ctx context.Context, teamID *uint) (fleet.MDMLinuxDiskEncryptionSummary, error) {
+ s.mu.Lock()
+ s.GetLinuxDiskEncryptionSummaryFuncInvoked = true
+ s.mu.Unlock()
+ return s.GetLinuxDiskEncryptionSummaryFunc(ctx, teamID)
+}
+
func (s *DataStore) GetMDMCommandPlatform(ctx context.Context, commandUUID string) (string, error) {
s.mu.Lock()
s.GetMDMCommandPlatformFuncInvoked = true
@@ -6716,6 +6805,13 @@ func (s *DataStore) GetSetupExperienceScript(ctx context.Context, teamID *uint)
return s.GetSetupExperienceScriptFunc(ctx, teamID)
}
+func (s *DataStore) GetSetupExperienceScriptByID(ctx context.Context, scriptID uint) (*fleet.Script, error) {
+ s.mu.Lock()
+ s.GetSetupExperienceScriptByIDFuncInvoked = true
+ s.mu.Unlock()
+ return s.GetSetupExperienceScriptByIDFunc(ctx, scriptID)
+}
+
func (s *DataStore) SetSetupExperienceScript(ctx context.Context, script *fleet.Script) error {
s.mu.Lock()
s.SetSetupExperienceScriptFuncInvoked = true
diff --git a/server/mock/mdm/datastore_mdm_mock.go b/server/mock/mdm/datastore_mdm_mock.go
index 5e3f19e6cb..9ad5b58d3d 100644
--- a/server/mock/mdm/datastore_mdm_mock.go
+++ b/server/mock/mdm/datastore_mdm_mock.go
@@ -19,10 +19,10 @@ type StoreAuthenticateFunc func(r *mdm.Request, msg *mdm.Authenticate) error
type StoreTokenUpdateFunc func(r *mdm.Request, msg *mdm.TokenUpdate) error
-type StoreUserAuthenticateFunc func(r *mdm.Request, msg *mdm.UserAuthenticate) error
-
type DisableFunc func(r *mdm.Request) error
+type StoreUserAuthenticateFunc func(r *mdm.Request, msg *mdm.UserAuthenticate) error
+
type StoreCommandReportFunc func(r *mdm.Request, report *mdm.CommandResults) error
type RetrieveNextCommandFunc func(r *mdm.Request, skipNotNow bool) (*mdm.Command, error)
@@ -33,7 +33,7 @@ type StoreBootstrapTokenFunc func(r *mdm.Request, msg *mdm.SetBootstrapToken) er
type RetrieveBootstrapTokenFunc func(r *mdm.Request, msg *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error)
-type RetrievePushInfoFunc func(p0 context.Context, p1 []string) (map[string]*mdm.Push, error)
+type RetrievePushInfoFunc func(ctx context.Context, ids []string) (map[string]*mdm.Push, error)
type IsPushCertStaleFunc func(ctx context.Context, topic string, staleToken string) (bool, error)
@@ -51,6 +51,8 @@ type IsCertHashAssociatedFunc func(r *mdm.Request, hash string) (bool, error)
type AssociateCertHashFunc func(r *mdm.Request, hash string, certNotValidAfter time.Time) error
+type EnrollmentFromHashFunc func(ctx context.Context, hash string) (string, error)
+
type RetrieveMigrationCheckinsFunc func(p0 context.Context, p1 chan<- interface{}) error
type RetrieveTokenUpdateTallyFunc func(ctx context.Context, id string) (int, error)
@@ -70,12 +72,12 @@ type MDMAppleStore struct {
StoreTokenUpdateFunc StoreTokenUpdateFunc
StoreTokenUpdateFuncInvoked bool
- StoreUserAuthenticateFunc StoreUserAuthenticateFunc
- StoreUserAuthenticateFuncInvoked bool
-
DisableFunc DisableFunc
DisableFuncInvoked bool
+ StoreUserAuthenticateFunc StoreUserAuthenticateFunc
+ StoreUserAuthenticateFuncInvoked bool
+
StoreCommandReportFunc StoreCommandReportFunc
StoreCommandReportFuncInvoked bool
@@ -118,6 +120,9 @@ type MDMAppleStore struct {
AssociateCertHashFunc AssociateCertHashFunc
AssociateCertHashFuncInvoked bool
+ EnrollmentFromHashFunc EnrollmentFromHashFunc
+ EnrollmentFromHashFuncInvoked bool
+
RetrieveMigrationCheckinsFunc RetrieveMigrationCheckinsFunc
RetrieveMigrationCheckinsFuncInvoked bool
@@ -153,13 +158,6 @@ func (fs *MDMAppleStore) StoreTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate)
return fs.StoreTokenUpdateFunc(r, msg)
}
-func (fs *MDMAppleStore) StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthenticate) error {
- fs.mu.Lock()
- fs.StoreUserAuthenticateFuncInvoked = true
- fs.mu.Unlock()
- return fs.StoreUserAuthenticateFunc(r, msg)
-}
-
func (fs *MDMAppleStore) Disable(r *mdm.Request) error {
fs.mu.Lock()
fs.DisableFuncInvoked = true
@@ -167,6 +165,13 @@ func (fs *MDMAppleStore) Disable(r *mdm.Request) error {
return fs.DisableFunc(r)
}
+func (fs *MDMAppleStore) StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthenticate) error {
+ fs.mu.Lock()
+ fs.StoreUserAuthenticateFuncInvoked = true
+ fs.mu.Unlock()
+ return fs.StoreUserAuthenticateFunc(r, msg)
+}
+
func (fs *MDMAppleStore) StoreCommandReport(r *mdm.Request, report *mdm.CommandResults) error {
fs.mu.Lock()
fs.StoreCommandReportFuncInvoked = true
@@ -202,11 +207,11 @@ func (fs *MDMAppleStore) RetrieveBootstrapToken(r *mdm.Request, msg *mdm.GetBoot
return fs.RetrieveBootstrapTokenFunc(r, msg)
}
-func (fs *MDMAppleStore) RetrievePushInfo(p0 context.Context, p1 []string) (map[string]*mdm.Push, error) {
+func (fs *MDMAppleStore) RetrievePushInfo(ctx context.Context, ids []string) (map[string]*mdm.Push, error) {
fs.mu.Lock()
fs.RetrievePushInfoFuncInvoked = true
fs.mu.Unlock()
- return fs.RetrievePushInfoFunc(p0, p1)
+ return fs.RetrievePushInfoFunc(ctx, ids)
}
func (fs *MDMAppleStore) IsPushCertStale(ctx context.Context, topic string, staleToken string) (bool, error) {
@@ -265,6 +270,13 @@ func (fs *MDMAppleStore) AssociateCertHash(r *mdm.Request, hash string, certNotV
return fs.AssociateCertHashFunc(r, hash, certNotValidAfter)
}
+func (fs *MDMAppleStore) EnrollmentFromHash(ctx context.Context, hash string) (string, error) {
+ fs.mu.Lock()
+ fs.EnrollmentFromHashFuncInvoked = true
+ fs.mu.Unlock()
+ return fs.EnrollmentFromHashFunc(ctx, hash)
+}
+
func (fs *MDMAppleStore) RetrieveMigrationCheckins(p0 context.Context, p1 chan<- interface{}) error {
fs.mu.Lock()
fs.RetrieveMigrationCheckinsFuncInvoked = true
diff --git a/server/service/appconfig.go b/server/service/appconfig.go
index 5618509012..ad8778b570 100644
--- a/server/service/appconfig.go
+++ b/server/service/appconfig.go
@@ -416,6 +416,9 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
// 1. To get the JSON value from the database
// 2. To update fields with the incoming values
if newAppConfig.MDM.EnableDiskEncryption.Valid {
+ if svc.config.Server.PrivateKey == "" {
+ return nil, ctxerr.New(ctx, "Missing required private key. Learn how to configure the private key here: https://fleetdm.com/learn-more-about/fleet-server-private-key")
+ }
appConfig.MDM.EnableDiskEncryption = newAppConfig.MDM.EnableDiskEncryption
} else if appConfig.MDM.EnableDiskEncryption.Set && !appConfig.MDM.EnableDiskEncryption.Valid {
appConfig.MDM.EnableDiskEncryption = oldAppConfig.MDM.EnableDiskEncryption
@@ -1130,15 +1133,6 @@ func (svc *Service) validateMDM(
return nil
}
}
-
- // if either macOS or Windows MDM is enabled, this setting can be set.
- if !mdm.AtLeastOnePlatformEnabledAndConfigured() {
- if mdm.EnableDiskEncryption.Valid && mdm.EnableDiskEncryption.Value && mdm.EnableDiskEncryption.Value != oldMdm.EnableDiskEncryption.Value {
- invalid.Append("mdm.enable_disk_encryption",
- `Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.`)
- }
- }
-
return nil
}
diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go
index 1e601997c4..734ef43f1c 100644
--- a/server/service/appconfig_test.go
+++ b/server/service/appconfig_test.go
@@ -1208,6 +1208,59 @@ func TestMDMAppleConfig(t *testing.T) {
}
}
+func TestDiskEncryptionSetting(t *testing.T) {
+ ds := new(mock.Store)
+
+ admin := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}
+ t.Run("enableDiskEncryptionWithNoPrivateKey", func(t *testing.T) {
+ testConfig = config.TestConfig()
+ testConfig.Server.PrivateKey = ""
+ svc, ctx := newTestServiceWithConfig(t, ds, testConfig, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}})
+ ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin})
+
+ dsAppConfig := &fleet.AppConfig{
+ OrgInfo: fleet.OrgInfo{OrgName: "Test"},
+ ServerSettings: fleet.ServerSettings{ServerURL: "https://example.org"},
+ MDM: fleet.MDM{},
+ }
+
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return dsAppConfig, nil
+ }
+
+ ds.SaveAppConfigFunc = func(ctx context.Context, conf *fleet.AppConfig) error {
+ *dsAppConfig = *conf
+ return nil
+ }
+ ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
+ return nil, sql.ErrNoRows
+ }
+ ds.NewMDMAppleEnrollmentProfileFunc = func(ctx context.Context, enrollmentPayload fleet.MDMAppleEnrollmentProfilePayload) (*fleet.MDMAppleEnrollmentProfile, error) {
+ return &fleet.MDMAppleEnrollmentProfile{}, nil
+ }
+ ds.GetMDMAppleEnrollmentProfileByTypeFunc = func(ctx context.Context, typ fleet.MDMAppleEnrollmentType) (*fleet.MDMAppleEnrollmentProfile, error) {
+ raw := json.RawMessage("{}")
+ return &fleet.MDMAppleEnrollmentProfile{DEPProfile: &raw}, nil
+ }
+ ds.NewJobFunc = func(ctx context.Context, job *fleet.Job) (*fleet.Job, error) {
+ return job, nil
+ }
+
+ ac, err := svc.AppConfigObfuscated(ctx)
+ require.NoError(t, err)
+ require.Equal(t, dsAppConfig.MDM, ac.MDM)
+
+ raw, err := json.Marshal(fleet.MDM{
+ EnableDiskEncryption: optjson.SetBool(true),
+ })
+ require.NoError(t, err)
+ raw = []byte(`{"mdm":` + string(raw) + `}`)
+ _, err = svc.ModifyAppConfig(ctx, raw, fleet.ApplySpecOptions{})
+ require.Error(t, err)
+ require.ErrorContains(t, err, "Missing required private key")
+ })
+}
+
func TestModifyAppConfigSMTPSSOAgentOptions(t *testing.T) {
ds := new(mock.Store)
svc, ctx := newTestService(t, ds, nil, nil)
diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go
index 0e0255a097..2c0f435e55 100644
--- a/server/service/apple_mdm.go
+++ b/server/service/apple_mdm.go
@@ -2845,6 +2845,15 @@ func (svc *MDMAppleCheckinAndCommandService) DeclarativeManagement(r *mdm.Reques
return nil, nil
}
+// GetToken handles MDM [GetToken][1] requests.
+//
+// This method is executed after the request has been handled by nanomdm.
+//
+// [1]: https://developer.apple.com/documentation/devicemanagement/get_token
+func (svc *MDMAppleCheckinAndCommandService) GetToken(_ *mdm.Request, _ *mdm.GetToken) (*mdm.GetTokenResponse, error) {
+ return nil, nil
+}
+
// CommandAndReportResults handles MDM [Commands and Queries][1].
//
// This method is executed after the request has been handled by nanomdm.
@@ -4745,7 +4754,7 @@ func (svc *Service) MDMAppleProcessOTAEnrollment(
// otherwise we might be in the second phase, check if the signing cert
// was issued by Fleet, only let the enrollment through if so.
certVerifier := mdmcrypto.NewSCEPVerifier(svc.ds)
- if err := certVerifier.Verify(rootSigner); err != nil {
+ if err := certVerifier.Verify(ctx, rootSigner); err != nil {
return nil, authz.ForbiddenWithInternal(fmt.Sprintf("payload signed with invalid certificate: %s", err), nil, nil, nil)
}
diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go
index 60ae723f9a..247eababf1 100644
--- a/server/service/apple_mdm_test.go
+++ b/server/service/apple_mdm_test.go
@@ -39,7 +39,6 @@ import (
mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
- "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/stdlogfmt"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
"github.com/fleetdm/fleet/v4/server/mock"
@@ -52,6 +51,7 @@ import (
"github.com/groob/plist"
"github.com/jmoiron/sqlx"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
+ "github.com/micromdm/nanolib/log/stdlogfmt"
"github.com/smallstep/pkcs7"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -1523,7 +1523,6 @@ func TestMDMCommandAndReportResultsProfileHandling(t *testing.T) {
Enrollment: mdm.Enrollment{UDID: hostUUID},
CommandUUID: commandUUID,
Status: c.status,
- RequestType: c.requestType,
ErrorChain: c.errors,
},
)
@@ -1939,7 +1938,7 @@ func TestUpdateMDMAppleSettings(t *testing.T) {
&fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
false,
nil,
- ErrMissingLicense.Error(),
+ fleet.ErrMissingLicense.Error(),
},
{
"global admin premium",
@@ -1960,7 +1959,7 @@ func TestUpdateMDMAppleSettings(t *testing.T) {
&fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)},
false,
nil,
- ErrMissingLicense.Error(),
+ fleet.ErrMissingLicense.Error(),
},
{
"global maintainer premium",
@@ -2037,7 +2036,7 @@ func TestUpdateMDMAppleSettings(t *testing.T) {
&fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)},
false,
ptr.Uint(1),
- ErrMissingLicense.Error(),
+ fleet.ErrMissingLicense.Error(),
},
}
diff --git a/server/service/campaigns.go b/server/service/campaigns.go
index 053b3cf256..e02406fe04 100644
--- a/server/service/campaigns.go
+++ b/server/service/campaigns.go
@@ -206,11 +206,23 @@ func (svc *Service) NewDistributedQueryCampaignByIdentifiers(ctx context.Context
return nil, ctxerr.Wrap(ctx, err, "finding host IDs")
}
+ if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionRead); err != nil {
+ return nil, err
+ }
labelMap, err := svc.ds.LabelIDsByName(ctx, labels)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "finding label IDs")
}
+ // DetectMissingLabels will return the list of labels that are not found in the database
+ // These labels are considered invalid
+ invalidLabels := fleet.DetectMissingLabels(labelMap, labels)
+ if len(invalidLabels) > 0 {
+ return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
+ Message: fmt.Sprintf("%s %s.", fleet.InvalidLabelSpecifiedErrMsg, strings.Join(invalidLabels, ", ")),
+ }, "invalid labels")
+ }
+
var labelIDs []uint
for _, labelID := range labelMap {
labelIDs = append(labelIDs, labelID)
diff --git a/server/service/campaigns_test.go b/server/service/campaigns_test.go
index 28c51ad863..91206ce990 100644
--- a/server/service/campaigns_test.go
+++ b/server/service/campaigns_test.go
@@ -10,6 +10,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/pubsub"
+ "github.com/stretchr/testify/require"
)
type nopLiveQuery struct{}
@@ -237,3 +238,82 @@ func TestLiveQueryAuth(t *testing.T) {
})
}
}
+
+func TestLiveQueryLabelValidation(t *testing.T) {
+ ds := new(mock.Store)
+ qr := pubsub.NewInmemQueryResults()
+ svc, ctx := newTestService(t, ds, qr, nopLiveQuery{})
+
+ user := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}
+ query := &fleet.Query{
+ ID: 1,
+ Name: "q1",
+ Query: "SELECT 1",
+ ObserverCanRun: true,
+ }
+ ds.NewQueryFunc = func(ctx context.Context, query *fleet.Query, opts ...fleet.OptionalArg) (*fleet.Query, error) {
+ query.ID = 123
+ return query, nil
+ }
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{ServerSettings: fleet.ServerSettings{LiveQueryDisabled: false}}, nil
+ }
+ ds.NewDistributedQueryCampaignFunc = func(ctx context.Context, camp *fleet.DistributedQueryCampaign) (*fleet.DistributedQueryCampaign, error) {
+ return camp, nil
+ }
+ ds.NewDistributedQueryCampaignTargetFunc = func(ctx context.Context, target *fleet.DistributedQueryCampaignTarget) (*fleet.DistributedQueryCampaignTarget, error) {
+ return target, nil
+ }
+ ds.HostIDsInTargetsFunc = func(ctx context.Context, filters fleet.TeamFilter, targets fleet.HostTargets) ([]uint, error) {
+ return []uint{1}, nil
+ }
+ ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, identifiers []string) ([]uint, error) {
+ return nil, nil
+ }
+ ds.CountHostsInTargetsFunc = func(ctx context.Context, filters fleet.TeamFilter, targets fleet.HostTargets, now time.Time) (fleet.TargetMetrics, error) {
+ return fleet.TargetMetrics{}, nil
+ }
+ ds.QueryFunc = func(ctx context.Context, id uint) (*fleet.Query, error) {
+ return query, nil
+ }
+
+ ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) {
+ return map[string]uint{"label1": uint(1)}, nil
+ }
+
+ testCases := []struct {
+ name string
+ labels []string
+ expectedError string
+ }{
+ {
+ name: "no labels",
+ labels: []string{},
+ expectedError: "",
+ },
+ {
+ name: "invalid label",
+ labels: []string{"iamnotalabel"},
+ expectedError: "Invalid label name(s): iamnotalabel.",
+ },
+ {
+ name: "valid label",
+ labels: []string{"label1"},
+ expectedError: "",
+ },
+ }
+
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := viewer.NewContext(ctx, viewer.Viewer{User: user})
+ _, err := svc.NewDistributedQueryCampaignByIdentifiers(ctx, query.Query, nil, nil, tt.labels)
+
+ if tt.expectedError == "" {
+ require.Nil(t, err)
+ } else {
+ require.NotNil(t, err)
+ require.Contains(t, err.Error(), tt.expectedError)
+ }
+ })
+ }
+}
diff --git a/server/service/client_labels.go b/server/service/client_labels.go
index 168dfdd6f7..e9dc69f8a1 100644
--- a/server/service/client_labels.go
+++ b/server/service/client_labels.go
@@ -23,7 +23,7 @@ func (c *Client) GetLabel(name string) (*fleet.LabelSpec, error) {
return responseBody.Spec, err
}
-// GetLabels retrieves the list of all Labels.
+// GetLabels retrieves the list of all LabelSpecs.
func (c *Client) GetLabels() ([]*fleet.LabelSpec, error) {
verb, path := "GET", "/api/latest/fleet/spec/labels"
var responseBody getLabelSpecsResponse
diff --git a/server/service/devices.go b/server/service/devices.go
index 3ae57851b1..187e168bf4 100644
--- a/server/service/devices.go
+++ b/server/service/devices.go
@@ -609,6 +609,43 @@ func (svc *Service) TriggerMigrateMDMDevice(ctx context.Context, host *fleet.Hos
return fleet.ErrMissingLicense
}
+////////////////////////////////////////////////////////////////////////////////
+// Trigger linux key escrow
+////////////////////////////////////////////////////////////////////////////////
+
+type triggerLinuxDiskEncryptionEscrowRequest struct {
+ Token string `url:"token"`
+}
+
+func (r *triggerLinuxDiskEncryptionEscrowRequest) deviceAuthToken() string {
+ return r.Token
+}
+
+type triggerLinuxDiskEncryptionEscrowResponse struct {
+ Err error `json:"error,omitempty"`
+}
+
+func (r triggerLinuxDiskEncryptionEscrowResponse) error() error { return r.Err }
+
+func (r triggerLinuxDiskEncryptionEscrowResponse) Status() int { return http.StatusNoContent }
+
+func triggerLinuxDiskEncryptionEscrowEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ host, ok := hostctx.FromContext(ctx)
+ if !ok {
+ err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
+ return triggerLinuxDiskEncryptionEscrowResponse{Err: err}, nil
+ }
+
+ if err := svc.TriggerLinuxDiskEncryptionEscrow(ctx, host); err != nil {
+ return triggerLinuxDiskEncryptionEscrowResponse{Err: err}, nil
+ }
+ return triggerLinuxDiskEncryptionEscrowResponse{}, nil
+}
+
+func (svc *Service) TriggerLinuxDiskEncryptionEscrow(ctx context.Context, host *fleet.Host) error {
+ return fleet.ErrMissingLicense
+}
+
////////////////////////////////////////////////////////////////////////////////
// Get Current Device's Software
////////////////////////////////////////////////////////////////////////////////
diff --git a/server/service/devices_test.go b/server/service/devices_test.go
index 1100683be4..53d9644931 100644
--- a/server/service/devices_test.go
+++ b/server/service/devices_test.go
@@ -3,10 +3,12 @@ package service
import (
"context"
"database/sql"
+ "errors"
"fmt"
"testing"
"time"
+ "github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
@@ -475,3 +477,116 @@ func TestGetFleetDesktopSummary(t *testing.T) {
})
}
+
+func TestTriggerLinuxDiskEncryptionEscrow(t *testing.T) {
+ t.Run("unavailable in Fleet Free", func(t *testing.T) {
+ ds := new(mock.Store)
+ svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{SkipCreateTestUsers: true})
+ err := svc.TriggerLinuxDiskEncryptionEscrow(ctx, &fleet.Host{ID: 1})
+ require.ErrorIs(t, err, fleet.ErrMissingLicense)
+ })
+
+ t.Run("no-op on already pending", func(t *testing.T) {
+ ds := new(mock.Store)
+ svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, SkipCreateTestUsers: true})
+ ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
+ return true
+ }
+
+ err := svc.TriggerLinuxDiskEncryptionEscrow(ctx, &fleet.Host{ID: 1})
+ require.NoError(t, err)
+ require.True(t, ds.IsHostPendingEscrowFuncInvoked)
+ })
+
+ t.Run("validation failures", func(t *testing.T) {
+ ds := new(mock.Store)
+ svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, SkipCreateTestUsers: true})
+ ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
+ return false
+ }
+ var reportedErrors []string
+ host := &fleet.Host{ID: 1, Platform: "rhel", OSVersion: "Red Hat Enterprise Linux 9.0.0"}
+ ds.ReportEscrowErrorFunc = func(ctx context.Context, hostID uint, err string) error {
+ require.Equal(t, hostID, host.ID)
+ reportedErrors = append(reportedErrors, err)
+ return nil
+ }
+
+ // invalid platform
+ err := svc.TriggerLinuxDiskEncryptionEscrow(ctx, host)
+ require.ErrorContains(t, err, "Fleet does not yet support creating LUKS disk encryption keys on this platform.")
+ require.True(t, ds.IsHostPendingEscrowFuncInvoked)
+
+ // valid platform, no-team, encryption not enabled
+ host.OSVersion = "Fedora 32.0.0"
+ appConfig := &fleet.AppConfig{MDM: fleet.MDM{EnableDiskEncryption: optjson.SetBool(false)}}
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return appConfig, nil
+ }
+ err = svc.TriggerLinuxDiskEncryptionEscrow(ctx, host)
+ require.ErrorContains(t, err, "Disk encryption is not enabled for hosts not assigned to a team.")
+
+ // valid platform, team, encryption not enabled
+ host.TeamID = ptr.Uint(1)
+ teamConfig := &fleet.TeamMDM{}
+ ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
+ require.Equal(t, uint(1), teamID)
+ return teamConfig, nil
+ }
+ err = svc.TriggerLinuxDiskEncryptionEscrow(ctx, host)
+ require.ErrorContains(t, err, "Disk encryption is not enabled for this host's team.")
+
+ // valid platform, team, host disk is not encrypted or unknown encryption state
+ teamConfig = &fleet.TeamMDM{EnableDiskEncryption: true}
+ err = svc.TriggerLinuxDiskEncryptionEscrow(ctx, host)
+ require.ErrorContains(t, err, "Host's disk is not encrypted. Please encrypt your disk first.")
+ host.DiskEncryptionEnabled = ptr.Bool(false)
+ err = svc.TriggerLinuxDiskEncryptionEscrow(ctx, host)
+ require.ErrorContains(t, err, "Host's disk is not encrypted. Please encrypt your disk first.")
+
+ // No Fleet Desktop
+ host.DiskEncryptionEnabled = ptr.Bool(true)
+ orbitInfo := &fleet.HostOrbitInfo{Version: "1.35.1"}
+ ds.GetHostOrbitInfoFunc = func(ctx context.Context, id uint) (*fleet.HostOrbitInfo, error) {
+ return orbitInfo, nil
+ }
+ err = svc.TriggerLinuxDiskEncryptionEscrow(ctx, host)
+ require.ErrorContains(t, err, "Your version of fleetd does not support creating disk encryption keys on Linux. Please upgrade fleetd, then click Refetch, then try again.")
+
+ // Encryption key is already escrowed
+ orbitInfo.Version = fleet.MinOrbitLUKSVersion
+ ds.AssertHasNoEncryptionKeyStoredFunc = func(ctx context.Context, hostID uint) error {
+ return errors.New("encryption key is already escrowed")
+ }
+ err = svc.TriggerLinuxDiskEncryptionEscrow(ctx, host)
+ require.ErrorContains(t, err, "encryption key is already escrowed")
+
+ require.Len(t, reportedErrors, 7)
+ })
+
+ t.Run("validation success", func(t *testing.T) {
+ ds := new(mock.Store)
+ svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, SkipCreateTestUsers: true})
+ ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
+ return false
+ }
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{MDM: fleet.MDM{EnableDiskEncryption: optjson.SetBool(true)}}, nil
+ }
+ ds.GetHostOrbitInfoFunc = func(ctx context.Context, id uint) (*fleet.HostOrbitInfo, error) {
+ return &fleet.HostOrbitInfo{Version: "1.36.0", DesktopVersion: ptr.String("42")}, nil
+ }
+ ds.AssertHasNoEncryptionKeyStoredFunc = func(ctx context.Context, hostID uint) error {
+ return nil
+ }
+ host := &fleet.Host{ID: 1, Platform: "ubuntu", DiskEncryptionEnabled: ptr.Bool(true), OrbitVersion: ptr.String(fleet.MinOrbitLUKSVersion)}
+ ds.QueueEscrowFunc = func(ctx context.Context, hostID uint) error {
+ require.Equal(t, uint(1), hostID)
+ return nil
+ }
+
+ err := svc.TriggerLinuxDiskEncryptionEscrow(ctx, host)
+ require.NoError(t, err)
+ require.True(t, ds.QueueEscrowFuncInvoked)
+ })
+}
diff --git a/server/service/handler.go b/server/service/handler.go
index 4f916f576b..071d28a859 100644
--- a/server/service/handler.go
+++ b/server/service/handler.go
@@ -16,8 +16,8 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
mdmcrypto "github.com/fleetdm/fleet/v4/server/mdm/crypto"
+ "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
httpmdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/http/mdm"
- nanomdm_log "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log"
nanomdm_service "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/certauth"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/multi"
@@ -32,6 +32,7 @@ import (
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/gorilla/mux"
+ nanomdm_log "github.com/micromdm/nanolib/log"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/throttled/throttled/v2"
@@ -696,18 +697,18 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// Deprecated: GET /mdm/disk_encryption/summary is now deprecated, replaced by the
// GET /disk_encryption endpoint.
- mdmAnyMW.GET("/api/_version_/fleet/mdm/disk_encryption/summary", getMDMDiskEncryptionSummaryEndpoint, getMDMDiskEncryptionSummaryRequest{})
- mdmAnyMW.GET("/api/_version_/fleet/disk_encryption", getMDMDiskEncryptionSummaryEndpoint, getMDMDiskEncryptionSummaryRequest{})
+ ue.GET("/api/_version_/fleet/mdm/disk_encryption/summary", getMDMDiskEncryptionSummaryEndpoint, getMDMDiskEncryptionSummaryRequest{})
+ ue.GET("/api/_version_/fleet/disk_encryption", getMDMDiskEncryptionSummaryEndpoint, getMDMDiskEncryptionSummaryRequest{})
// Deprecated: GET /mdm/hosts/:id/encryption_key is now deprecated, replaced by
// GET /hosts/:id/encryption_key.
- mdmAnyMW.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{})
- mdmAnyMW.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{})
+ ue.GET("/api/_version_/fleet/mdm/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{})
+ ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/encryption_key", getHostEncryptionKey, getHostEncryptionKeyRequest{})
// Deprecated: GET /mdm/profiles/summary is now deprecated, replaced by the
// GET /configuration_profiles/summary endpoint.
- mdmAnyMW.GET("/api/_version_/fleet/mdm/profiles/summary", getMDMProfilesSummaryEndpoint, getMDMProfilesSummaryRequest{})
- mdmAnyMW.GET("/api/_version_/fleet/configuration_profiles/summary", getMDMProfilesSummaryEndpoint, getMDMProfilesSummaryRequest{})
+ ue.GET("/api/_version_/fleet/mdm/profiles/summary", getMDMProfilesSummaryEndpoint, getMDMProfilesSummaryRequest{})
+ ue.GET("/api/_version_/fleet/configuration_profiles/summary", getMDMProfilesSummaryEndpoint, getMDMProfilesSummaryRequest{})
// Deprecated: GET /mdm/profiles/:profile_uuid is now deprecated, replaced by
// GET /configuration_profiles/:profile_uuid.
@@ -734,7 +735,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// Deprecated: PATCH /mdm/apple/settings is deprecated, replaced by POST /disk_encryption.
// It was only used to set disk encryption.
mdmAnyMW.PATCH("/api/_version_/fleet/mdm/apple/settings", updateMDMAppleSettingsEndpoint, updateMDMAppleSettingsRequest{})
- mdmAnyMW.POST("/api/_version_/fleet/disk_encryption", updateMDMDiskEncryptionEndpoint, updateMDMDiskEncryptionRequest{})
+ ue.POST("/api/_version_/fleet/disk_encryption", updateDiskEncryptionEndpoint, updateDiskEncryptionRequest{})
// the following set of mdm endpoints must always be accessible (even
// if MDM is not configured) as it bootstraps the setup of MDM
@@ -837,6 +838,10 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
errorLimiter.Limit("post_device_migrate_mdm", desktopQuota),
).POST("/api/_version_/fleet/device/{token}/migrate_mdm", migrateMDMDeviceEndpoint, deviceMigrateMDMRequest{})
+ de.WithCustomMiddleware(
+ errorLimiter.Limit("post_device_trigger_linux_escrow", desktopQuota),
+ ).POST("/api/_version_/fleet/device/{token}/mdm/linux/trigger_escrow", triggerLinuxDiskEncryptionEscrowEndpoint, triggerLinuxDiskEncryptionEscrowRequest{})
+
// host-authenticated endpoints
he := newHostAuthenticatedEndpointer(svc, logger, opts, r, apiVersions...)
@@ -879,6 +884,8 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
oeWindowsMDM := oe.WithCustomMiddleware(mdmConfiguredMiddleware.VerifyWindowsMDM())
oeWindowsMDM.POST("/api/fleet/orbit/disk_encryption_key", postOrbitDiskEncryptionKeyEndpoint, orbitPostDiskEncryptionKeyRequest{})
+ oe.POST("/api/fleet/orbit/luks_data", postOrbitLUKSEndpoint, orbitPostLUKSRequest{})
+
// 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
@@ -1221,7 +1228,8 @@ func registerMDM(
} else {
mdmHandler = httpmdm.CertVerifyMiddleware(mdmHandler, certVerifier, mdmLogger.With("handler", "cert-verify"))
}
- mdmHandler = httpmdm.CertExtractMdmSignatureMiddleware(mdmHandler, mdmLogger.With("handler", "cert-extract"))
+ mdmHandler = httpmdm.CertExtractMdmSignatureMiddleware(mdmHandler, httpmdm.MdmSignatureVerifierFunc(cryptoutil.VerifyMdmSignature),
+ httpmdm.SigLogWithLogger(mdmLogger.With("handler", "cert-extract")))
mux.Handle(apple_mdm.MDMPath, mdmHandler)
return nil
}
diff --git a/server/service/hosts.go b/server/service/hosts.go
index 33e32c4380..4af625c202 100644
--- a/server/service/hosts.go
+++ b/server/service/hosts.go
@@ -1242,6 +1242,20 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f
}
host.MDM.Profiles = &profiles
+ if host.IsLUKSSupported() {
+ status, err := svc.LinuxHostDiskEncryptionStatus(ctx, *host)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get host disk encryption status")
+ }
+ host.MDM.OSSettings = &fleet.HostMDMOSSettings{
+ DiskEncryption: status,
+ }
+
+ if status.Status != nil && *status.Status == fleet.DiskEncryptionVerified {
+ host.MDM.EncryptionKeyAvailable = true
+ }
+ }
+
var macOSSetup *fleet.HostMDMMacOSSetup
if ac.MDM.EnabledAndConfigured && license.IsPremium(ctx) {
macOSSetup, err = svc.ds.GetHostMDMMacOSSetup(ctx, host.ID)
@@ -2200,50 +2214,32 @@ func (svc *Service) HostEncryptionKey(ctx context.Context, id uint) (*fleet.Host
return nil, err
}
- // The middleware checks that either Apple or Windows MDM are configured and
- // enabled, but here we must check if the specific one is enabled for that
- // particular host's platform.
- var decryptCert *tls.Certificate
- switch host.FleetPlatform() {
- case "windows":
- if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
+ var key *fleet.HostDiskEncryptionKey
+ if host.IsLUKSSupported() {
+ if svc.config.Server.PrivateKey == "" {
+ return nil, ctxerr.Wrap(ctx, errors.New("private key is unavailable"), "getting host encryption key")
+ }
+
+ key, err = svc.ds.GetHostDiskEncryptionKey(ctx, id)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "getting host encryption key")
+ }
+ if key.Base64Encrypted == "" {
+ return nil, ctxerr.Wrap(ctx, newNotFoundError(), "host encryption key is not set")
+ }
+
+ decryptedKey, err := mdm.DecodeAndDecrypt(key.Base64Encrypted, svc.config.Server.PrivateKey)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "decrypt host encryption key")
+ }
+ key.DecryptedValue = decryptedKey
+ } else {
+ key, err = svc.decryptForMDMPlatform(ctx, host)
+ if err != nil {
return nil, err
}
-
- // use Microsoft's WSTEP certificate for decrypting
- cert, _, _, err := svc.config.MDM.MicrosoftWSTEP()
- if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "getting Microsoft WSTEP certificate to decrypt key")
- }
- decryptCert = cert
-
- default:
- if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
- return nil, err
- }
-
- // use Apple's SCEP certificate for decrypting
- cert, err := assets.CAKeyPair(ctx, svc.ds)
- if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "loading existing assets from the database")
- }
- decryptCert = cert
}
- key, err := svc.ds.GetHostDiskEncryptionKey(ctx, id)
- if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "getting host encryption key")
- }
- if key.Decryptable == nil || !*key.Decryptable {
- return nil, ctxerr.Wrap(ctx, newNotFoundError(), "host encryption key is not decryptable")
- }
-
- decryptedKey, err := mdm.DecryptBase64CMS(key.Base64Encrypted, decryptCert.Leaf, decryptCert.PrivateKey)
- if err != nil {
- return nil, ctxerr.Wrap(ctx, err, "decrypt host encryption key")
- }
- key.DecryptedValue = string(decryptedKey)
-
err = svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
@@ -2259,6 +2255,50 @@ func (svc *Service) HostEncryptionKey(ctx context.Context, id uint) (*fleet.Host
return key, nil
}
+func (svc *Service) decryptForMDMPlatform(ctx context.Context, host *fleet.Host) (*fleet.HostDiskEncryptionKey, error) {
+ // Here we must check if the appropriate MDM is enabled for that particular host's platform.
+ var decryptCert *tls.Certificate
+ if host.FleetPlatform() == "windows" {
+ if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
+ return nil, err
+ }
+
+ // use Microsoft's WSTEP certificate for decrypting
+ cert, _, _, err := svc.config.MDM.MicrosoftWSTEP()
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "getting Microsoft WSTEP certificate to decrypt key")
+ }
+ decryptCert = cert
+ } else {
+ if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
+ return nil, err
+ }
+
+ // use Apple's SCEP certificate for decrypting
+ cert, err := assets.CAKeyPair(ctx, svc.ds)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "loading existing assets from the database")
+ }
+ decryptCert = cert
+ }
+
+ key, err := svc.ds.GetHostDiskEncryptionKey(ctx, host.ID)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "getting host encryption key")
+ }
+ if key.Decryptable == nil || !*key.Decryptable {
+ return nil, ctxerr.Wrap(ctx, newNotFoundError(), "host encryption key is not decryptable")
+ }
+
+ decryptedKey, err := mdm.DecryptBase64CMS(key.Base64Encrypted, decryptCert.Leaf, decryptCert.PrivateKey)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "decrypt host encryption key")
+ }
+
+ key.DecryptedValue = string(decryptedKey)
+ return key, nil
+}
+
////////////////////////////////////////////////////////////////////////////////
// Host Health
////////////////////////////////////////////////////////////////////////////////
diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go
index 5b0837cf60..035a552486 100644
--- a/server/service/hosts_test.go
+++ b/server/service/hosts_test.go
@@ -19,6 +19,7 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
@@ -399,6 +400,10 @@ func TestHostDetailsOSSettings(t *testing.T) {
return &fleet.HostLockWipeStatus{}, nil
}
+ ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) {
+ return &fleet.HostDiskEncryptionKey{}, nil
+ }
+
type testCase struct {
name string
host *fleet.Host
@@ -1315,7 +1320,8 @@ func TestHostEncryptionKey(t *testing.T) {
}
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
- _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
+ _ sqlx.QueryerContext,
+ ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: testCertPEM},
fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: testKeyPEM},
@@ -1368,7 +1374,8 @@ func TestHostEncryptionKey(t *testing.T) {
return nil, keyErr
}
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
- _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
+ _ sqlx.QueryerContext,
+ ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: testCertPEM},
fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: testKeyPEM},
@@ -1429,7 +1436,8 @@ func TestHostEncryptionKey(t *testing.T) {
return nil
}
ds.GetAllMDMConfigAssetsByNameFunc = func(ctx context.Context, assetNames []fleet.MDMAssetName,
- _ sqlx.QueryerContext) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
+ _ sqlx.QueryerContext,
+ ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) {
return map[fleet.MDMAssetName]fleet.MDMConfigAsset{
fleet.MDMAssetCACert: {Name: fleet.MDMAssetCACert, Value: testCertPEM},
fleet.MDMAssetCAKey: {Name: fleet.MDMAssetCAKey, Value: testKeyPEM},
@@ -1448,6 +1456,73 @@ func TestHostEncryptionKey(t *testing.T) {
})
}
})
+
+ t.Run("Linux encryption", func(t *testing.T) {
+ ds := new(mock.Store)
+ host := &fleet.Host{ID: 1, Platform: "ubuntu"}
+ symmetricKey := "this_is_a_32_byte_symmetric_key!"
+ passphrase := "this_is_a_passphrase"
+ base64EncryptedKey, err := mdm.EncryptAndEncode(passphrase, symmetricKey)
+ require.NoError(t, err)
+
+ ds.HostLiteFunc = func(ctx context.Context, id uint) (*fleet.Host, error) {
+ return host, nil
+ }
+
+ ds.NewActivityFunc = func(
+ ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time,
+ ) error {
+ return nil
+ }
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { // needed for new activity
+ return &fleet.AppConfig{}, nil
+ }
+
+ // error when no server private key
+ fleetCfg.Server.PrivateKey = ""
+ svc, ctx := newTestServiceWithConfig(t, ds, fleetCfg, nil, nil)
+ ctx = test.UserContext(ctx, test.UserAdmin)
+ key, err := svc.HostEncryptionKey(ctx, 1)
+ require.Error(t, err, "private key is unavailable")
+ require.Nil(t, key)
+
+ // error when key is not set
+ ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) {
+ return &fleet.HostDiskEncryptionKey{}, nil
+ }
+ fleetCfg.Server.PrivateKey = symmetricKey
+ svc, ctx = newTestServiceWithConfig(t, ds, fleetCfg, nil, nil)
+ ctx = test.UserContext(ctx, test.UserAdmin)
+ key, err = svc.HostEncryptionKey(ctx, 1)
+ require.Error(t, err, "host encryption key is not set")
+ require.Nil(t, key)
+
+ // error when key is not set
+ ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) {
+ return &fleet.HostDiskEncryptionKey{
+ Base64Encrypted: "thisIsWrong",
+ Decryptable: ptr.Bool(true),
+ }, nil
+ }
+ svc, ctx = newTestServiceWithConfig(t, ds, fleetCfg, nil, nil)
+ ctx = test.UserContext(ctx, test.UserAdmin)
+ key, err = svc.HostEncryptionKey(ctx, 1)
+ require.Error(t, err, "decrypt host encryption key")
+ require.Nil(t, key)
+
+ // happy path
+ ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) {
+ return &fleet.HostDiskEncryptionKey{
+ Base64Encrypted: base64EncryptedKey,
+ Decryptable: ptr.Bool(true),
+ }, nil
+ }
+ svc, ctx = newTestServiceWithConfig(t, ds, fleetCfg, nil, nil)
+ ctx = test.UserContext(ctx, test.UserAdmin)
+ key, err = svc.HostEncryptionKey(ctx, 1)
+ require.NoError(t, err)
+ require.Equal(t, passphrase, key.DecryptedValue)
+ })
}
func TestHostMDMProfileDetail(t *testing.T) {
diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go
index 45c56a3d76..999b6bfb34 100644
--- a/server/service/integration_core_test.go
+++ b/server/service/integration_core_test.go
@@ -6223,10 +6223,8 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Fleet MDM is not configured")
- // update MDM disk encryption, the endpoint returns an error if MDM is not enabled
- res = s.Do("POST", "/api/latest/fleet/disk_encryption", fleet.MDMAppleSettingsPayload{}, fleet.ErrMDMNotConfigured.StatusCode())
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, fleet.ErrMDMNotConfigured.Error())
+ // update MDM disk encryption
+ _ = s.Do("POST", "/api/latest/fleet/disk_encryption", fleet.MDMAppleSettingsPayload{}, http.StatusPaymentRequired)
// device migrate mdm endpoint returns an error if not premium
createHostAndDeviceToken(t, s.ds, "some-token")
diff --git a/server/service/integration_live_queries_test.go b/server/service/integration_live_queries_test.go
index 5d5c009aa4..8ecd65fb7b 100644
--- a/server/service/integration_live_queries_test.go
+++ b/server/service/integration_live_queries_test.go
@@ -1021,6 +1021,17 @@ func (s *liveQueriesTestSuite) TestCreateDistributedQueryCampaign() {
},
}
s.DoJSON("POST", "/api/latest/fleet/queries/run_by_identifiers", req2, http.StatusOK, &createResp)
+
+ // create with invalid label
+ req3 := createDistributedQueryCampaignByIdentifierRequest{
+ QuerySQL: "SELECT 4",
+ Selected: distributedQueryCampaignTargetsByIdentifiers{
+ Hosts: []string{h1.Hostname},
+ Labels: []string{"label1"},
+ },
+ }
+
+ s.DoJSON("POST", "/api/latest/fleet/queries/run_by_identifiers", req3, http.StatusBadRequest, &createResp)
}
func (s *liveQueriesTestSuite) TestOsqueryDistributedRead() {
diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go
index d2a32c8e33..235d8c7aa0 100644
--- a/server/service/integration_mdm_dep_test.go
+++ b/server/service/integration_mdm_dep_test.go
@@ -302,7 +302,7 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Empty(t, listHostsRes.Hosts)
- s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
+ s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{}, nil
}
@@ -871,7 +871,7 @@ func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
require.Equal(t, host.DisplayName, fmt.Sprintf("MacBook Mini (%s)", host.HardwareSerial))
}
- s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
+ s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{}, nil
}
@@ -1902,7 +1902,7 @@ func (s *integrationMDMTestSuite) createTeamDeviceForSetupExperienceWithProfileS
// no bootstrap package, no custom setup assistant (those are already tested
// in the DEPEnrollReleaseDevice tests).
- s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
+ s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{}, nil
}
@@ -1952,6 +1952,7 @@ func (s *integrationMDMTestSuite) createTeamDeviceForSetupExperienceWithProfileS
require.Len(t, listHostsRes.Hosts, 1)
require.Equal(t, listHostsRes.Hosts[0].HardwareSerial, teamDevice.SerialNumber)
enrolledHost := listHostsRes.Hosts[0].Host
+ enrolledHost.TeamID = &tm.ID
// transfer it to the team
s.Do("POST", "/api/v1/fleet/hosts/transfer",
@@ -2076,6 +2077,57 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
+ // The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull
+ // it out manually
+ results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID)
+ require.Len(t, results, 2)
+ require.NoError(t, err)
+ var installUUID string
+ for _, r := range results {
+ if r.HostSoftwareInstallsExecutionID != nil {
+ installUUID = *r.HostSoftwareInstallsExecutionID
+ }
+ }
+
+ require.NotEmpty(t, installUUID)
+
+ // Need to get the software title to get the package name
+ var getSoftwareTitleResp getSoftwareTitleResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", *statusResp.Results.Software[0].SoftwareTitleID), nil, http.StatusOK, &getSoftwareTitleResp, "team_id", fmt.Sprintf("%d", *enrolledHost.TeamID))
+ require.NotNil(t, getSoftwareTitleResp.SoftwareTitle)
+ require.NotNil(t, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage)
+
+ debugPrintActivities := func(activities []*fleet.Activity) []string {
+ var res []string
+ for _, activity := range activities {
+ res = append(res, fmt.Sprintf("%+v", activity))
+ }
+ return res
+ }
+
+ // Check upcoming activities: we should only have the software upcoming because we don't run the
+ // script until after the software is done
+ var hostActivitiesResp listHostUpcomingActivitiesResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", enrolledHost.ID),
+ nil, http.StatusOK, &hostActivitiesResp)
+
+ expectedActivityDetail := fmt.Sprintf(`
+ {
+ "status": "pending_install",
+ "host_id": %d,
+ "policy_id": null,
+ "policy_name": null,
+ "install_uuid": "%s",
+ "self_service": false,
+ "software_title": "%s",
+ "software_package": "%s",
+ "host_display_name": "%s"
+ }
+ `, enrolledHost.ID, installUUID, getSoftwareTitleResp.SoftwareTitle.Name, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage.Name, enrolledHost.DisplayName())
+ require.Len(t, hostActivitiesResp.Activities, 1, "got activities: %v", debugPrintActivities(hostActivitiesResp.Activities))
+ require.NotNil(t, hostActivitiesResp.Activities[0].Details)
+ require.JSONEq(t, expectedActivityDetail, string(*hostActivitiesResp.Activities[0].Details))
+
// no MDM command got enqueued due to the /status call (device not released yet)
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
@@ -2093,20 +2145,6 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
require.Equal(t, fleet.SetupExperienceStatusPending, statusResp.Results.Script.Status)
- // The /setup_experience/status endpoint doesn't return the various IDs for executions, so pull
- // it out manually
- results, err := s.ds.ListSetupExperienceResultsByHostUUID(ctx, enrolledHost.UUID)
- require.Len(t, results, 2)
- require.NoError(t, err)
- var installUUID string
- for _, r := range results {
- if r.HostSoftwareInstallsExecutionID != nil {
- installUUID = *r.HostSoftwareInstallsExecutionID
- }
- }
-
- require.NotEmpty(t, installUUID)
-
// record a result for software installation
s.Do("POST", "/api/fleet/orbit/software_install/result",
json.RawMessage(fmt.Sprintf(`{
@@ -2137,12 +2175,10 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu
// Software is installed, now we should run the script
statusResp = getOrbitSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
- // Software is now running, script is still pending
require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name)
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
require.NotZero(t, *statusResp.Results.Software[0].SoftwareTitleID)
-
require.NotNil(t, statusResp.Results.Script)
require.Equal(t, "script.sh", statusResp.Results.Script.Name)
require.Equal(t, fleet.SetupExperienceStatusRunning, statusResp.Results.Script.Status)
@@ -2158,6 +2194,48 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu
}
}
+ // Validate past activity for software install
+ // For some reason the display name that's included in the `enrolledHost` is _slightly_
+ // different than the expected value in the activities. Pulling the host directly gets the
+ // correct display name.
+ var getHostResp getHostResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", enrolledHost.ID), nil, http.StatusOK, &getHostResp)
+
+ expectedActivityDetail = fmt.Sprintf(`
+{
+ "host_id": %d,
+ "host_display_name": "%s",
+ "software_title": "%s",
+ "software_package": "%s",
+ "self_service": false,
+ "install_uuid": "%s",
+ "status": "installed",
+ "policy_id": null,
+ "policy_name": null
+}
+ `, enrolledHost.ID, getHostResp.Host.DisplayName, statusResp.Results.Software[0].Name, getSoftwareTitleResp.SoftwareTitle.SoftwarePackage.Name, installUUID)
+
+ s.lastActivityMatches(fleet.ActivityTypeInstalledSoftware{}.ActivityName(), expectedActivityDetail, 0)
+
+ // Validate upcoming activity for the script
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities/upcoming", enrolledHost.ID),
+ nil, http.StatusOK, &hostActivitiesResp)
+
+ expectedActivityDetail = fmt.Sprintf(`
+{
+ "async": true,
+ "host_id": %d,
+ "policy_id": null,
+ "policy_name": null,
+ "script_name": "%s",
+ "host_display_name": "%s",
+ "script_execution_id": "%s"
+}
+ `, enrolledHost.ID, statusResp.Results.Script.Name, enrolledHost.DisplayName(), execID)
+ require.Len(t, hostActivitiesResp.Activities, 1, "got activities: %v", debugPrintActivities(hostActivitiesResp.Activities))
+ require.NotNil(t, hostActivitiesResp.Activities[0].Details)
+ require.JSONEq(t, expectedActivityDetail, string(*hostActivitiesResp.Activities[0].Details))
+
// record a result for script execution
var scriptResp orbitPostScriptResultResponse
s.DoJSON("POST", "/api/fleet/orbit/scripts/result",
@@ -2168,7 +2246,6 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu
// release of the device, as all setup experience steps are now complete.
statusResp = getOrbitSetupExperienceStatusResponse{}
s.DoJSON("POST", "/api/fleet/orbit/setup_experience/status", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *enrolledHost.OrbitNodeKey)), http.StatusOK, &statusResp)
- // Software is now running, script is still pending
require.Equal(t, "DummyApp.app", statusResp.Results.Software[0].Name)
require.Equal(t, fleet.SetupExperienceStatusSuccess, statusResp.Results.Software[0].Status)
require.NotNil(t, statusResp.Results.Software[0].SoftwareTitleID)
@@ -2202,6 +2279,21 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptAu
}
require.Equal(t, 1, deviceConfiguredCount)
require.Equal(t, 0, otherCount)
+
+ // Validate activity for script run
+ expectedActivityDetail = fmt.Sprintf(`
+{
+ "async": true,
+ "host_id": %d,
+ "policy_id": null,
+ "policy_name": null,
+ "script_name": "%s",
+ "host_display_name": "%s",
+ "script_execution_id": "%s"
+}
+ `, enrolledHost.ID, statusResp.Results.Script.Name, getHostResp.Host.DisplayName, execID)
+
+ s.lastActivityMatches(fleet.ActivityTypeRanScript{}.ActivityName(), expectedActivityDetail, 0)
}
func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptForceRelease() {
@@ -2360,7 +2452,7 @@ func (s *integrationMDMTestSuite) TestSetupExperienceFlowWithSoftwareAndScriptFo
require.Equal(t, 0, otherCount)
}
-func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingtFromABM() {
+func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingItFromABM() {
t := s.T()
s.enableABM(t.Name())
ctx := context.Background()
@@ -2470,7 +2562,7 @@ func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingtFromABM(
}
}))
- s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
+ s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{}, nil
}
@@ -2534,7 +2626,6 @@ func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingtFromABM(
{SerialNumber: mdmDevice.SerialNumber, Model: "MacBook Pro", OS: "osx", OpType: "deleted", OpDate: time.Now()},
}
- t.Log("RUN AFTER DELETED")
s.runDEPSchedule()
a := checkHostDEPAssignProfileResponses([]string{mdmDevice.SerialNumber}, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
@@ -2550,7 +2641,6 @@ func (s *integrationMDMTestSuite) TestReenrollingADEDeviceAfterRemovingtFromABM(
{SerialNumber: mdmDevice.SerialNumber, Model: "MacBook Pro", OS: "osx", OpType: "added", OpDate: time.Now(), ProfileUUID: a[mdmDevice.SerialNumber].ProfileUUID},
}
- t.Log("RUN AFTER RE-ADDED")
s.runDEPSchedule()
a = checkHostDEPAssignProfileResponses([]string{mdmDevice.SerialNumber}, profileAssignmentReqs[0].ProfileUUID, fleet.DEPAssignProfileResponseSuccess)
diff --git a/server/service/integration_mdm_lifecycle_test.go b/server/service/integration_mdm_lifecycle_test.go
index 4bbc784435..01c2a525e9 100644
--- a/server/service/integration_mdm_lifecycle_test.go
+++ b/server/service/integration_mdm_lifecycle_test.go
@@ -46,6 +46,11 @@ type mdmLifecycleAssertion[T any] func(t *testing.T, host *fleet.Host, device T)
func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsApple() {
t := s.T()
+ // Skip worker jobs to avoid running into timing issues with this test.
+ // We can manually run the jobs if needed with s.runWorker().
+ s.skipWorkerJobs = true
+ t.Cleanup(func() { s.skipWorkerJobs = false })
+
s.setupLifecycleSettings()
testCases := []struct {
@@ -104,8 +109,8 @@ func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsApple() {
originalPushMock := s.pushProvider.PushFunc
defer func() { s.pushProvider.PushFunc = originalPushMock }()
- s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
- res, err := mockSuccessfulPush(pushes)
+ s.pushProvider.PushFunc = func(ctx context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
+ res, err := mockSuccessfulPush(ctx, pushes)
require.NoError(t, err)
err = device.Checkout()
require.NoError(t, err)
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index 2d98542328..fe930b96c4 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -1058,7 +1058,7 @@ func setupExpectedCAProfile(t *testing.T, ds *mysql.Datastore) []byte {
func setupPusher(s *integrationMDMTestSuite, t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient) {
origPush := s.pushProvider.PushFunc
- s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
+ s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
require.Len(t, pushes, 1)
require.Equal(t, pushes[0].PushMagic, "pushmagic"+mdmDevice.SerialNumber)
res := map[string]*push.Response{
@@ -1477,7 +1477,7 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() {
defer func() { s.pushProvider.PushFunc = originalPushMock }()
// if there's an error coming from APNs servers
- s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
+ s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{
pushes[0].Token.String(): {
Id: uuid.New().String(),
@@ -1488,7 +1488,7 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() {
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", h.ID), nil, http.StatusBadGateway)
// if there was an error unrelated to APNs
- s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
+ s.pushProvider.PushFunc = func(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
res := map[string]*push.Response{
pushes[0].Token.String(): {
Id: uuid.New().String(),
@@ -1501,8 +1501,8 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() {
// try again, but this time the host is online and answers
var checkoutErr error
- s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
- res, err := mockSuccessfulPush(pushes)
+ s.pushProvider.PushFunc = func(ctx context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
+ res, err := mockSuccessfulPush(ctx, pushes)
checkoutErr = mdmDevice.Checkout()
return res, err
}
@@ -1656,45 +1656,6 @@ func (s *integrationMDMTestSuite) TestDiskEncryptionSharedSetting() {
require.NoError(s.T(), err)
})
- checkConfigSetErrors := func() {
- // try to set app config
- res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
- "mdm": { "enable_disk_encryption": true }
- }`), http.StatusUnprocessableEntity)
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.")
-
- // try to create a new team using specs
- teamSpecs := map[string]any{
- "specs": []any{
- map[string]any{
- "name": teamName + uuid.NewString(),
- "mdm": map[string]any{
- "enable_disk_encryption": true,
- },
- },
- },
- }
- res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.")
-
- // try to edit the existing team using specs
- teamSpecs = map[string]any{
- "specs": []any{
- map[string]any{
- "name": teamName,
- "mdm": map[string]any{
- "enable_disk_encryption": true,
- },
- },
- },
- }
- res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Couldn't edit enable_disk_encryption. Neither macOS MDM nor Windows is turned on. Visit https://fleetdm.com/docs/using-fleet to learn how to turn on MDM.")
- }
-
checkConfigSetSucceeds := func() {
res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "enable_disk_encryption": true }
@@ -1749,10 +1710,9 @@ func (s *integrationMDMTestSuite) TestDiskEncryptionSharedSetting() {
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
}
- // disable both windows and mac mdm
- // we should get an error
+ // MDM config succeeds because we have a private key baked into default suite config
setMDMEnabled(false, false)
- checkConfigSetErrors()
+ checkConfigSetSucceeds()
// enable windows mdm, no errors
setMDMEnabled(false, true)
@@ -2218,7 +2178,7 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() {
// use the MDM disk encryption endpoint to set it to true
s.Do("POST", "/api/latest/fleet/disk_encryption",
- updateMDMDiskEncryptionRequest{EnableDiskEncryption: true}, http.StatusNoContent)
+ updateDiskEncryptionRequest{EnableDiskEncryption: true}, http.StatusNoContent)
enabledDiskActID = s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
`{"team_id": null, "team_name": null}`, 0)
@@ -2263,13 +2223,13 @@ func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() {
// flip and verify the value
s.Do("POST", "/api/latest/fleet/disk_encryption",
- updateMDMDiskEncryptionRequest{EnableDiskEncryption: false}, http.StatusNoContent)
+ updateDiskEncryptionRequest{EnableDiskEncryption: false}, http.StatusNoContent)
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
assert.False(t, acResp.MDM.EnableDiskEncryption.Value)
s.Do("POST", "/api/latest/fleet/disk_encryption",
- updateMDMDiskEncryptionRequest{EnableDiskEncryption: true}, http.StatusNoContent)
+ updateDiskEncryptionRequest{EnableDiskEncryption: true}, http.StatusNoContent)
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
assert.True(t, acResp.MDM.EnableDiskEncryption.Value)
@@ -2573,7 +2533,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() {
// use the MDM settings endpoint to set it to true
s.Do("POST", "/api/latest/fleet/disk_encryption",
- updateMDMDiskEncryptionRequest{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: true}, http.StatusNoContent)
+ updateDiskEncryptionRequest{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: true}, http.StatusNoContent)
lastDiskActID = s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0)
@@ -2599,7 +2559,7 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() {
// use the MDM settings endpoint with an unknown team id
s.Do("POST", "/api/latest/fleet/disk_encryption",
- updateMDMDiskEncryptionRequest{TeamID: ptr.Uint(9999), EnableDiskEncryption: true}, http.StatusNotFound)
+ updateDiskEncryptionRequest{TeamID: ptr.Uint(9999), EnableDiskEncryption: true}, http.StatusNotFound)
// mdm/apple/settings works for windows as well as it's being used by
// clients (UI) this way
@@ -2620,13 +2580,13 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() {
// flip and verify the value
s.Do("POST", "/api/latest/fleet/disk_encryption",
- updateMDMDiskEncryptionRequest{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: false}, http.StatusNoContent)
+ updateDiskEncryptionRequest{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: false}, http.StatusNoContent)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.False(t, teamResp.Team.Config.MDM.EnableDiskEncryption)
s.Do("POST", "/api/latest/fleet/disk_encryption",
- updateMDMDiskEncryptionRequest{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: true}, http.StatusNoContent)
+ updateDiskEncryptionRequest{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: true}, http.StatusNoContent)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption)
@@ -2661,10 +2621,14 @@ func (s *integrationMDMTestSuite) TestEnrollOrbitAfterDEPSync() {
// enroll the host from orbit, it should match the host above via the serial
var resp EnrollOrbitResponse
hostUUID := uuid.New().String()
+ h.ComputerName = "My Mac"
+ h.HardwareModel = "MacBook Pro"
s.DoJSON("POST", "/api/fleet/orbit/enroll", EnrollOrbitRequest{
EnrollSecret: secret,
HardwareUUID: hostUUID, // will not match any existing host
HardwareSerial: h.HardwareSerial,
+ ComputerName: h.ComputerName,
+ HardwareModel: h.HardwareModel,
}, http.StatusOK, &resp)
require.NotEmpty(t, resp.OrbitNodeKey)
@@ -2674,11 +2638,21 @@ func (s *integrationMDMTestSuite) TestEnrollOrbitAfterDEPSync() {
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h.ID), nil, http.StatusOK, &hostResp)
require.Equal(t, h.ID, hostResp.Host.ID)
require.NotEqual(t, dbZeroTime, hostResp.Host.LastEnrolledAt)
+ assert.Equal(t, h.ComputerName, hostResp.Host.ComputerName)
+ assert.Equal(t, h.HardwareModel, hostResp.Host.HardwareModel)
+ assert.Equal(t, h.HardwareSerial, hostResp.Host.HardwareSerial)
+ assert.Equal(t, h.DisplayName(), hostResp.Host.DisplayName)
got, err := s.ds.LoadHostByOrbitNodeKey(ctx, resp.OrbitNodeKey)
require.NoError(t, err)
require.Equal(t, h.ID, got.ID)
+ s.lastActivityMatches(
+ "fleet_enrolled",
+ fmt.Sprintf(`{"host_display_name": "%s", "host_serial": "%s"}`, h.DisplayName(), h.HardwareSerial),
+ 0,
+ )
+
// enroll the host from osquery, it should match the same host
var osqueryResp enrollAgentResponse
osqueryID := uuid.New().String()
@@ -2889,7 +2863,6 @@ func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
}, &mdm.CommandResults{
CommandUUID: uuid2,
Status: "Acknowledged",
- RequestType: "ProfileList",
Raw: []byte(rawCmd),
})
require.NoError(t, err)
@@ -7558,7 +7531,7 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() {
// TODO: Some global MDM config settings don't have MDMEnabledAndConfigured or
// WindowsMDMEnabledAndConfigured validations currently. Either add validations
- // and test them or test abscence of validation.
+ // and test them or test absence of validation.
t.Run("apply app config spec", func(t *testing.T) {
t.Run("disk encryption", func(t *testing.T) {
t.Cleanup(func() {
@@ -7591,14 +7564,14 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() {
require.True(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // no change
require.Equal(t, "f1337", acResp.AppConfig.OrgInfo.OrgName)
- // disabling disk encryption doesn't cause validation error because Windows is still enabled
+ // disabling disk encryption doesn't cause validation error
ac.MDM.EnableDiskEncryption = optjson.SetBool(false)
s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp)
acResp = checkAppConfig(t, false, true) // only windows mdm enabled
require.False(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // disabled
require.Equal(t, "f1337", acResp.AppConfig.OrgInfo.OrgName)
- // enabling disk encryption doesn't cause validation error because Windows is still enabled
+ // enabling disk encryption doesn't cause validation error
ac.MDM.EnableDiskEncryption = optjson.SetBool(true)
s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp)
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
@@ -7611,25 +7584,26 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() {
acResp = checkAppConfig(t, false, false) // both mac and windows mdm disabled
require.True(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // disabling mdm doesn't change disk encryption
+ // disabling disk encryption doesn't cause validation error
+ ac.MDM.EnableDiskEncryption = optjson.SetBool(false)
+ s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp)
+ acResp = checkAppConfig(t, false, false) // no MDM enabled
+ require.False(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // disabled
+ require.Equal(t, "f1337", acResp.AppConfig.OrgInfo.OrgName)
+
+ // enabling disk encryption doesn't cause validation error
+ ac.MDM.EnableDiskEncryption = optjson.SetBool(true)
+ s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp)
+ s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
+ acResp = checkAppConfig(t, false, false) // no MDM enabled
+ require.True(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // enabled
+
// changing unrelated config doesn't cause validation error
ac.OrgInfo.OrgName = "f1338"
s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp)
acResp = checkAppConfig(t, false, false) // both mac and windows mdm disabled
require.True(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // no change
require.Equal(t, "f1338", acResp.AppConfig.OrgInfo.OrgName)
-
- // changing MDM config doesn't cause validation error when switching to default values
- ac.MDM.EnableDiskEncryption = optjson.SetBool(false)
- // TODO: Should it be ok to disable disk encryption when MDM is disabled?
- s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusOK, &acResp)
- acResp = checkAppConfig(t, false, false) // both mac and windows mdm disabled
- require.False(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // changed to disabled
-
- // changing MDM config does cause validation error when switching to non-default vailes
- ac.MDM.EnableDiskEncryption = optjson.SetBool(true)
- s.DoJSON("PATCH", "/api/latest/fleet/config", ac, http.StatusUnprocessableEntity, &acResp)
- acResp = checkAppConfig(t, false, false) // both mac and windows mdm disabled
- require.False(t, acResp.AppConfig.MDM.EnableDiskEncryption.Value) // still disabled
})
t.Run("macos setup", func(t *testing.T) {
@@ -8093,17 +8067,18 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() {
})
checkTeam := func(t *testing.T, team *fleet.Team, checkMDM *fleet.TeamSpecMDM) teamResponse {
- var wantDiskEncryption bool
+ // TODO - remove check of disk encryption from this function entirely?
+ // var wantDiskEncryption bool
var wantMacOSSetup fleet.MacOSSetup
if checkMDM != nil {
wantMacOSSetup = checkMDM.MacOSSetup
- wantDiskEncryption = checkMDM.EnableDiskEncryption.Value
+ // wantDiskEncryption = checkMDM.EnableDiskEncryption.Value
}
var resp teamResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &resp)
require.Equal(t, team.Name, resp.Team.Name)
- require.Equal(t, wantDiskEncryption, resp.Team.Config.MDM.EnableDiskEncryption)
+ // require.Equal(t, wantDiskEncryption, resp.Team.Config.MDM.EnableDiskEncryption)
require.Equal(t, wantMacOSSetup.BootstrapPackage.Value, resp.Team.Config.MDM.MacOSSetup.BootstrapPackage.Value)
require.Equal(t, wantMacOSSetup.MacOSSetupAssistant.Value, resp.Team.Config.MDM.MacOSSetup.MacOSSetupAssistant.Value)
require.Equal(t, wantMacOSSetup.EnableEndUserAuthentication, resp.Team.Config.MDM.MacOSSetup.EnableEndUserAuthentication)
@@ -8181,9 +8156,10 @@ func (s *integrationMDMTestSuite) TestMDMEnabledAndConfigured() {
&fleet.TeamSpecMDM{
EnableDiskEncryption: optjson.SetBool(true),
},
- // disk encryption requires mdm enabled and configured
- http.StatusUnprocessableEntity,
+ // disk encryption does not require mdm enabled and configured
+ http.StatusOK,
},
+ // Ian - this test still passes, that is, returns 4xx – perhaps related to one of the endpoints we still need to update
{
"enable end user auth",
&fleet.TeamSpecMDM{
@@ -9935,11 +9911,11 @@ func (s *integrationMDMTestSuite) TestAPNsPushCron() {
var recordedPushes []*mdm.Push
var mu sync.Mutex
- s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
+ s.pushProvider.PushFunc = func(ctx context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
mu.Lock()
defer mu.Unlock()
recordedPushes = pushes
- return mockSuccessfulPush(pushes)
+ return mockSuccessfulPush(ctx, pushes)
}
// trigger the reconciliation schedule
diff --git a/server/service/linux_mdm.go b/server/service/linux_mdm.go
new file mode 100644
index 0000000000..d4ae8da27e
--- /dev/null
+++ b/server/service/linux_mdm.go
@@ -0,0 +1,44 @@
+package service
+
+import (
+ "context"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+)
+
+func (svc *Service) LinuxHostDiskEncryptionStatus(ctx context.Context, host fleet.Host) (fleet.HostMDMDiskEncryption, error) {
+ if !host.IsLUKSSupported() {
+ return fleet.HostMDMDiskEncryption{}, nil
+ }
+
+ actionRequired := fleet.DiskEncryptionActionRequired
+ verified := fleet.DiskEncryptionVerified
+ failed := fleet.DiskEncryptionFailed
+
+ key, err := svc.ds.GetHostDiskEncryptionKey(ctx, host.ID)
+ if err != nil {
+ if fleet.IsNotFound(err) {
+ return fleet.HostMDMDiskEncryption{
+ Status: &actionRequired,
+ }, nil
+ }
+ return fleet.HostMDMDiskEncryption{}, err
+ }
+
+ if key.ClientError != "" {
+ return fleet.HostMDMDiskEncryption{
+ Status: &failed,
+ Detail: key.ClientError,
+ }, nil
+ }
+
+ if key.Base64Encrypted == "" {
+ return fleet.HostMDMDiskEncryption{
+ Status: &actionRequired,
+ }, nil
+ }
+
+ return fleet.HostMDMDiskEncryption{
+ Status: &verified,
+ }, nil
+}
diff --git a/server/service/linux_mdm_test.go b/server/service/linux_mdm_test.go
new file mode 100644
index 0000000000..05809eb4fc
--- /dev/null
+++ b/server/service/linux_mdm_test.go
@@ -0,0 +1,118 @@
+package service
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/mock"
+ "github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestLinuxHostDiskEncryptionStatus(t *testing.T) {
+ ds := new(mock.Store)
+ svc, ctx := newTestService(t, ds, nil, nil)
+
+ actionRequired := fleet.DiskEncryptionActionRequired
+ verified := fleet.DiskEncryptionVerified
+ failed := fleet.DiskEncryptionFailed
+
+ testcases := []struct {
+ name string
+ host fleet.Host
+ keyExists bool
+ clientErrorExists bool
+ status fleet.HostMDMDiskEncryption
+ notFound bool
+ }{
+ {
+ name: "no key",
+ host: fleet.Host{ID: 1, Platform: "ubuntu"},
+ keyExists: false,
+ clientErrorExists: false,
+ status: fleet.HostMDMDiskEncryption{
+ Status: &actionRequired,
+ },
+ },
+ {
+ name: "key exists",
+ host: fleet.Host{ID: 1, Platform: "ubuntu"},
+ keyExists: true,
+ clientErrorExists: false,
+ status: fleet.HostMDMDiskEncryption{
+ Status: &verified,
+ },
+ },
+ {
+ name: "key exists && client error",
+ host: fleet.Host{ID: 1, Platform: "ubuntu"},
+ keyExists: true,
+ clientErrorExists: true,
+ status: fleet.HostMDMDiskEncryption{
+ Status: &failed,
+ Detail: "client error",
+ },
+ },
+ {
+ name: "no key && client error",
+ host: fleet.Host{ID: 1, Platform: "ubuntu"},
+ keyExists: false,
+ clientErrorExists: true,
+ status: fleet.HostMDMDiskEncryption{
+ Status: &failed,
+ Detail: "client error",
+ },
+ },
+ {
+ name: "key not found",
+ host: fleet.Host{ID: 1, Platform: "ubuntu"},
+ keyExists: false,
+ clientErrorExists: false,
+ status: fleet.HostMDMDiskEncryption{
+ Status: &actionRequired,
+ },
+ notFound: true,
+ },
+ {
+ name: "unsupported platform",
+ host: fleet.Host{ID: 1, Platform: "amzn"},
+ status: fleet.HostMDMDiskEncryption{},
+ },
+ }
+
+ for _, tt := range testcases {
+ t.Run(tt.name, func(t *testing.T) {
+ ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) {
+ var encrypted string
+ if tt.keyExists {
+ encrypted = "encrypted"
+ }
+
+ var clientError string
+ if tt.clientErrorExists {
+ clientError = "client error"
+ }
+
+ var nfe notFoundError
+ if tt.notFound {
+ return nil, &nfe
+ }
+
+ return &fleet.HostDiskEncryptionKey{
+ HostID: hostID,
+ Base64Encrypted: encrypted,
+ Decryptable: ptr.Bool(true),
+ UpdatedAt: time.Now(),
+ ClientError: clientError,
+ }, nil
+ }
+
+ status, err := svc.LinuxHostDiskEncryptionStatus(ctx, tt.host)
+ assert.Nil(t, err)
+
+ assert.Equal(t, tt.status, status)
+ })
+ }
+}
diff --git a/server/service/mdm.go b/server/service/mdm.go
index 92524ba1f1..12876b95dd 100644
--- a/server/service/mdm.go
+++ b/server/service/mdm.go
@@ -2165,7 +2165,7 @@ func (svc *Service) ListMDMConfigProfiles(ctx context.Context, teamID *uint, opt
// Update MDM Disk encryption
////////////////////////////////////////////////////////////////////////////////
-type updateMDMDiskEncryptionRequest struct {
+type updateDiskEncryptionRequest struct {
TeamID *uint `json:"team_id"`
EnableDiskEncryption bool `json:"enable_disk_encryption"`
}
@@ -2178,8 +2178,8 @@ func (r updateMDMDiskEncryptionResponse) error() error { return r.Err }
func (r updateMDMDiskEncryptionResponse) Status() int { return http.StatusNoContent }
-func updateMDMDiskEncryptionEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
- req := request.(*updateMDMDiskEncryptionRequest)
+func updateDiskEncryptionEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ req := request.(*updateDiskEncryptionRequest)
if err := svc.UpdateMDMDiskEncryption(ctx, req.TeamID, &req.EnableDiskEncryption); err != nil {
return updateMDMDiskEncryptionResponse{Err: err}, nil
}
@@ -2194,7 +2194,7 @@ func (svc *Service) UpdateMDMDiskEncryption(ctx context.Context, teamID *uint, e
lic, _ := license.FromContext(ctx)
if lic == nil || !lic.IsPremium() {
svc.authz.SkipAuthorization(ctx) // so that the error message is not replaced by "forbidden"
- return ErrMissingLicense
+ return fleet.ErrMissingLicense
}
// for historical reasons (the deprecated PATCH /mdm/apple/settings
diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go
index 59ea63c8b4..e763cf6c05 100644
--- a/server/service/mdm_test.go
+++ b/server/service/mdm_test.go
@@ -606,6 +606,11 @@ func TestMDMCommonAuthorization(t *testing.T) {
ds.GetMDMWindowsProfilesSummaryFunc = func(ctx context.Context, teamID *uint) (*fleet.MDMProfilesSummary, error) {
return &fleet.MDMProfilesSummary{}, nil
}
+
+ ds.GetLinuxDiskEncryptionSummaryFunc = func(ctx context.Context, teamID *uint) (fleet.MDMLinuxDiskEncryptionSummary, error) {
+ return fleet.MDMLinuxDiskEncryptionSummary{}, nil
+ }
+
ds.AreHostsConnectedToFleetMDMFunc = func(ctx context.Context, hosts []*fleet.Host) (map[string]bool, error) {
res := make(map[string]bool, len(hosts))
for _, h := range hosts {
@@ -874,6 +879,11 @@ func TestGetMDMDiskEncryptionSummary(t *testing.T) {
return res, nil
}
+ ds.GetLinuxDiskEncryptionSummaryFunc = func(ctx context.Context, teamID *uint) (fleet.MDMLinuxDiskEncryptionSummary, error) {
+ require.Nil(t, teamID)
+ return fleet.MDMLinuxDiskEncryptionSummary{Verified: 1, ActionRequired: 2, Failed: 3}, nil
+ }
+
// Test that the summary properly combines the results of the two methods
des, err := svc.GetMDMDiskEncryptionSummary(ctx, nil)
require.NoError(t, err)
@@ -882,6 +892,7 @@ func TestGetMDMDiskEncryptionSummary(t *testing.T) {
Verified: fleet.MDMPlatformsCounts{
MacOS: 1,
Windows: 7,
+ Linux: 1,
},
Verifying: fleet.MDMPlatformsCounts{
MacOS: 2,
@@ -890,10 +901,12 @@ func TestGetMDMDiskEncryptionSummary(t *testing.T) {
ActionRequired: fleet.MDMPlatformsCounts{
MacOS: 3,
Windows: 0,
+ Linux: 2,
},
Failed: fleet.MDMPlatformsCounts{
MacOS: 4,
Windows: 8,
+ Linux: 3,
},
Enforcing: fleet.MDMPlatformsCounts{
MacOS: 5,
diff --git a/server/service/mock/service_push_provider.go b/server/service/mock/service_push_provider.go
index 8ddc84a9dd..159b643c0c 100644
--- a/server/service/mock/service_push_provider.go
+++ b/server/service/mock/service_push_provider.go
@@ -3,6 +3,7 @@
package mock
import (
+ "context"
"sync"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
@@ -11,7 +12,7 @@ import (
var _ push.PushProvider = (*APNSPushProvider)(nil)
-type PushFunc func(p0 []*mdm.Push) (map[string]*push.Response, error)
+type PushFunc func(p0 context.Context, p1 []*mdm.Push) (map[string]*push.Response, error)
type APNSPushProvider struct {
PushFunc PushFunc
@@ -20,9 +21,9 @@ type APNSPushProvider struct {
mu sync.Mutex
}
-func (s *APNSPushProvider) Push(p0 []*mdm.Push) (map[string]*push.Response, error) {
+func (s *APNSPushProvider) Push(p0 context.Context, p1 []*mdm.Push) (map[string]*push.Response, error) {
s.mu.Lock()
s.PushFuncInvoked = true
s.mu.Unlock()
- return s.PushFunc(p0)
+ return s.PushFunc(p0, p1)
}
diff --git a/server/service/orbit.go b/server/service/orbit.go
index e5b73e3e52..0a0e852bb3 100644
--- a/server/service/orbit.go
+++ b/server/service/orbit.go
@@ -16,6 +16,7 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/mdm"
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/worker"
@@ -41,6 +42,10 @@ type EnrollOrbitRequest struct {
// OsqueryIdentifier holds the identifier used by osquery.
// If not set, then the hardware UUID is used to match orbit and osquery.
OsqueryIdentifier string `json:"osquery_identifier"`
+ // ComputerName is the device's friendly name (optional).
+ ComputerName string `json:"computer_name"`
+ // HardwareModel is the device's hardware model.
+ HardwareModel string `json:"hardware_model"`
}
type EnrollOrbitResponse struct {
@@ -90,6 +95,8 @@ func enrollOrbitEndpoint(ctx context.Context, request interface{}, svc fleet.Ser
Hostname: req.Hostname,
Platform: req.Platform,
OsqueryIdentifier: req.OsqueryIdentifier,
+ ComputerName: req.ComputerName,
+ HardwareModel: req.HardwareModel,
}, req.EnrollSecret)
if err != nil {
return EnrollOrbitResponse{Err: err}, nil
@@ -129,6 +136,8 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf
"hostname", hostInfo.Hostname,
"platform", hostInfo.Platform,
"osquery_identifier", hostInfo.OsqueryIdentifier,
+ "computer_name", hostInfo.ComputerName,
+ "hardware_model", hostInfo.HardwareModel,
),
level.Info,
)
@@ -155,11 +164,22 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf
return "", fleet.OrbitError{Message: "app config load failed: " + err.Error()}
}
- _, err = svc.ds.EnrollOrbit(ctx, appConfig.MDM.EnabledAndConfigured, hostInfo, orbitNodeKey, secret.TeamID)
+ host, err := svc.ds.EnrollOrbit(ctx, appConfig.MDM.EnabledAndConfigured, hostInfo, orbitNodeKey, secret.TeamID)
if err != nil {
return "", fleet.OrbitError{Message: "failed to enroll " + err.Error()}
}
+ if err := svc.NewActivity(
+ ctx,
+ nil,
+ fleet.ActivityTypeFleetEnrolled{
+ HostSerial: hostInfo.HardwareSerial,
+ HostDisplayName: host.DisplayName(),
+ },
+ ); err != nil {
+ level.Error(svc.logger).Log("msg", "record fleet enroll activity", "err", err)
+ }
+
return orbitNodeKey, nil
}
@@ -268,6 +288,9 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro
}
}
+ notifs.RunDiskEncryptionEscrow = host.IsLUKSSupported() &&
+ host.DiskEncryptionEnabled != nil && *host.DiskEncryptionEnabled && svc.ds.IsHostPendingEscrow(ctx, host.ID)
+
pendingInstalls, err := svc.ds.ListPendingSoftwareInstalls(ctx, host.ID)
if err != nil {
return fleet.OrbitConfig{}, err
@@ -349,6 +372,11 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro
updateChannels = &uc
}
+ // only unset this flag once we know there were no errors so this notification will be picked up by the agent
+ if notifs.RunDiskEncryptionEscrow {
+ _ = svc.ds.ClearPendingEscrow(ctx, host.ID)
+ }
+
return fleet.OrbitConfig{
ScriptExeTimeout: opts.ScriptExecutionTimeout,
Flags: opts.CommandLineStartUpFlags,
@@ -419,6 +447,11 @@ func (svc *Service) GetOrbitConfig(ctx context.Context) (fleet.OrbitConfig, erro
updateChannels = &uc
}
+ // only unset this flag once we know there were no errors so this notification will be picked up by the agent
+ if notifs.RunDiskEncryptionEscrow {
+ _ = svc.ds.ClearPendingEscrow(ctx, host.ID)
+ }
+
return fleet.OrbitConfig{
ScriptExeTimeout: opts.ScriptExecutionTimeout,
Flags: opts.CommandLineStartUpFlags,
@@ -804,11 +837,20 @@ func (svc *Service) SaveHostScriptResult(ctx context.Context, result *fleet.Host
}
}
var scriptName string
- if hsr.ScriptID != nil {
+
+ switch {
+ case hsr.ScriptID != nil:
scr, err := svc.ds.Script(ctx, *hsr.ScriptID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get saved script")
}
+ scriptName = scr.Name
+ case hsr.SetupExperienceScriptID != nil:
+ scr, err := svc.ds.GetSetupExperienceScriptByID(ctx, *hsr.SetupExperienceScriptID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "get setup experience script")
+ }
+
scriptName = scr.Name
}
@@ -985,6 +1027,85 @@ func (svc *Service) SetOrUpdateDiskEncryptionKey(ctx context.Context, encryption
return nil
}
+/////////////////////////////////////////////////////////////////////////////////
+// Post Orbit LUKS (Linux disk encryption) data
+/////////////////////////////////////////////////////////////////////////////////
+
+type orbitPostLUKSRequest struct {
+ OrbitNodeKey string `json:"orbit_node_key"`
+ Passphrase string `json:"passphrase"`
+ Salt string `json:"salt"`
+ KeySlot *uint `json:"key_slot"`
+ ClientError string `json:"client_error"`
+}
+
+// interface implementation required by the OrbitClient
+func (r *orbitPostLUKSRequest) setOrbitNodeKey(nodeKey string) {
+ r.OrbitNodeKey = nodeKey
+}
+
+// interface implementation required by orbit authentication
+func (r *orbitPostLUKSRequest) orbitHostNodeKey() string {
+ return r.OrbitNodeKey
+}
+
+type orbitPostLUKSResponse struct {
+ Err error `json:"error,omitempty"`
+}
+
+func (r orbitPostLUKSResponse) error() error { return r.Err }
+func (r orbitPostLUKSResponse) Status() int { return http.StatusNoContent }
+
+func postOrbitLUKSEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
+ req := request.(*orbitPostLUKSRequest)
+ if err := svc.EscrowLUKSData(ctx, req.Passphrase, req.Salt, req.KeySlot, req.ClientError); err != nil {
+ return orbitPostLUKSResponse{Err: err}, nil
+ }
+ return orbitPostLUKSResponse{}, nil
+}
+
+func (svc *Service) EscrowLUKSData(ctx context.Context, passphrase string, salt string, keySlot *uint, clientError string) error {
+ // this is not a user-authenticated endpoint
+ svc.authz.SkipAuthorization(ctx)
+
+ host, ok := hostctx.FromContext(ctx)
+ if !ok {
+ return newOsqueryError("internal error: missing host from request context")
+ }
+
+ if clientError != "" {
+ return svc.ds.ReportEscrowError(ctx, host.ID, clientError)
+ }
+
+ encryptedPassphrase, encryptedSalt, validatedKeySlot, err := svc.validateAndEncrypt(ctx, passphrase, salt, keySlot)
+ if err != nil {
+ _ = svc.ds.ReportEscrowError(ctx, host.ID, err.Error())
+ return err
+ }
+
+ return svc.ds.SaveLUKSData(ctx, host.ID, encryptedPassphrase, encryptedSalt, validatedKeySlot)
+}
+
+func (svc *Service) validateAndEncrypt(ctx context.Context, passphrase string, salt string, keySlot *uint) (encryptedPassphrase string, encryptedSalt string, validatedKeySlot uint, err error) {
+ if passphrase == "" || salt == "" || keySlot == nil {
+ return "", "", 0, badRequest("passphrase, salt, and key_slot must be provided to escrow LUKS data")
+ }
+ if svc.config.Server.PrivateKey == "" {
+ return "", "", 0, newOsqueryError("internal error: missing server private key")
+ }
+
+ encryptedPassphrase, err = mdm.EncryptAndEncode(passphrase, svc.config.Server.PrivateKey)
+ if err != nil {
+ return "", "", 0, ctxerr.Wrap(ctx, err, "internal error: could not encrypt LUKS data")
+ }
+ encryptedSalt, err = mdm.EncryptAndEncode(salt, svc.config.Server.PrivateKey)
+ if err != nil {
+ return "", "", 0, ctxerr.Wrap(ctx, err, "internal error: could not encrypt LUKS data")
+ }
+
+ return encryptedPassphrase, encryptedSalt, *keySlot, nil
+}
+
/////////////////////////////////////////////////////////////////////////////////
// Get Orbit pending software installations
/////////////////////////////////////////////////////////////////////////////////
diff --git a/server/service/orbit_client.go b/server/service/orbit_client.go
index 4c13f07db5..912d47c86f 100644
--- a/server/service/orbit_client.go
+++ b/server/service/orbit_client.go
@@ -22,6 +22,7 @@ import (
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
"github.com/fleetdm/fleet/v4/orbit/pkg/logging"
+ "github.com/fleetdm/fleet/v4/orbit/pkg/luks"
"github.com/fleetdm/fleet/v4/orbit/pkg/platform"
"github.com/fleetdm/fleet/v4/pkg/retry"
"github.com/fleetdm/fleet/v4/server/fleet"
@@ -456,6 +457,8 @@ func (oc *OrbitClient) enroll() (string, error) {
Hostname: oc.hostInfo.Hostname,
Platform: oc.hostInfo.Platform,
OsqueryIdentifier: oc.hostInfo.OsqueryIdentifier,
+ ComputerName: oc.hostInfo.ComputerName,
+ HardwareModel: oc.hostInfo.HardwareModel,
}
var resp EnrollOrbitResponse
err := oc.request(verb, path, params, &resp)
@@ -666,3 +669,18 @@ func (oc *OrbitClient) GetSetupExperienceStatus() (*fleet.SetupExperienceStatusP
return resp.Results, nil
}
+
+func (oc *OrbitClient) SendLinuxKeyEscrowResponse(lr luks.LuksResponse) error {
+ verb, path := "POST", "/api/fleet/orbit/luks_data"
+ var resp orbitPostLUKSResponse
+ if err := oc.authenticatedRequest(verb, path, &orbitPostLUKSRequest{
+ Passphrase: lr.Passphrase,
+ KeySlot: lr.KeySlot,
+ Salt: lr.Salt,
+ ClientError: lr.Err,
+ }, &resp); err != nil {
+ return err
+ }
+
+ return nil
+}
diff --git a/server/service/orbit_test.go b/server/service/orbit_test.go
index 992d8b057c..3dc98b64aa 100644
--- a/server/service/orbit_test.go
+++ b/server/service/orbit_test.go
@@ -4,16 +4,268 @@ import (
"context"
"database/sql"
"encoding/json"
+ "errors"
"testing"
"github.com/fleetdm/fleet/v4/pkg/optjson"
+ "github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
+ "github.com/fleetdm/fleet/v4/server/mdm"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/require"
)
+func TestGetOrbitConfigLinuxEscrow(t *testing.T) {
+ t.Run("don't check for pending escrow if unsupported platform or encryption is not enabled", func(t *testing.T) {
+ ds := new(mock.Store)
+ license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
+ svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
+ os := &fleet.OperatingSystem{
+ Platform: "rhel",
+ Version: "9.0",
+ }
+ host := &fleet.Host{
+ OsqueryHostID: ptr.String("test"),
+ ID: 1,
+ OSVersion: "Red Hat Enterprise Linux 9.0",
+ Platform: "rhel",
+ }
+
+ team := fleet.Team{ID: 1}
+ teamMDM := fleet.TeamMDM{EnableDiskEncryption: true}
+ ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
+ require.Equal(t, team.ID, teamID)
+ return &teamMDM, nil
+ }
+ ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) {
+ return ptr.RawMessage(json.RawMessage(`{}`)), nil
+ }
+ ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) {
+ return nil, nil
+ }
+ ds.ListPendingSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) {
+ return nil, nil
+ }
+ ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
+ return true, nil
+ }
+ ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
+ return nil, nil
+ }
+
+ appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnableDiskEncryption: optjson.SetBool(true)}}
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return appCfg, nil
+ }
+ ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) {
+ return os, nil
+ }
+
+ ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) {
+ return false, nil
+ }
+
+ ctx = test.HostContext(ctx, host)
+
+ cfg, err := svc.GetOrbitConfig(ctx)
+ require.NoError(t, err)
+ require.False(t, cfg.Notifications.RunDiskEncryptionEscrow)
+
+ host.OSVersion = "Fedora 38.0"
+ cfg, err = svc.GetOrbitConfig(ctx)
+ require.NoError(t, err)
+ require.False(t, cfg.Notifications.RunDiskEncryptionEscrow)
+ })
+
+ t.Run("pending escrow sets config flag and clears in DB", func(t *testing.T) {
+ ds := new(mock.Store)
+ license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
+ svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
+ os := &fleet.OperatingSystem{
+ Platform: "ubuntu",
+ Version: "20.04",
+ }
+ host := &fleet.Host{
+ OsqueryHostID: ptr.String("test"),
+ ID: 1,
+ OSVersion: "Ubuntu 20.04",
+ Platform: "ubuntu",
+ DiskEncryptionEnabled: ptr.Bool(true),
+ }
+
+ team := fleet.Team{ID: 1}
+ teamMDM := fleet.TeamMDM{EnableDiskEncryption: true}
+ ds.TeamMDMConfigFunc = func(ctx context.Context, teamID uint) (*fleet.TeamMDM, error) {
+ require.Equal(t, team.ID, teamID)
+ return &teamMDM, nil
+ }
+ ds.TeamAgentOptionsFunc = func(ctx context.Context, id uint) (*json.RawMessage, error) {
+ return ptr.RawMessage(json.RawMessage(`{}`)), nil
+ }
+ ds.ListPendingHostScriptExecutionsFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostScriptResult, error) {
+ return nil, nil
+ }
+ ds.ListPendingSoftwareInstallsFunc = func(ctx context.Context, hostID uint) ([]string, error) {
+ return nil, nil
+ }
+ ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
+ return true, nil
+ }
+ ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
+ return nil, nil
+ }
+ ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
+ return true
+ }
+ ds.ClearPendingEscrowFunc = func(ctx context.Context, hostID uint) error {
+ return nil
+ }
+
+ appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnableDiskEncryption: optjson.SetBool(true)}}
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return appCfg, nil
+ }
+ ds.GetHostOperatingSystemFunc = func(ctx context.Context, hostID uint) (*fleet.OperatingSystem, error) {
+ return os, nil
+ }
+
+ ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) {
+ return false, nil
+ }
+
+ ctx = test.HostContext(ctx, host)
+
+ // no-team
+ cfg, err := svc.GetOrbitConfig(ctx)
+ require.NoError(t, err)
+ require.True(t, cfg.Notifications.RunDiskEncryptionEscrow)
+ require.True(t, ds.ClearPendingEscrowFuncInvoked)
+
+ // with team
+ ds.ClearPendingEscrowFuncInvoked = false
+ host.TeamID = ptr.Uint(team.ID)
+ cfg, err = svc.GetOrbitConfig(ctx)
+ require.NoError(t, err)
+ require.True(t, cfg.Notifications.RunDiskEncryptionEscrow)
+ require.True(t, ds.ClearPendingEscrowFuncInvoked)
+
+ // ignore clear escrow errors
+ ds.ClearPendingEscrowFuncInvoked = false
+ ds.ClearPendingEscrowFunc = func(ctx context.Context, hostID uint) error {
+ return errors.New("clear pending escrow")
+ }
+ cfg, err = svc.GetOrbitConfig(ctx)
+ require.NoError(t, err)
+ require.True(t, cfg.Notifications.RunDiskEncryptionEscrow)
+ require.True(t, ds.ClearPendingEscrowFuncInvoked)
+ })
+}
+
+func TestOrbitLUKSDataSave(t *testing.T) {
+ t.Run("when private key is set", func(t *testing.T) {
+ ds := new(mock.Store)
+ license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
+ svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
+ host := &fleet.Host{
+ OsqueryHostID: ptr.String("test"),
+ ID: 1,
+ }
+ ctx = test.HostContext(ctx, host)
+ expectedErrorMessage := "There was an error."
+ ds.ReportEscrowErrorFunc = func(ctx context.Context, hostID uint, err string) error {
+ require.Equal(t, expectedErrorMessage, err)
+ return nil
+ }
+
+ // test reporting client errors
+ err := svc.EscrowLUKSData(ctx, "foo", "bar", nil, expectedErrorMessage)
+ require.NoError(t, err)
+ require.True(t, ds.ReportEscrowErrorFuncInvoked)
+
+ // blank passphrase
+ ds.ReportEscrowErrorFuncInvoked = false
+ expectedErrorMessage = "passphrase, salt, and key_slot must be provided to escrow LUKS data"
+ err = svc.EscrowLUKSData(ctx, "", "bar", ptr.Uint(0), "")
+ require.Error(t, err)
+ require.True(t, ds.ReportEscrowErrorFuncInvoked)
+
+ ds.ReportEscrowErrorFuncInvoked = false
+ passphrase, salt := "foo", ""
+ var keySlot *uint
+ ds.SaveLUKSDataFunc = func(ctx context.Context, hostID uint, encryptedBase64Passphrase string, encryptedBase64Salt string, keySlotToPersist uint) error {
+ require.Equal(t, host.ID, hostID)
+ key := config.TestConfig().Server.PrivateKey
+
+ decryptedPassphrase, err := mdm.DecodeAndDecrypt(encryptedBase64Passphrase, key)
+ require.NoError(t, err)
+ require.Equal(t, passphrase, decryptedPassphrase)
+
+ decryptedSalt, err := mdm.DecodeAndDecrypt(encryptedBase64Salt, key)
+ require.NoError(t, err)
+ require.Equal(t, salt, decryptedSalt)
+
+ require.Equal(t, *keySlot, keySlotToPersist)
+
+ return nil
+ }
+
+ // with no salt
+ err = svc.EscrowLUKSData(ctx, passphrase, salt, keySlot, "")
+ require.Error(t, err)
+ require.True(t, ds.ReportEscrowErrorFuncInvoked)
+ require.False(t, ds.SaveLUKSDataFuncInvoked)
+
+ // with no key slot
+ ds.ReportEscrowErrorFuncInvoked = false
+ salt = "baz"
+ err = svc.EscrowLUKSData(ctx, passphrase, salt, keySlot, "")
+ require.Error(t, err)
+ require.True(t, ds.ReportEscrowErrorFuncInvoked)
+ require.False(t, ds.SaveLUKSDataFuncInvoked)
+
+ // with salt and key slot
+ keySlot = ptr.Uint(0)
+ ds.ReportEscrowErrorFuncInvoked = false
+ err = svc.EscrowLUKSData(ctx, passphrase, salt, keySlot, "")
+ require.NoError(t, err)
+ require.False(t, ds.ReportEscrowErrorFuncInvoked)
+ require.True(t, ds.SaveLUKSDataFuncInvoked)
+ })
+
+ t.Run("fail when no/invalid private key is set", func(t *testing.T) {
+ ds := new(mock.Store)
+ license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
+ host := &fleet.Host{
+ OsqueryHostID: ptr.String("test"),
+ ID: 1,
+ }
+ expectedErrorMessage := "internal error: missing server private key"
+ ds.ReportEscrowErrorFunc = func(ctx context.Context, hostID uint, err string) error {
+ require.Equal(t, expectedErrorMessage, err)
+ return nil
+ }
+
+ cfg := config.TestConfig()
+ cfg.Server.PrivateKey = ""
+ svc, ctx := newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
+ ctx = test.HostContext(ctx, host)
+ err := svc.EscrowLUKSData(ctx, "foo", "bar", ptr.Uint(0), "")
+ require.Error(t, err)
+ require.True(t, ds.ReportEscrowErrorFuncInvoked)
+
+ expectedErrorMessage = "internal error: could not encrypt LUKS data: create new cipher: crypto/aes: invalid key size 7"
+ ds.ReportEscrowErrorFuncInvoked = false
+ cfg.Server.PrivateKey = "invalid"
+ svc, ctx = newTestServiceWithConfig(t, ds, cfg, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
+ ctx = test.HostContext(ctx, host)
+ err = svc.EscrowLUKSData(ctx, "foo", "bar", ptr.Uint(0), "")
+ require.Error(t, err)
+ require.True(t, ds.ReportEscrowErrorFuncInvoked)
+ })
+}
+
func TestGetOrbitConfigNudge(t *testing.T) {
t.Run("missing values in AppConfig", func(t *testing.T) {
ds := new(mock.Store)
@@ -39,6 +291,9 @@ func TestGetOrbitConfigNudge(t *testing.T) {
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
return true, nil
}
+ ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
+ return false
+ }
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{
@@ -114,6 +369,9 @@ func TestGetOrbitConfigNudge(t *testing.T) {
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
return true, nil
}
+ ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
+ return false
+ }
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{
@@ -161,7 +419,7 @@ func TestGetOrbitConfigNudge(t *testing.T) {
ds.TeamMDMConfigFuncInvoked = false
})
- t.Run("non-elegible MDM status", func(t *testing.T) {
+ t.Run("non-eligible MDM status", func(t *testing.T) {
ds := new(mock.Store)
license := &fleet.LicenseInfo{Tier: fleet.TierPremium}
svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: license, SkipCreateTestUsers: true})
@@ -207,6 +465,9 @@ func TestGetOrbitConfigNudge(t *testing.T) {
ds.GetHostAwaitingConfigurationFunc = func(ctx context.Context, hostUUID string) (bool, error) {
return false, nil
}
+ ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
+ return false
+ }
checkEmptyNudgeConfig := func(h *fleet.Host) {
ctx := test.HostContext(ctx, h)
@@ -283,6 +544,9 @@ func TestGetOrbitConfigNudge(t *testing.T) {
Name: fleet.WellKnownMDMFleet,
}, nil
}
+ ds.IsHostPendingEscrowFunc = func(ctx context.Context, hostID uint) bool {
+ return false
+ }
appCfg := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}
appCfg.MDM.MacOSUpdates.Deadline = optjson.SetString("2022-04-01")
@@ -315,6 +579,7 @@ func TestGetOrbitConfigNudge(t *testing.T) {
ds.GetHostOperatingSystemFuncInvoked = false
cfg, err = svc.GetOrbitConfig(ctx)
require.NoError(t, err)
+ require.False(t, cfg.Notifications.RunDiskEncryptionEscrow)
require.Empty(t, cfg.NudgeConfig)
require.True(t, ds.GetHostOperatingSystemFuncInvoked)
diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go
index 99a0851bdc..1aaee12e7f 100644
--- a/server/service/testing_utils.go
+++ b/server/service/testing_utils.go
@@ -653,7 +653,7 @@ func newMockAPNSPushProviderFactory() (*mock.APNSPushProviderFactory, *mock.APNS
return factory, provider
}
-func mockSuccessfulPush(pushes []*mdm.Push) (map[string]*push.Response, error) {
+func mockSuccessfulPush(_ context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
res := make(map[string]*push.Response, len(pushes))
for _, p := range pushes {
res[p.Token.String()] = &push.Response{
@@ -684,18 +684,13 @@ func mdmConfigurationRequiredEndpoints() []struct {
{"GET", "/api/latest/fleet/mdm/apple/profiles/1", false, false},
{"DELETE", "/api/latest/fleet/mdm/apple/profiles/1", false, false},
{"GET", "/api/latest/fleet/mdm/apple/profiles/summary", false, false},
- {"GET", "/api/latest/fleet/mdm/profiles/summary", false, false},
- {"GET", "/api/latest/fleet/configuration_profiles/summary", false, false},
{"PATCH", "/api/latest/fleet/mdm/hosts/1/unenroll", false, false},
{"DELETE", "/api/latest/fleet/hosts/1/mdm", false, false},
- {"GET", "/api/latest/fleet/mdm/hosts/1/encryption_key", false, false},
- {"GET", "/api/latest/fleet/hosts/1/encryption_key", false, false},
{"GET", "/api/latest/fleet/mdm/hosts/1/profiles", false, true},
{"GET", "/api/latest/fleet/hosts/1/configuration_profiles", false, true},
{"POST", "/api/latest/fleet/mdm/hosts/1/lock", false, false},
{"POST", "/api/latest/fleet/mdm/hosts/1/wipe", false, false},
{"PATCH", "/api/latest/fleet/mdm/apple/settings", false, false},
- {"POST", "/api/latest/fleet/disk_encryption", false, false},
{"GET", "/api/latest/fleet/mdm/apple", false, false},
{"GET", "/api/latest/fleet/apns", false, false},
{"GET", apple_mdm.EnrollPath + "?token=test", false, false},
@@ -725,8 +720,6 @@ func mdmConfigurationRequiredEndpoints() []struct {
{"GET", "/api/latest/fleet/mdm/commands", false, false},
{"GET", "/api/latest/fleet/commands", false, false},
{"POST", "/api/fleet/orbit/disk_encryption_key", false, false},
- {"GET", "/api/latest/fleet/mdm/disk_encryption/summary", false, true},
- {"GET", "/api/latest/fleet/disk_encryption", false, true},
{"GET", "/api/latest/fleet/mdm/profiles/1", false, false},
{"GET", "/api/latest/fleet/configuration_profiles/1", false, false},
{"DELETE", "/api/latest/fleet/mdm/profiles/1", false, false},
diff --git a/server/test/new_objects.go b/server/test/new_objects.go
index 8a285783e5..f56496faea 100644
--- a/server/test/new_objects.go
+++ b/server/test/new_objects.go
@@ -217,6 +217,12 @@ func WithPlatform(s string) NewHostOption {
}
}
+func WithOSVersion(s string) NewHostOption {
+ return func(h *fleet.Host) {
+ h.OSVersion = s
+ }
+}
+
func WithTeamID(teamID uint) NewHostOption {
return func(h *fleet.Host) {
h.TeamID = &teamID
diff --git a/server/vulnerabilities/nvd/tools/README.md b/server/vulnerabilities/nvd/tools/README.md
index d5685d52c0..67dae52d6f 100644
--- a/server/vulnerabilities/nvd/tools/README.md
+++ b/server/vulnerabilities/nvd/tools/README.md
@@ -126,7 +126,7 @@ host2.foo.bar CVE-2017-8817 cpe:/a:haxx:curl:7.55.0
### `csv2cpe`
-*csv2cpe* is a tool that generates an URI-bound CPE from CSV input, flags configure the meaning of each input field:
+*csv2cpe* is a tool that generates a URI-bound CPE from CSV input, flags configure the meaning of each input field:
* `-cpe_part` -- identifies the class of a product: h for hardware, a for application and o for OS
* `-cpe_vendor` -- identifies the person or organisation that manufactured or created the product
diff --git a/terraform/README.md b/terraform/README.md
index f8015f428c..243586d485 100644
--- a/terraform/README.md
+++ b/terraform/README.md
@@ -72,7 +72,7 @@ No resources.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
-| [alb\_config](#input\_alb\_config) | n/a | object({
name = optional(string, "fleet")
security_groups = optional(list(string), [])
access_logs = optional(map(string), {})
allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
allowed_ipv6_cidrs = optional(list(string), ["::/0"])
egress_cidrs = optional(list(string), ["0.0.0.0/0"])
egress_ipv6_cidrs = optional(list(string), ["::/0"])
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
idle_timeout = optional(number, 60)
}) | `{}` | no |
+| [alb\_config](#input\_alb\_config) | n/a | object({
name = optional(string, "fleet")
security_groups = optional(list(string), [])
access_logs = optional(map(string), {})
allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
allowed_ipv6_cidrs = optional(list(string), ["::/0"])
egress_cidrs = optional(list(string), ["0.0.0.0/0"])
egress_ipv6_cidrs = optional(list(string), ["::/0"])
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
idle_timeout = optional(number, 905)
}) | `{}` | no |
| [certificate\_arn](#input\_certificate\_arn) | n/a | `string` | n/a | yes |
| [ecs\_cluster](#input\_ecs\_cluster) | The config for the terraform-aws-modules/ecs/aws module | object({
autoscaling_capacity_providers = optional(any, {})
cluster_configuration = optional(any, {
execute_command_configuration = {
logging = "OVERRIDE"
log_configuration = {
cloud_watch_log_group_name = "/aws/ecs/aws-ec2"
}
}
})
cluster_name = optional(string, "fleet")
cluster_settings = optional(map(string), {
"name" : "containerInsights",
"value" : "enabled",
})
create = optional(bool, true)
default_capacity_provider_use_fargate = optional(bool, true)
fargate_capacity_providers = optional(any, {
FARGATE = {
default_capacity_provider_strategy = {
weight = 100
}
}
FARGATE_SPOT = {
default_capacity_provider_strategy = {
weight = 0
}
}
})
tags = optional(map(string))
}) | {
"autoscaling_capacity_providers": {},
"cluster_configuration": {
"execute_command_configuration": {
"log_configuration": {
"cloud_watch_log_group_name": "/aws/ecs/aws-ec2"
},
"logging": "OVERRIDE"
}
},
"cluster_name": "fleet",
"cluster_settings": {
"name": "containerInsights",
"value": "enabled"
},
"create": true,
"default_capacity_provider_use_fargate": true,
"fargate_capacity_providers": {
"FARGATE": {
"default_capacity_provider_strategy": {
"weight": 100
}
},
"FARGATE_SPOT": {
"default_capacity_provider_strategy": {
"weight": 0
}
}
},
"tags": {}
} | no |
| [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. | object({
task_mem = optional(number, null)
task_cpu = optional(number, null)
mem = optional(number, 4096)
cpu = optional(number, 512)
pid_mode = optional(string, null)
image = optional(string, "fleetdm/fleet:v4.54.1")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])
mount_points = optional(list(any), [])
volumes = optional(list(any), [])
extra_environment_variables = optional(map(string), {})
extra_iam_policies = optional(list(string), [])
extra_execution_iam_policies = optional(list(string), [])
extra_secrets = optional(map(string), {})
security_group_name = optional(string, "fleet")
iam_role_arn = optional(string, null)
repository_credentials = optional(string, "")
private_key_secret_name = optional(string, "fleet-server-private-key")
service = optional(object({
name = optional(string, "fleet")
}), {
name = "fleet"
})
database = optional(object({
password_secret_arn = string
user = string
database = string
address = string
rr_address = optional(string, null)
}), {
password_secret_arn = null
user = null
database = null
address = null
rr_address = null
})
redis = optional(object({
address = string
use_tls = optional(bool, true)
}), {
address = null
use_tls = true
})
awslogs = optional(object({
name = optional(string, null)
region = optional(string, null)
create = optional(bool, true)
prefix = optional(string, "fleet")
retention = optional(number, 5)
}), {
name = null
region = null
prefix = "fleet"
retention = 5
})
loadbalancer = optional(object({
arn = string
}), {
arn = null
})
extra_load_balancers = optional(list(any), [])
networking = optional(object({
subnets = optional(list(string), null)
security_groups = optional(list(string), null)
ingress_sources = optional(object({
cidr_blocks = optional(list(string), [])
ipv6_cidr_blocks = optional(list(string), [])
security_groups = optional(list(string), [])
prefix_list_ids = optional(list(string), [])
}), {
cidr_blocks = []
ipv6_cidr_blocks = []
security_groups = []
prefix_list_ids = []
})
}), {
subnets = null
security_groups = null
ingress_sources = {
cidr_blocks = []
ipv6_cidr_blocks = []
security_groups = []
prefix_list_ids = []
}
})
autoscaling = optional(object({
max_capacity = optional(number, 5)
min_capacity = optional(number, 1)
memory_tracking_target_value = optional(number, 80)
cpu_tracking_target_value = optional(number, 80)
}), {
max_capacity = 5
min_capacity = 1
memory_tracking_target_value = 80
cpu_tracking_target_value = 80
})
iam = optional(object({
role = optional(object({
name = optional(string, "fleet-role")
policy_name = optional(string, "fleet-iam-policy")
}), {
name = "fleet-role"
policy_name = "fleet-iam-policy"
})
execution = optional(object({
name = optional(string, "fleet-execution-role")
policy_name = optional(string, "fleet-execution-role")
}), {
name = "fleet-execution-role"
policy_name = "fleet-iam-policy-execution"
})
}), {
name = "fleetdm-execution-role"
})
software_installers = optional(object({
create_bucket = optional(bool, true)
bucket_name = optional(string, null)
bucket_prefix = optional(string, "fleet-software-installers-")
s3_object_prefix = optional(string, "")
}), {
create_bucket = true
bucket_name = null
bucket_prefix = "fleet-software-installers-"
s3_object_prefix = ""
})
}) | {
"autoscaling": {
"cpu_tracking_target_value": 80,
"max_capacity": 5,
"memory_tracking_target_value": 80,
"min_capacity": 1
},
"awslogs": {
"create": true,
"name": null,
"prefix": "fleet",
"region": null,
"retention": 5
},
"cpu": 256,
"database": {
"address": null,
"database": null,
"password_secret_arn": null,
"rr_address": null,
"user": null
},
"depends_on": [],
"extra_environment_variables": {},
"extra_execution_iam_policies": [],
"extra_iam_policies": [],
"extra_load_balancers": [],
"extra_secrets": {},
"family": "fleet",
"iam": {
"execution": {
"name": "fleet-execution-role",
"policy_name": "fleet-iam-policy-execution"
},
"role": {
"name": "fleet-role",
"policy_name": "fleet-iam-policy"
}
},
"iam_role_arn": null,
"image": "fleetdm/fleet:v4.54.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"ingress_sources": {
"cidr_blocks": [],
"ipv6_cidr_blocks": [],
"prefix_list_ids": [],
"security_groups": []
},
"security_groups": null,
"subnets": null
},
"pid_mode": null,
"private_key_secret_name": "fleet-server-private-key",
"redis": {
"address": null,
"use_tls": true
},
"repository_credentials": "",
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"software_installers": {
"bucket_name": null,
"bucket_prefix": "fleet-software-installers-",
"create_bucket": true,
"s3_object_prefix": ""
},
"task_cpu": null,
"task_mem": null,
"volumes": []
} | no |
diff --git a/terraform/addons/mdmproxy/variables.tf b/terraform/addons/mdmproxy/variables.tf
index cb3d924d25..cd743e0276 100644
--- a/terraform/addons/mdmproxy/variables.tf
+++ b/terraform/addons/mdmproxy/variables.tf
@@ -79,7 +79,7 @@ variable "alb_config" {
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
- idle_timeout = optional(number, 60)
+ idle_timeout = optional(number, 905)
})
}
diff --git a/terraform/addons/vuln-processing/variables.tf b/terraform/addons/vuln-processing/variables.tf
index 17947300da..b372a36ff8 100644
--- a/terraform/addons/vuln-processing/variables.tf
+++ b/terraform/addons/vuln-processing/variables.tf
@@ -24,7 +24,7 @@ variable "fleet_config" {
vuln_processing_cpu = optional(number, 2048)
vuln_data_stream_mem = optional(number, 1024)
vuln_data_stream_cpu = optional(number, 512)
- image = optional(string, "fleetdm/fleet:v4.59.0")
+ image = optional(string, "fleetdm/fleet:v4.59.1")
family = optional(string, "fleet-vuln-processing")
sidecars = optional(list(any), [])
extra_environment_variables = optional(map(string), {})
@@ -82,7 +82,7 @@ variable "fleet_config" {
vuln_processing_cpu = 2048
vuln_data_stream_mem = 1024
vuln_data_stream_cpu = 512
- image = "fleetdm/fleet:v4.59.0"
+ image = "fleetdm/fleet:v4.59.1"
family = "fleet-vuln-processing"
sidecars = []
extra_environment_variables = {}
diff --git a/terraform/byo-vpc/README.md b/terraform/byo-vpc/README.md
index 16998b14b1..c40128a9da 100644
--- a/terraform/byo-vpc/README.md
+++ b/terraform/byo-vpc/README.md
@@ -31,7 +31,7 @@ No requirements.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
-| [alb\_config](#input\_alb\_config) | n/a | object({
name = optional(string, "fleet")
subnets = list(string)
security_groups = optional(list(string), [])
access_logs = optional(map(string), {})
certificate_arn = string
allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
allowed_ipv6_cidrs = optional(list(string), ["::/0"])
egress_cidrs = optional(list(string), ["0.0.0.0/0"])
egress_ipv6_cidrs = optional(list(string), ["::/0"])
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
idle_timeout = optional(number, 60)
}) | n/a | yes |
+| [alb\_config](#input\_alb\_config) | n/a | object({
name = optional(string, "fleet")
subnets = list(string)
security_groups = optional(list(string), [])
access_logs = optional(map(string), {})
certificate_arn = string
allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
allowed_ipv6_cidrs = optional(list(string), ["::/0"])
egress_cidrs = optional(list(string), ["0.0.0.0/0"])
egress_ipv6_cidrs = optional(list(string), ["::/0"])
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
idle_timeout = optional(number, 905)
}) | n/a | yes |
| [ecs\_cluster](#input\_ecs\_cluster) | The config for the terraform-aws-modules/ecs/aws module | object({
autoscaling_capacity_providers = optional(any, {})
cluster_configuration = optional(any, {
execute_command_configuration = {
logging = "OVERRIDE"
log_configuration = {
cloud_watch_log_group_name = "/aws/ecs/aws-ec2"
}
}
})
cluster_name = optional(string, "fleet")
cluster_settings = optional(map(string), {
"name" : "containerInsights",
"value" : "enabled",
})
create = optional(bool, true)
default_capacity_provider_use_fargate = optional(bool, true)
fargate_capacity_providers = optional(any, {
FARGATE = {
default_capacity_provider_strategy = {
weight = 100
}
}
FARGATE_SPOT = {
default_capacity_provider_strategy = {
weight = 0
}
}
})
tags = optional(map(string))
}) | {
"autoscaling_capacity_providers": {},
"cluster_configuration": {
"execute_command_configuration": {
"log_configuration": {
"cloud_watch_log_group_name": "/aws/ecs/aws-ec2"
},
"logging": "OVERRIDE"
}
},
"cluster_name": "fleet",
"cluster_settings": {
"name": "containerInsights",
"value": "enabled"
},
"create": true,
"default_capacity_provider_use_fargate": true,
"fargate_capacity_providers": {
"FARGATE": {
"default_capacity_provider_strategy": {
"weight": 100
}
},
"FARGATE_SPOT": {
"default_capacity_provider_strategy": {
"weight": 0
}
}
},
"tags": {}
} | no |
| [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. | object({
task_mem = optional(number, null)
task_cpu = optional(number, null)
mem = optional(number, 4096)
cpu = optional(number, 512)
pid_mode = optional(string, null)
image = optional(string, "fleetdm/fleet:v4.54.1")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])
mount_points = optional(list(any), [])
volumes = optional(list(any), [])
extra_environment_variables = optional(map(string), {})
extra_iam_policies = optional(list(string), [])
extra_execution_iam_policies = optional(list(string), [])
extra_secrets = optional(map(string), {})
security_group_name = optional(string, "fleet")
iam_role_arn = optional(string, null)
repository_credentials = optional(string, "")
private_key_secret_name = optional(string, "fleet-server-private-key")
service = optional(object({
name = optional(string, "fleet")
}), {
name = "fleet"
})
database = optional(object({
password_secret_arn = string
user = string
database = string
address = string
rr_address = optional(string, null)
}), {
password_secret_arn = null
user = null
database = null
address = null
rr_address = null
})
redis = optional(object({
address = string
use_tls = optional(bool, true)
}), {
address = null
use_tls = true
})
awslogs = optional(object({
name = optional(string, null)
region = optional(string, null)
create = optional(bool, true)
prefix = optional(string, "fleet")
retention = optional(number, 5)
}), {
name = null
region = null
prefix = "fleet"
retention = 5
})
loadbalancer = optional(object({
arn = string
}), {
arn = null
})
extra_load_balancers = optional(list(any), [])
networking = optional(object({
subnets = optional(list(string), null)
security_groups = optional(list(string), null)
ingress_sources = optional(object({
cidr_blocks = optional(list(string), [])
ipv6_cidr_blocks = optional(list(string), [])
security_groups = optional(list(string), [])
prefix_list_ids = optional(list(string), [])
}), {
cidr_blocks = []
ipv6_cidr_blocks = []
security_groups = []
prefix_list_ids = []
})
}), {
subnets = null
security_groups = null
ingress_sources = {
cidr_blocks = []
ipv6_cidr_blocks = []
security_groups = []
prefix_list_ids = []
}
})
autoscaling = optional(object({
max_capacity = optional(number, 5)
min_capacity = optional(number, 1)
memory_tracking_target_value = optional(number, 80)
cpu_tracking_target_value = optional(number, 80)
}), {
max_capacity = 5
min_capacity = 1
memory_tracking_target_value = 80
cpu_tracking_target_value = 80
})
iam = optional(object({
role = optional(object({
name = optional(string, "fleet-role")
policy_name = optional(string, "fleet-iam-policy")
}), {
name = "fleet-role"
policy_name = "fleet-iam-policy"
})
execution = optional(object({
name = optional(string, "fleet-execution-role")
policy_name = optional(string, "fleet-execution-role")
}), {
name = "fleet-execution-role"
policy_name = "fleet-iam-policy-execution"
})
}), {
name = "fleetdm-execution-role"
})
software_installers = optional(object({
create_bucket = optional(bool, true)
bucket_name = optional(string, null)
bucket_prefix = optional(string, "fleet-software-installers-")
s3_object_prefix = optional(string, "")
}), {
create_bucket = true
bucket_name = null
bucket_prefix = "fleet-software-installers-"
s3_object_prefix = ""
})
}) | {
"autoscaling": {
"cpu_tracking_target_value": 80,
"max_capacity": 5,
"memory_tracking_target_value": 80,
"min_capacity": 1
},
"awslogs": {
"create": true,
"name": null,
"prefix": "fleet",
"region": null,
"retention": 5
},
"cpu": 256,
"database": {
"address": null,
"database": null,
"password_secret_arn": null,
"rr_address": null,
"user": null
},
"depends_on": [],
"extra_environment_variables": {},
"extra_execution_iam_policies": [],
"extra_iam_policies": [],
"extra_load_balancers": [],
"extra_secrets": {},
"family": "fleet",
"iam": {
"execution": {
"name": "fleet-execution-role",
"policy_name": "fleet-iam-policy-execution"
},
"role": {
"name": "fleet-role",
"policy_name": "fleet-iam-policy"
}
},
"iam_role_arn": null,
"image": "fleetdm/fleet:v4.54.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"ingress_sources": {
"cidr_blocks": [],
"ipv6_cidr_blocks": [],
"prefix_list_ids": [],
"security_groups": []
},
"security_groups": null,
"subnets": null
},
"pid_mode": null,
"private_key_secret_name": "fleet-server-private-key",
"redis": {
"address": null,
"use_tls": true
},
"repository_credentials": "",
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"software_installers": {
"bucket_name": null,
"bucket_prefix": "fleet-software-installers-",
"create_bucket": true,
"s3_object_prefix": ""
},
"task_cpu": null,
"task_mem": null,
"volumes": []
} | no |
| [migration\_config](#input\_migration\_config) | The configuration object for Fleet's migration task. | object({
mem = number
cpu = number
}) | {
"cpu": 1024,
"mem": 2048
} | no |
diff --git a/terraform/byo-vpc/byo-db/README.md b/terraform/byo-vpc/byo-db/README.md
index 60d1444489..ddc7c0e656 100644
--- a/terraform/byo-vpc/byo-db/README.md
+++ b/terraform/byo-vpc/byo-db/README.md
@@ -26,7 +26,7 @@ No requirements.
| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
-| [alb\_config](#input\_alb\_config) | n/a | object({
name = optional(string, "fleet")
subnets = list(string)
security_groups = optional(list(string), [])
access_logs = optional(map(string), {})
certificate_arn = string
allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
allowed_ipv6_cidrs = optional(list(string), ["::/0"])
egress_cidrs = optional(list(string), ["0.0.0.0/0"])
egress_ipv6_cidrs = optional(list(string), ["::/0"])
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
idle_timeout = optional(number, 60)
}) | n/a | yes |
+| [alb\_config](#input\_alb\_config) | n/a | object({
name = optional(string, "fleet")
subnets = list(string)
security_groups = optional(list(string), [])
access_logs = optional(map(string), {})
certificate_arn = string
allowed_cidrs = optional(list(string), ["0.0.0.0/0"])
allowed_ipv6_cidrs = optional(list(string), ["::/0"])
egress_cidrs = optional(list(string), ["0.0.0.0/0"])
egress_ipv6_cidrs = optional(list(string), ["::/0"])
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
idle_timeout = optional(number, 905)
}) | n/a | yes |
| [ecs\_cluster](#input\_ecs\_cluster) | The config for the terraform-aws-modules/ecs/aws module | object({
autoscaling_capacity_providers = optional(any, {})
cluster_configuration = optional(any, {
execute_command_configuration = {
logging = "OVERRIDE"
log_configuration = {
cloud_watch_log_group_name = "/aws/ecs/aws-ec2"
}
}
})
cluster_name = optional(string, "fleet")
cluster_settings = optional(map(string), {
"name" : "containerInsights",
"value" : "enabled",
})
create = optional(bool, true)
default_capacity_provider_use_fargate = optional(bool, true)
fargate_capacity_providers = optional(any, {
FARGATE = {
default_capacity_provider_strategy = {
weight = 100
}
}
FARGATE_SPOT = {
default_capacity_provider_strategy = {
weight = 0
}
}
})
tags = optional(map(string))
}) | {
"autoscaling_capacity_providers": {},
"cluster_configuration": {
"execute_command_configuration": {
"log_configuration": {
"cloud_watch_log_group_name": "/aws/ecs/aws-ec2"
},
"logging": "OVERRIDE"
}
},
"cluster_name": "fleet",
"cluster_settings": {
"name": "containerInsights",
"value": "enabled"
},
"create": true,
"default_capacity_provider_use_fargate": true,
"fargate_capacity_providers": {
"FARGATE": {
"default_capacity_provider_strategy": {
"weight": 100
}
},
"FARGATE_SPOT": {
"default_capacity_provider_strategy": {
"weight": 0
}
}
},
"tags": {}
} | no |
| [fleet\_config](#input\_fleet\_config) | The configuration object for Fleet itself. Fields that default to null will have their respective resources created if not specified. | object({
task_mem = optional(number, null)
task_cpu = optional(number, null)
mem = optional(number, 4096)
cpu = optional(number, 512)
pid_mode = optional(string, null)
image = optional(string, "fleetdm/fleet:v4.54.1")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])
mount_points = optional(list(any), [])
volumes = optional(list(any), [])
extra_environment_variables = optional(map(string), {})
extra_iam_policies = optional(list(string), [])
extra_execution_iam_policies = optional(list(string), [])
extra_secrets = optional(map(string), {})
security_group_name = optional(string, "fleet")
iam_role_arn = optional(string, null)
repository_credentials = optional(string, "")
private_key_secret_name = optional(string, "fleet-server-private-key")
service = optional(object({
name = optional(string, "fleet")
}), {
name = "fleet"
})
database = optional(object({
password_secret_arn = string
user = string
database = string
address = string
rr_address = optional(string, null)
}), {
password_secret_arn = null
user = null
database = null
address = null
rr_address = null
})
redis = optional(object({
address = string
use_tls = optional(bool, true)
}), {
address = null
use_tls = true
})
awslogs = optional(object({
name = optional(string, null)
region = optional(string, null)
create = optional(bool, true)
prefix = optional(string, "fleet")
retention = optional(number, 5)
}), {
name = null
region = null
prefix = "fleet"
retention = 5
})
loadbalancer = optional(object({
arn = string
}), {
arn = null
})
extra_load_balancers = optional(list(any), [])
networking = optional(object({
subnets = optional(list(string), null)
security_groups = optional(list(string), null)
ingress_sources = optional(object({
cidr_blocks = optional(list(string), [])
ipv6_cidr_blocks = optional(list(string), [])
security_groups = optional(list(string), [])
prefix_list_ids = optional(list(string), [])
}), {
cidr_blocks = []
ipv6_cidr_blocks = []
security_groups = []
prefix_list_ids = []
})
}), {
subnets = null
security_groups = null
ingress_sources = {
cidr_blocks = []
ipv6_cidr_blocks = []
security_groups = []
prefix_list_ids = []
}
})
autoscaling = optional(object({
max_capacity = optional(number, 5)
min_capacity = optional(number, 1)
memory_tracking_target_value = optional(number, 80)
cpu_tracking_target_value = optional(number, 80)
}), {
max_capacity = 5
min_capacity = 1
memory_tracking_target_value = 80
cpu_tracking_target_value = 80
})
iam = optional(object({
role = optional(object({
name = optional(string, "fleet-role")
policy_name = optional(string, "fleet-iam-policy")
}), {
name = "fleet-role"
policy_name = "fleet-iam-policy"
})
execution = optional(object({
name = optional(string, "fleet-execution-role")
policy_name = optional(string, "fleet-execution-role")
}), {
name = "fleet-execution-role"
policy_name = "fleet-iam-policy-execution"
})
}), {
name = "fleetdm-execution-role"
})
software_installers = optional(object({
create_bucket = optional(bool, true)
bucket_name = optional(string, null)
bucket_prefix = optional(string, "fleet-software-installers-")
s3_object_prefix = optional(string, "")
}), {
create_bucket = true
bucket_name = null
bucket_prefix = "fleet-software-installers-"
s3_object_prefix = ""
})
}) | {
"autoscaling": {
"cpu_tracking_target_value": 80,
"max_capacity": 5,
"memory_tracking_target_value": 80,
"min_capacity": 1
},
"awslogs": {
"create": true,
"name": null,
"prefix": "fleet",
"region": null,
"retention": 5
},
"cpu": 256,
"database": {
"address": null,
"database": null,
"password_secret_arn": null,
"rr_address": null,
"user": null
},
"depends_on": [],
"extra_environment_variables": {},
"extra_execution_iam_policies": [],
"extra_iam_policies": [],
"extra_load_balancers": [],
"extra_secrets": {},
"family": "fleet",
"iam": {
"execution": {
"name": "fleet-execution-role",
"policy_name": "fleet-iam-policy-execution"
},
"role": {
"name": "fleet-role",
"policy_name": "fleet-iam-policy"
}
},
"iam_role_arn": null,
"image": "fleetdm/fleet:v4.54.1",
"loadbalancer": {
"arn": null
},
"mem": 512,
"mount_points": [],
"networking": {
"ingress_sources": {
"cidr_blocks": [],
"ipv6_cidr_blocks": [],
"prefix_list_ids": [],
"security_groups": []
},
"security_groups": null,
"subnets": null
},
"pid_mode": null,
"private_key_secret_name": "fleet-server-private-key",
"redis": {
"address": null,
"use_tls": true
},
"repository_credentials": "",
"security_group_name": "fleet",
"security_groups": null,
"service": {
"name": "fleet"
},
"sidecars": [],
"software_installers": {
"bucket_name": null,
"bucket_prefix": "fleet-software-installers-",
"create_bucket": true,
"s3_object_prefix": ""
},
"task_cpu": null,
"task_mem": null,
"volumes": []
} | no |
| [migration\_config](#input\_migration\_config) | The configuration object for Fleet's migration task. | object({
mem = number
cpu = number
}) | {
"cpu": 1024,
"mem": 2048
} | no |
diff --git a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf
index 049841c1f7..580e94cbf5 100644
--- a/terraform/byo-vpc/byo-db/byo-ecs/variables.tf
+++ b/terraform/byo-vpc/byo-db/byo-ecs/variables.tf
@@ -16,7 +16,7 @@ variable "fleet_config" {
mem = optional(number, 4096)
cpu = optional(number, 512)
pid_mode = optional(string, null)
- image = optional(string, "fleetdm/fleet:v4.59.0")
+ image = optional(string, "fleetdm/fleet:v4.59.1")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])
@@ -119,7 +119,7 @@ variable "fleet_config" {
mem = 512
cpu = 256
pid_mode = null
- image = "fleetdm/fleet:v4.59.0"
+ image = "fleetdm/fleet:v4.59.1"
family = "fleet"
sidecars = []
depends_on = []
diff --git a/terraform/byo-vpc/byo-db/variables.tf b/terraform/byo-vpc/byo-db/variables.tf
index 20040d516c..16c7d7a1e9 100644
--- a/terraform/byo-vpc/byo-db/variables.tf
+++ b/terraform/byo-vpc/byo-db/variables.tf
@@ -77,7 +77,7 @@ variable "fleet_config" {
mem = optional(number, 4096)
cpu = optional(number, 512)
pid_mode = optional(string, null)
- image = optional(string, "fleetdm/fleet:v4.59.0")
+ image = optional(string, "fleetdm/fleet:v4.59.1")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])
@@ -205,7 +205,7 @@ variable "fleet_config" {
mem = 512
cpu = 256
pid_mode = null
- image = "fleetdm/fleet:v4.59.0"
+ image = "fleetdm/fleet:v4.59.1"
family = "fleet"
sidecars = []
depends_on = []
@@ -309,6 +309,6 @@ variable "alb_config" {
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
- idle_timeout = optional(number, 60)
+ idle_timeout = optional(number, 905)
})
}
diff --git a/terraform/byo-vpc/example/main.tf b/terraform/byo-vpc/example/main.tf
index 8b0eefb3be..855ab59f9f 100644
--- a/terraform/byo-vpc/example/main.tf
+++ b/terraform/byo-vpc/example/main.tf
@@ -17,7 +17,7 @@ provider "aws" {
}
locals {
- fleet_image = "fleetdm/fleet:v4.59.0"
+ fleet_image = "fleetdm/fleet:v4.59.1"
domain_name = "example.com"
}
diff --git a/terraform/byo-vpc/variables.tf b/terraform/byo-vpc/variables.tf
index 593d3a390f..4c8e173387 100644
--- a/terraform/byo-vpc/variables.tf
+++ b/terraform/byo-vpc/variables.tf
@@ -170,7 +170,7 @@ variable "fleet_config" {
mem = optional(number, 4096)
cpu = optional(number, 512)
pid_mode = optional(string, null)
- image = optional(string, "fleetdm/fleet:v4.59.0")
+ image = optional(string, "fleetdm/fleet:v4.59.1")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])
@@ -298,7 +298,7 @@ variable "fleet_config" {
mem = 512
cpu = 256
pid_mode = null
- image = "fleetdm/fleet:v4.59.0"
+ image = "fleetdm/fleet:v4.59.1"
family = "fleet"
sidecars = []
depends_on = []
@@ -402,6 +402,6 @@ variable "alb_config" {
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
- idle_timeout = optional(number, 60)
+ idle_timeout = optional(number, 905)
})
}
diff --git a/terraform/example/main.tf b/terraform/example/main.tf
index 8b92f669be..81ff3cd693 100644
--- a/terraform/example/main.tf
+++ b/terraform/example/main.tf
@@ -63,8 +63,8 @@ module "fleet" {
fleet_config = {
# To avoid pull-rate limiting from dockerhub, consider using our quay.io mirror
- # for the Fleet image. e.g. "quay.io/fleetdm/fleet:v4.59.0"
- image = "fleetdm/fleet:v4.59.0" # override default to deploy the image you desire
+ # for the Fleet image. e.g. "quay.io/fleetdm/fleet:v4.59.1"
+ image = "fleetdm/fleet:v4.59.1" # override default to deploy the image you desire
# See https://fleetdm.com/docs/deploy/reference-architectures#aws for appropriate scaling
# memory and cpu.
autoscaling = {
@@ -108,7 +108,7 @@ module "fleet" {
alb_config = {
# Script execution can run for up to 300s plus overhead.
# Ensure the load balancer does not 5XX before we have results.
- idle_timeout = 605
+ idle_timeout = 905
}
}
diff --git a/terraform/variables.tf b/terraform/variables.tf
index 34c6d7a1f5..6bb2f22317 100644
--- a/terraform/variables.tf
+++ b/terraform/variables.tf
@@ -218,7 +218,7 @@ variable "fleet_config" {
mem = optional(number, 4096)
cpu = optional(number, 512)
pid_mode = optional(string, null)
- image = optional(string, "fleetdm/fleet:v4.59.0")
+ image = optional(string, "fleetdm/fleet:v4.59.1")
family = optional(string, "fleet")
sidecars = optional(list(any), [])
depends_on = optional(list(any), [])
@@ -346,7 +346,7 @@ variable "fleet_config" {
mem = 512
cpu = 256
pid_mode = null
- image = "fleetdm/fleet:v4.59.0"
+ image = "fleetdm/fleet:v4.59.1"
family = "fleet"
sidecars = []
depends_on = []
@@ -448,7 +448,7 @@ variable "alb_config" {
extra_target_groups = optional(any, [])
https_listener_rules = optional(any, [])
tls_policy = optional(string, "ELBSecurityPolicy-TLS-1-2-2017-01")
- idle_timeout = optional(number, 60)
+ idle_timeout = optional(number, 905)
})
default = {}
}
diff --git a/tools/apm-elastic/README.md b/tools/apm-elastic/README.md
index 7d29e7aa8a..2f0aca4429 100644
--- a/tools/apm-elastic/README.md
+++ b/tools/apm-elastic/README.md
@@ -5,8 +5,8 @@
To setup a full Elastic APM stack, from this directory, run:
```
-$ docker-compose up -d
-$ docker-compose exec apm-server ./apm-server setup
+$ docker compose up -d
+$ docker compose exec apm-server ./apm-server setup
```
Give it a few seconds to complete setup, and then you should be able to view the APM website at `http://localhost:5601`.
diff --git a/tools/apm-elastic/docker-compose.yml b/tools/apm-elastic/docker-compose.yml
index 9caf3a042a..ee8a20700c 100644
--- a/tools/apm-elastic/docker-compose.yml
+++ b/tools/apm-elastic/docker-compose.yml
@@ -18,14 +18,14 @@
version: "3"
services:
elasticsearch:
- image: docker.elastic.co/elasticsearch/elasticsearch:7.8.0
+ image: docker.elastic.co/elasticsearch/elasticsearch:7.17.25
ports:
- "9200:9200"
- "9300:9300"
environment:
- discovery.type=single-node
kibana:
- image: docker.elastic.co/kibana/kibana:7.8.0
+ image: docker.elastic.co/kibana/kibana:7.17.25
ports:
- "5601:5601"
links:
@@ -33,7 +33,7 @@ services:
depends_on:
- elasticsearch
apm-server:
- image: docker.elastic.co/apm/apm-server:7.8.0
+ image: docker.elastic.co/apm/apm-server:7.17.25
ports:
- "8200:8200"
volumes:
diff --git a/tools/dialog/main.go b/tools/dialog/main.go
new file mode 100644
index 0000000000..23e46da66c
--- /dev/null
+++ b/tools/dialog/main.go
@@ -0,0 +1,55 @@
+package main
+
+// This is a tool to test the zenity package on Linux
+// It will show an entry dialog, a progress dialog, and an info dialog
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/fleetdm/fleet/v4/orbit/pkg/dialog"
+ "github.com/fleetdm/fleet/v4/orbit/pkg/zenity"
+)
+
+func main() {
+ prompt := zenity.New()
+ ctx := context.Background()
+
+ output, err := prompt.ShowEntry(ctx, dialog.EntryOptions{
+ Title: "Zenity Test Entry Title",
+ Text: "Zenity Test Entry Text",
+ HideText: true,
+ TimeOut: 10 * time.Second,
+ })
+ if err != nil {
+ fmt.Println("Err ShowEntry")
+ panic(err)
+ }
+
+ ctx, cancelProgress := context.WithCancel(context.Background())
+
+ go func() {
+ err := prompt.ShowProgress(ctx, dialog.ProgressOptions{
+ Title: "Zenity Test Progress Title",
+ Text: "Zenity Test Progress Text",
+ })
+ if err != nil {
+ fmt.Println("Err ShowProgress")
+ panic(err)
+ }
+ }()
+
+ time.Sleep(2 * time.Second)
+ cancelProgress()
+
+ err = prompt.ShowInfo(ctx, dialog.InfoOptions{
+ Title: "Zenity Test Info Title",
+ Text: "Result: " + string(output),
+ TimeOut: 10 * time.Second,
+ })
+ if err != nil {
+ fmt.Println("Err ShowInfo")
+ panic(err)
+ }
+}
diff --git a/tools/fleetctl-npm/package.json b/tools/fleetctl-npm/package.json
index 4832a3f837..d9d07156ca 100644
--- a/tools/fleetctl-npm/package.json
+++ b/tools/fleetctl-npm/package.json
@@ -1,6 +1,6 @@
{
"name": "fleetctl",
- "version": "v4.59.0",
+ "version": "v4.59.1",
"description": "Installer for the fleetctl CLI tool",
"bin": {
"fleetctl": "./run.js"
diff --git a/tools/luks/luks/main.go b/tools/luks/luks/main.go
new file mode 100644
index 0000000000..f20c28f4e2
--- /dev/null
+++ b/tools/luks/luks/main.go
@@ -0,0 +1,72 @@
+//go:build linux
+
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/fleetdm/fleet/v4/orbit/pkg/dialog"
+ "github.com/fleetdm/fleet/v4/orbit/pkg/lvm"
+ "github.com/fleetdm/fleet/v4/orbit/pkg/zenity"
+ "github.com/siderolabs/go-blockdevice/v2/encryption"
+ "github.com/siderolabs/go-blockdevice/v2/encryption/luks"
+)
+
+func main() {
+ devicePath, err := lvm.FindRootDisk()
+ if err != nil {
+ fmt.Println("devicepath err:", err)
+ panic(err)
+ }
+
+ prompt := zenity.New()
+
+ // Prompt existing passphrase from the user.
+ currentPassphrase, err := prompt.ShowEntry(context.Background(), dialog.EntryOptions{
+ Title: "Enter Existing LUKS Passphrase",
+ Text: "Enter your existing LUKS passphrase:",
+ HideText: true,
+ })
+ if err != nil {
+ fmt.Println("Err ShowEntry")
+ panic(err)
+ }
+
+ const escrowPassPhrase = "fleet123"
+
+ device := luks.New(luks.AESXTSPlain64Cipher)
+
+ keySlot := 1
+ for {
+ if keySlot == 8 {
+ panic(errors.New("all LUKS key slots are full"))
+ }
+
+ userKey := encryption.NewKey(0, currentPassphrase)
+ escrowKey := encryption.NewKey(keySlot, []byte(escrowPassPhrase))
+
+ if err := device.AddKey(context.Background(), devicePath, userKey, escrowKey); err != nil {
+ if errors.Is(err, encryption.ErrEncryptionKeyRejected) {
+ currentPassphrase, err = prompt.ShowEntry(context.Background(), dialog.EntryOptions{
+ Title: "Enter Existing LUKS Passphrase",
+ Text: "Bad password. Enter your existing LUKS passphrase:",
+ HideText: true,
+ })
+ if err != nil {
+ fmt.Println("Err Retry ShowEntry")
+ panic(err)
+ }
+ continue
+ }
+
+ keySlot++
+ continue
+ }
+
+ break
+ }
+
+ fmt.Println("Key escrowed successfully.")
+}
diff --git a/tools/luks/lvm/main.go b/tools/luks/lvm/main.go
new file mode 100644
index 0000000000..de6cbe1431
--- /dev/null
+++ b/tools/luks/lvm/main.go
@@ -0,0 +1,15 @@
+package main
+
+import (
+ "fmt"
+
+ "github.com/fleetdm/fleet/v4/orbit/pkg/lvm"
+)
+
+func main() {
+ disk, err := lvm.FindRootDisk()
+ if err != nil {
+ panic(err)
+ }
+ fmt.Println("Root Partition:", disk)
+}
diff --git a/tools/osquery/README.md b/tools/osquery/README.md
index 4f90f19df8..bed074d556 100644
--- a/tools/osquery/README.md
+++ b/tools/osquery/README.md
@@ -81,7 +81,7 @@ docker-compose rm
We have had no trouble running up to 100 containerized osqueryd instances on a single processor core and about 1GB of RAM.
-### Generating a osqueryd core file
+### Generating an osqueryd core file
The docker containers are configured to allow core files to be generated if osqueryd
crashes for some reason. You can attach to the container hosting the errant osqueryd
diff --git a/tools/release/README.md b/tools/release/README.md
index 5f21ad47a6..99b89edb0d 100644
--- a/tools/release/README.md
+++ b/tools/release/README.md
@@ -116,9 +116,7 @@ Go update osquery-slack version
# Publish patch
./tools/release/publish_release.sh -u
-# Make sure to wait for the CLI to open NPM to publish fleetctl.
-# If that fails, manually publish by going to the `/tools/fleetctl-npm/` directory
-# and running `npm publish`
-
-# Go update osquery-slack version
+- Make sure to wait for the CLI to open NPM to publish fleetctl.
+- If that fails, manually publish by going to the `/tools/fleetctl-npm/` directory and running `npm publish`
+- Go update osquery-slack version
```
diff --git a/tools/tuf/test/README.md b/tools/tuf/test/README.md
index dd9dd4c332..e22b5642e2 100644
--- a/tools/tuf/test/README.md
+++ b/tools/tuf/test/README.md
@@ -96,7 +96,7 @@ GOOS=windows GOARCH=amd64 go build -o orbit-windows.exe ./orbit/cmd/orbit
./tools/tuf/test/push_target.sh windows orbit orbit-windows.exe 43
```
-If the script was executed on a macOS host, the Orbit binary will be an universal binary. To push updates you can do:
+If the script was executed on a macOS host, the Orbit binary will be a universal binary. To push updates you can do:
```sh
# Compile a universal binary of Orbit:
diff --git a/website/api/controllers/get-est-device-certificate.js b/website/api/controllers/get-est-device-certificate.js
index a69e83d0eb..5a69979f2f 100644
--- a/website/api/controllers/get-est-device-certificate.js
+++ b/website/api/controllers/get-est-device-certificate.js
@@ -83,11 +83,11 @@ module.exports = {
throw 'invalidToken';
}
- if (!introspectResponse.body.active) {
+ const introspectBody = JSON.parse(introspectResponse.body);
+ if (!introspectBody.active) {
throw 'invalidToken';
}
-
- const introspectUsername = introspectResponse.body.username;
+ const introspectUsername = introspectBody.username;
// Extract the email and username from the CSR. Ensure they match.
let jsrsasign = require('jsrsasign');
diff --git a/website/api/controllers/view-endpoint-ops.js b/website/api/controllers/view-endpoint-ops.js
index 7e1f33c5ac..7854159b65 100644
--- a/website/api/controllers/view-endpoint-ops.js
+++ b/website/api/controllers/view-endpoint-ops.js
@@ -22,8 +22,8 @@ module.exports = {
}
// Get testimonials for the component.
let testimonialsForScrollableTweets = _.clone(sails.config.builtStaticContent.testimonials);
- // Default the pagePersonalization to the user's primaryBuyingSituation.
- let pagePersonalization = this.req.session.primaryBuyingSituation;
+ // Default the pagePersonalization to the user's primaryBuyingSituation if it is set, otherwise, default to the eo-it view..
+ let pagePersonalization = this.req.session.primaryBuyingSituation ? this.req.session.primaryBuyingSituation : 'eo-it';
// If a purpose query parameter is set, update the pagePersonalization value.
// Note: This is the only page we're using this method instead of using the primaryBuyingSiutation value set in the users session.
// This lets us link to the security and IT versions of the endpoint ops page from the unpersonalized homepage without changing the users primaryBuyingSituation.
diff --git a/website/api/controllers/view-vulnerability-management.js b/website/api/controllers/view-software-management.js
similarity index 90%
rename from website/api/controllers/view-vulnerability-management.js
rename to website/api/controllers/view-software-management.js
index 2a9c2ae04c..b95b292b45 100644
--- a/website/api/controllers/view-vulnerability-management.js
+++ b/website/api/controllers/view-software-management.js
@@ -1,16 +1,16 @@
module.exports = {
- friendlyName: 'View vulnerability-management',
+ friendlyName: 'View software-management',
- description: 'Display "Vulnerability management" page.',
+ description: 'Display "Software management" page.',
exits: {
success: {
- viewTemplatePath: 'pages/vulnerability-management'
+ viewTemplatePath: 'pages/software-management'
},
badConfig: { responseType: 'badConfig' },
},
diff --git a/website/api/controllers/view-testimonials.js b/website/api/controllers/view-testimonials.js
index dfd040ccf5..5365a312d8 100644
--- a/website/api/controllers/view-testimonials.js
+++ b/website/api/controllers/view-testimonials.js
@@ -28,12 +28,82 @@ module.exports = {
let testimonialsForMdm = _.filter(testimonials, (testimonial)=>{
return _.contains(testimonial.productCategories, 'Device management');
});
+ let testimonialOrderForMdm = [
+ 'Scott MacVicar',
+ 'Wes Whetstone',
+ 'Nick Fohs',
+ 'Erik Gomez',
+ 'Matt Carr',
+ 'Nico Waisman',
+ 'Kenny Botelho',
+ 'Dan Grzelak',
+ 'Eric Tan',
+ ];
+ testimonialsForMdm.sort((a, b)=>{
+ if(testimonialOrderForMdm.indexOf(a.quoteAuthorName) === -1){
+ return 1;
+ } else if(testimonialOrderForMdm.indexOf(b.quoteAuthorName) === -1) {
+ return -1;
+ }
+ return testimonialOrderForMdm.indexOf(a.quoteAuthorName) - testimonialOrderForMdm.indexOf(b.quoteAuthorName);
+ });
let testimonialsForSecurityEngineering = _.filter(testimonials, (testimonial)=>{
return _.contains(testimonial.productCategories, 'Vulnerability management');
});
+ let testimonialOrderForSecurityEngineering = [
+ 'Nico Waisman',
+ 'Austin Anderson',
+ 'Chandra Majumdar',
+ 'Andre Shields',
+ 'Dan Grzelak',
+ 'Charles Zaffery',
+ 'Erik Gomez',
+ 'Nick Fohs',
+ 'Dhruv Majumdar',
+ 'Arsenio Figueroa',
+ ];
+ testimonialsForSecurityEngineering.sort((a, b)=>{
+ if(testimonialOrderForSecurityEngineering.indexOf(a.quoteAuthorName) === -1){
+ return 1;
+ } else if(testimonialOrderForSecurityEngineering.indexOf(b.quoteAuthorName) === -1) {
+ return -1;
+ }
+ return testimonialOrderForSecurityEngineering.indexOf(a.quoteAuthorName) - testimonialOrderForSecurityEngineering.indexOf(b.quoteAuthorName);
+ });
let testimonialsForItEngineering = _.filter(testimonials, (testimonial)=>{
return _.contains(testimonial.productCategories, 'Endpoint operations');
});
+ let testimonialOrderForItEngineering = [
+ 'Charles Zaffery',
+ 'Nico Waisman',
+ 'Erik Gomez',
+ 'Mike Arpaia',
+ 'Ahmed Elshaer',
+ 'Kenny Botelho',
+ 'Alvaro Gutierrez',
+ 'Tom Larkin',
+ 'Nick Fohs',
+ 'charles zaffery',// Note: This testimonial's quoteAuthorName value is lowercased so it can be sorted to a different position than the other Charles Zaffery quote.
+ 'Andre Shields',
+ 'Abubakar Yousafzai',
+ 'Chandra Majumdar',
+ 'Joe Pistone',
+ 'Dan Grzelak',
+ 'Austin Anderson',
+ 'Brendan Shaklovitz',
+ 'Dhruv Majumdar',
+ 'Wes Whetstone',
+ 'Eric Tan',
+ 'Arsenio Figueroa',
+ ];
+ testimonialsForItEngineering.sort((a, b)=>{
+ if(testimonialOrderForItEngineering.indexOf(a.quoteAuthorName) === -1){
+ return 1;
+ } else if(testimonialOrderForItEngineering.indexOf(b.quoteAuthorName) === -1) {
+ return -1;
+ }
+ return testimonialOrderForItEngineering.indexOf(a.quoteAuthorName) - testimonialOrderForItEngineering.indexOf(b.quoteAuthorName);
+ });
let testimonialsWithVideoLinks = _.filter(testimonials, (testimonial)=>{
return testimonial.youtubeVideoUrl;
});
diff --git a/website/api/hooks/custom/index.js b/website/api/hooks/custom/index.js
index 0c421415d2..1e6d150087 100644
--- a/website/api/hooks/custom/index.js
+++ b/website/api/hooks/custom/index.js
@@ -325,6 +325,7 @@ will be disabled and/or hidden in the UI.
return await salesforceConnection.sobject('fleet_website_page_views__c')
.create({
Contact__c: recordIds.salesforceContactId,// eslint-disable-line camelcase
+ Account__c: recordIds.salesforceAccountId,// eslint-disable-line camelcase
Page_URL__c: `https://fleetdm.com${req.url}`,// eslint-disable-line camelcase
Visited_on__c: nowOn,// eslint-disable-line camelcase
Website_visit_reason__c: websiteVisitReason// eslint-disable-line camelcase
diff --git a/website/assets/images/articles/fleet-and-workbrew-1600x900@2x.png b/website/assets/images/articles/fleet-and-workbrew-1600x900@2x.png
new file mode 100644
index 0000000000..9074d10b97
Binary files /dev/null and b/website/assets/images/articles/fleet-and-workbrew-1600x900@2x.png differ
diff --git a/website/assets/images/articles/workbrew-console-3412x2020px.png b/website/assets/images/articles/workbrew-console-3412x2020px.png
new file mode 100644
index 0000000000..060143e987
Binary files /dev/null and b/website/assets/images/articles/workbrew-console-3412x2020px.png differ
diff --git a/website/assets/images/software-management-feature-image-1-528x377@2x.png b/website/assets/images/software-management-feature-image-1-528x377@2x.png
new file mode 100644
index 0000000000..451b9beb3d
Binary files /dev/null and b/website/assets/images/software-management-feature-image-1-528x377@2x.png differ
diff --git a/website/assets/images/software-management-feature-image-2-528x377@2x.png b/website/assets/images/software-management-feature-image-2-528x377@2x.png
new file mode 100644
index 0000000000..3f6ca292fd
Binary files /dev/null and b/website/assets/images/software-management-feature-image-2-528x377@2x.png differ
diff --git a/website/assets/images/software-management-feature-image-3-528x377@2x.png b/website/assets/images/software-management-feature-image-3-528x377@2x.png
new file mode 100644
index 0000000000..ec2d6cb51a
Binary files /dev/null and b/website/assets/images/software-management-feature-image-3-528x377@2x.png differ
diff --git a/website/assets/images/software-management-feature-image-4-528x377@2x.png b/website/assets/images/software-management-feature-image-4-528x377@2x.png
new file mode 100644
index 0000000000..cc5d385262
Binary files /dev/null and b/website/assets/images/software-management-feature-image-4-528x377@2x.png differ
diff --git a/website/assets/images/software-management-feature-image-5-528x377@2x.png b/website/assets/images/software-management-feature-image-5-528x377@2x.png
new file mode 100644
index 0000000000..2e14bf963f
Binary files /dev/null and b/website/assets/images/software-management-feature-image-5-528x377@2x.png differ
diff --git a/website/assets/images/software-management-feature-slide-1-1072x480@2x.png b/website/assets/images/software-management-feature-slide-1-1072x480@2x.png
new file mode 100644
index 0000000000..ca7d076f2a
Binary files /dev/null and b/website/assets/images/software-management-feature-slide-1-1072x480@2x.png differ
diff --git a/website/assets/images/software-management-feature-slide-2-1072x480@2x.png b/website/assets/images/software-management-feature-slide-2-1072x480@2x.png
new file mode 100644
index 0000000000..7df8d28de4
Binary files /dev/null and b/website/assets/images/software-management-feature-slide-2-1072x480@2x.png differ
diff --git a/website/assets/images/software-management-feature-slide-3-1072x480@2x.png b/website/assets/images/software-management-feature-slide-3-1072x480@2x.png
new file mode 100644
index 0000000000..b75534a680
Binary files /dev/null and b/website/assets/images/software-management-feature-slide-3-1072x480@2x.png differ
diff --git a/website/assets/images/testimonial-author-arsenio-figueroa-48x48@2x.png b/website/assets/images/testimonial-author-arsenio-figueroa-48x48@2x.png
new file mode 100644
index 0000000000..ccd1ec9306
Binary files /dev/null and b/website/assets/images/testimonial-author-arsenio-figueroa-48x48@2x.png differ
diff --git a/website/assets/images/vuln-management-feature-image-1-380x281@2x.png b/website/assets/images/vuln-management-feature-image-1-380x281@2x.png
deleted file mode 100644
index 38b72e56d4..0000000000
Binary files a/website/assets/images/vuln-management-feature-image-1-380x281@2x.png and /dev/null differ
diff --git a/website/assets/images/vuln-management-feature-image-2-380x323@2x.png b/website/assets/images/vuln-management-feature-image-2-380x323@2x.png
deleted file mode 100644
index 154dd72365..0000000000
Binary files a/website/assets/images/vuln-management-feature-image-2-380x323@2x.png and /dev/null differ
diff --git a/website/assets/images/vuln-management-feature-image-3-380x320@2x.png b/website/assets/images/vuln-management-feature-image-3-380x320@2x.png
deleted file mode 100644
index 23a9928dd5..0000000000
Binary files a/website/assets/images/vuln-management-feature-image-3-380x320@2x.png and /dev/null differ
diff --git a/website/assets/images/vuln-management-feature-image-4-380x270@2x.png b/website/assets/images/vuln-management-feature-image-4-380x270@2x.png
deleted file mode 100644
index 8b3131ca6b..0000000000
Binary files a/website/assets/images/vuln-management-feature-image-4-380x270@2x.png and /dev/null differ
diff --git a/website/assets/images/vuln-management-feature-image-5-380x312@2x.png b/website/assets/images/vuln-management-feature-image-5-380x312@2x.png
deleted file mode 100644
index 7f5d1da9f8..0000000000
Binary files a/website/assets/images/vuln-management-feature-image-5-380x312@2x.png and /dev/null differ
diff --git a/website/assets/js/pages/osquery-table-details.page.js b/website/assets/js/pages/osquery-table-details.page.js
index 4897b68ccc..d940b76af9 100644
--- a/website/assets/js/pages/osquery-table-details.page.js
+++ b/website/assets/js/pages/osquery-table-details.page.js
@@ -73,20 +73,32 @@ parasails.registerPage('osquery-table-details', {
keywordsForThisTable = keywordsForThisTable.sort((a,b)=>{// Sorting the array of keywords by length to match larger keywords first.
return a.length < b.length ? 1 : -1;
});
+ keywordsForThisTable = _.pull(keywordsForThisTable, this.tableToDisplay.title);
(()=>{
$('pre code').each((i, block) => {
- let keywordsToHighlight = [];// Empty array to track the keywords that we will need to highlight
- for(let keyword of keywordsForThisTable){// Going through the array of keywords for this table, if the entire word matches, we'll add it to the
- for(let match of block.innerHTML.match(keyword)||[]){
- keywordsToHighlight.push(match);
- }
+ let tableNamesToHighlight = [];// Empty array to track the keywords that we will need to highlight
+ for(let match of block.innerHTML.match(this.tableToDisplay.title)||[]){
+ tableNamesToHighlight.push(match);
}
// Now iterate through the keywordsToHighlight, replacing all matches in the elements innerHTML.
let replacementHMTL = block.innerHTML;
- for(let keywordInExample of keywordsToHighlight) {
+ for(let keywordInExample of tableNamesToHighlight) {
let regexForThisExample = new RegExp(keywordInExample, 'g');
replacementHMTL = replacementHMTL.replace(regexForThisExample, ''+keywordInExample+'');
}
+ // $(block).html(replacementHMTL);
+ let columnNamesToHighlight = [];// Empty array to track the keywords that we will need to highlight
+ for(let keyword of keywordsForThisTable){// Going through the array of keywords for this table, if the entire word matches, we'll add it to the
+ for(let match of block.innerHTML.match(keyword)||[]){
+ columnNamesToHighlight.push(match);
+ }
+ }
+ // Now iterate through the keywordsToHighlight, replacing all matches in the elements innerHTML.
+ // let replacementHMTL = block.innerHTML;
+ for(let keywordInExample of columnNamesToHighlight) {
+ let regexForThisExample = new RegExp(keywordInExample, 'g');
+ replacementHMTL = replacementHMTL.replace(regexForThisExample, ''+keywordInExample+'');
+ }
$(block).html(replacementHMTL);
// After we've highlighted our keywords, we'll highlight the rest of the codeblock
window.hljs.highlightElement(block);
diff --git a/website/assets/js/pages/vulnerability-management.page.js b/website/assets/js/pages/software-management.page.js
similarity index 63%
rename from website/assets/js/pages/vulnerability-management.page.js
rename to website/assets/js/pages/software-management.page.js
index 29c8bfc12f..02c7a6f39f 100644
--- a/website/assets/js/pages/vulnerability-management.page.js
+++ b/website/assets/js/pages/software-management.page.js
@@ -1,9 +1,10 @@
-parasails.registerPage('vulnerability-management-page', {
+parasails.registerPage('software-management-page', {
// ╦╔╗╔╦╔╦╗╦╔═╗╦ ╔═╗╔╦╗╔═╗╔╦╗╔═╗
// ║║║║║ ║ ║╠═╣║ ╚═╗ ║ ╠═╣ ║ ║╣
// ╩╝╚╝╩ ╩ ╩╩ ╩╩═╝ ╚═╝ ╩ ╩ ╩ ╩ ╚═╝
data: {
modal: undefined,
+ visibleFeature: 'mitigate-cves-automatically',
},
// ╦ ╦╔═╗╔═╗╔═╗╦ ╦╔═╗╦ ╔═╗
@@ -13,13 +14,24 @@ parasails.registerPage('vulnerability-management-page', {
//…
},
mounted: async function() {
- //…
+ $('#heroCarousel').carousel({
+ interval: 5000,
+ });
+ $('#heroCarousel').on('slide.bs.carousel', (e)=>{
+ let toIndicatorElement = $('ol[purpose="carousel-indicators"] li')[e.to];
+ let fromIndicatorElement = $('ol[purpose="carousel-indicators"] li')[e.from];
+ $(toIndicatorElement).addClass('active');
+ $(fromIndicatorElement).removeClass('active');
+ });
},
// ╦╔╗╔╔╦╗╔═╗╦═╗╔═╗╔═╗╔╦╗╦╔═╗╔╗╔╔═╗
// ║║║║ ║ ║╣ ╠╦╝╠═╣║ ║ ║║ ║║║║╚═╗
// ╩╝╚╝ ╩ ╚═╝╩╚═╩ ╩╚═╝ ╩ ╩╚═╝╝╚╝╚═╝
methods: {
+ clickSwitchFeature: function(feature) {
+ this.visibleFeature = feature;
+ },
clickOpenVideoModal: function(modalName) {
this.modal = modalName;
},
diff --git a/website/assets/styles/components/scrollable-tweets.component.less b/website/assets/styles/components/scrollable-tweets.component.less
index b11473169f..b890e5d6c3 100644
--- a/website/assets/styles/components/scrollable-tweets.component.less
+++ b/website/assets/styles/components/scrollable-tweets.component.less
@@ -92,6 +92,7 @@
line-height: 18px !important;//lesshint-disable-line importantRule,duplicateProperty
}
[purpose='name'] {
+ text-transform: capitalize;
color: @core-fleet-black !important;//lesshint-disable-line importantRule,duplicateProperty
}
[purpose='profile-picture'] {
diff --git a/website/assets/styles/importer.less b/website/assets/styles/importer.less
index f68370a395..ef6d10bbcb 100644
--- a/website/assets/styles/importer.less
+++ b/website/assets/styles/importer.less
@@ -70,7 +70,7 @@
@import 'pages/endpoint-ops.less';
@import 'pages/transparency.less';
@import 'pages/press-kit.less';
-@import 'pages/vulnerability-management.less';
+@import 'pages/software-management.less';
@import 'pages/support.less';
@import 'pages/try-fleet/waitlist.less';
@import 'pages/admin/sandbox-waitlist.less';
diff --git a/website/assets/styles/layout.less b/website/assets/styles/layout.less
index 7452a27bd7..61dbd0f5fd 100644
--- a/website/assets/styles/layout.less
+++ b/website/assets/styles/layout.less
@@ -17,6 +17,27 @@ html, body {
padding-bottom: @footer-height;
background: linear-gradient(180deg, #E8F1F6 0%, #FFFFFF 200px);
background-position: center 40px;
+ opacity: 1;
+ // Note: This element has the "show" class toggled by the mobile header nav menu button.
+ // We are overriding bootstrap classes here to allow us to prevent the page being scrolled while the mobile menu is open.
+ &.collapse {
+ display: block;
+ [purpose='mobile-nav'] {
+ display: none;
+ }
+ }
+ &.collapsing {
+ opacity: 0;
+ transition: 0s;
+ }
+ &.collapse.show {
+ max-height: 100vh;
+ overflow: hidden;
+ [purpose='mobile-nav'] {
+ display: block;
+ opacity: 1;
+ }
+ }
}
[purpose='header-background'] {
@@ -40,7 +61,7 @@ html, body {
}
[purpose='continue-banner'] {
- z-index: 199;
+ z-index: 198;
position: fixed;
bottom: 24px;
left: 24px;
@@ -279,14 +300,14 @@ html, body {
left: 0;
right: 0;
bottom: 0;
- pointer-events: none;
+ z-index: 200;
background-color: #ffffff;
hr {
margin-top: 4px;
margin-bottom: 8px;
}
[purpose='mobile-nav-header'] {
- padding: 19px 40px;
+ padding: 19px 32px;
height: 80px;
}
[purpose='mobile-nav-container'] {
@@ -783,6 +804,7 @@ body.detected-mobile {
@media (max-width: 375px) {
[purpose='page-header'] {
+ padding: 19px 16px;
[purpose='mobile-nav'] {
[purpose='mobile-nav-header'] {
padding: 19px 16px;
diff --git a/website/assets/styles/pages/articles/articles.less b/website/assets/styles/pages/articles/articles.less
index 770943f00c..7d5fd9f097 100644
--- a/website/assets/styles/pages/articles/articles.less
+++ b/website/assets/styles/pages/articles/articles.less
@@ -220,7 +220,7 @@
}
[purpose='guides'] {
column-count: 3;
- margin-left: -2px;
+ margin-left: -10px;
margin-right: 14px;
}
[purpose='guide-card'] {
diff --git a/website/assets/styles/pages/entrance/login.less b/website/assets/styles/pages/entrance/login.less
index 46ffa53c30..de5aafbe2c 100644
--- a/website/assets/styles/pages/entrance/login.less
+++ b/website/assets/styles/pages/entrance/login.less
@@ -164,7 +164,7 @@
[purpose='customer-portal-form'] {
max-width: unset;
}
- [purpose='signup-form'] {
+ [purpose='login-form'] {
width: 100%;
}
[purpose='quote-and-logos'] {
@@ -183,7 +183,7 @@
[purpose='page-container'] {
padding: 48px 24px;
}
- [purpose='login-link'] {
+ [purpose='register-link'] {
margin-bottom: 12px;
}
[purpose='customer-portal-form'] {
diff --git a/website/assets/styles/pages/osquery-table-details.less b/website/assets/styles/pages/osquery-table-details.less
index b452731b13..e7b42e2f31 100644
--- a/website/assets/styles/pages/osquery-table-details.less
+++ b/website/assets/styles/pages/osquery-table-details.less
@@ -278,19 +278,26 @@
.hljs-keyword {
color: #FFF;
}
+ .hljs-string { // For words wrapped in quotation marks
+ color: #FFF;
+ }
color: #FFF;
background-color: #AE6DDF;
- border-radius: 4px;
- padding: 4px 4px 4px 4px;
+ border-radius: 3px;
white-space: pre;
vertical-align: baseline;
- line-height: 16px;
span {
padding: 0;
}
}
+ .hljs-number {
+ color: #f5871f;
+ }
.hljs-string { // For words wrapped in quotation marks
- color: #3DB67B;
+ color: #4fd061;
+ .hljs-keyword {
+ color: #4fd061;
+ }
}
background-color: @ui-off-white;
border: none;
diff --git a/website/assets/styles/pages/query-library.less b/website/assets/styles/pages/query-library.less
index 7560755e2b..924a4dc887 100644
--- a/website/assets/styles/pages/query-library.less
+++ b/website/assets/styles/pages/query-library.less
@@ -51,6 +51,7 @@
}
.DocSearch-Button-Placeholder {
font-size: 16px;
+ line-height: 16px;
font-weight: 400;
padding-left: 0px;
}
diff --git a/website/assets/styles/pages/software-management.less b/website/assets/styles/pages/software-management.less
new file mode 100644
index 0000000000..56ea6c8f02
--- /dev/null
+++ b/website/assets/styles/pages/software-management.less
@@ -0,0 +1,479 @@
+#software-management-page {
+ @heading-lineheight: 120%;
+ @text-lineheight: 150%;
+
+ h1 {
+ color: @core-fleet-black;
+ font-size: 48px;
+ font-weight: 800;
+ line-height: @heading-lineheight; /* 120% */
+ }
+ h2 {
+ color: @core-fleet-black;
+ text-align: center;
+ font-feature-settings: 'salt' on, 'ss01' on, 'ss02' on;
+ font-size: 32px;
+ font-weight: 800;
+ line-height: @text-lineheight;
+ }
+ h3 {
+ margin-bottom: 32px;
+ color: @core-fleet-black;
+ font-size: 24px;
+ font-weight: 800;
+ line-height: @heading-lineheight;
+ }
+ h4 {
+ color: @core-fleet-black-75;
+ font-feature-settings: 'salt' on, 'ss01' on, 'ss02' on;
+ font-family: 'Roboto Mono';
+ font-size: 14px;
+ font-weight: 400;
+ line-height: @text-lineheight;
+ text-transform: uppercase;
+ }
+ p {
+ font-size: 16px;
+ line-height: @text-lineheight;
+ color: @core-fleet-black-75;
+ }
+
+
+
+ [parasails-component='animated-arrow-button'] {
+ font-weight: 600;
+ }
+ [purpose='page-container'] {
+ padding: 64px;
+ }
+ [purpose='page-content'] {
+ max-width: 1072px;
+ margin-left: auto;
+ margin-right: auto;
+ }
+ [purpose='hero-text'] {
+ max-width: 568px;
+ padding-top: 16px;
+ padding-bottom: 32px;
+ }
+ [purpose='button-row'] {
+ [purpose='contact-button'] {
+ display: flex;
+ height: 36px;
+ padding: 16px;
+ justify-content: center;
+ align-items: center;
+ gap: 4px;
+ border-radius: 8px;
+ background: @core-vibrant-red;
+ color: #FFF;
+ text-align: center;
+ font-size: 16px;
+ font-weight: 700;
+ line-height: @text-lineheight;
+ margin-right: 24px;
+ &:hover {
+ text-decoration: none;
+ }
+ }
+ }
+ [purpose='hero-slides'] {
+ padding-top: 64px;
+ padding-bottom: 24px;
+ [purpose='carousel-headings'] {
+ position: unset;
+ padding-top: 24px;
+ padding-bottom: 16px;
+ margin-left: unset;
+ margin-right: unset;
+ }
+ [purpose='slide-text'] {
+ width: 33%;
+ cursor: pointer;
+ h5 {
+ font-size: 16px;
+ font-weight: 800;
+ line-height: @heading-lineheight;
+ }
+ p {
+ margin-right: 16px;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: @text-lineheight;
+ }
+ h5, p {
+ color: @core-fleet-black-50;
+ }
+ &:not(:last-of-type) {
+ margin-right: 16px;
+ }
+ &.active {
+ h5, p {
+ color: @core-fleet-black;
+ }
+ }
+ &:hover {
+ h5, p {
+ color: @core-fleet-black;
+ }
+ }
+ }
+ [purpose='carousel-indicators'] {
+ position: relative;
+ bottom: 0px;
+ margin-top: 16px;
+ margin-bottom: 16px;
+ li {
+ width: 40px;
+ background-color: @core-fleet-black-10;
+ &.active {
+ background-color: @core-fleet-black-50;
+ }
+ }
+ }
+ }
+ [purpose='logo-row'] {
+ [parasails-component='logo-carousel'] {
+ margin-top: 64px;
+ margin-bottom: 32px;
+ }
+ }
+ [purpose='feature-slides'] {
+ height: 660px;
+ }
+ [purpose='feature-switch'] {
+ padding-top: 64px;
+ margin-right: auto;
+ margin-left: auto;
+ border-bottom: 1px solid @core-fleet-black-10;
+ }
+ [purpose='feature-option'] {
+ width: 33%;
+ padding: 16px 40px;
+ white-space: nowrap;
+ text-align: center;
+ cursor: pointer;
+ color: @core-fleet-black-50;
+ &.active {
+ color: @core-fleet-black;
+ border-bottom: 2px solid @core-fleet-black;
+ }
+ &:hover {
+ color: @core-fleet-black;
+ }
+ }
+ [purpose='feature-slide'] {
+ padding-top: 64px;
+ padding-bottom: 32px;
+ &.invisible {
+ height: 0;
+ padding: 0;
+ }
+ [purpose='feature-text'] {
+ padding-left: 48px;
+ margin-left: 16px;
+ width: 50%;
+ }
+ }
+ [purpose='feature-image'] {
+ width: 50%;
+ img {
+ max-width: 100%;
+ max-height: 100%;
+ }
+ }
+ [purpose='checklist'] {
+ margin-top: 8px;
+ p {
+ font-size: 14px;
+ font-weight: 400;
+ line-height: @text-lineheight;
+ padding-left: 37px;
+ text-indent: -37px;
+ margin-bottom: 1.5rem;
+ &:last-of-type {
+ margin-bottom: 0px;
+ }
+ }
+ p::before {
+ content: ' ';
+ background-image: url('/images/icon-checkmark-green-20x20@2x.png');
+ background-size: 20px 20px;
+ display: inline-block;
+ position: relative;
+ top: 5px;
+ margin-right: 16px;
+ width: 20px;
+ height: 20px;
+ }
+ }
+ [purpose='feature-link'] {
+ padding-top: 32px;
+ }
+
+ [purpose='testimonial'] {
+ max-width: 524px;
+ margin-left: auto;
+ margin-right: auto;
+ padding-bottom: 64px;
+ padding-top: 32px;
+ text-align: center;
+ [purpose='testimonial-image'] {
+ height: 48px;
+ margin-bottom: 5px;
+ }
+ [purpose='testimonial-text'] {
+ color: @core-fleet-black-75;
+ text-align: center;
+ font-size: 18px;
+ font-style: italic;
+ font-weight: 400;
+ line-height: @text-lineheight;
+ margin-bottom: 24px;
+ }
+ [purpose='testimonial-attribution'] {
+ [purpose='name'] {
+ color: @core-fleet-black-75;
+ text-align: center;
+ font-size: 12px;
+ font-weight: 700;
+ line-height: @text-lineheight; /* 150% */
+ margin-bottom: 0px;
+ }
+ [purpose='job-title'] {
+ color: @core-fleet-black-75;
+ margin-bottom: 0px;
+ font-size: 12px;
+ font-weight: 400;
+ line-height: @text-lineheight;
+ }
+ }
+ }
+
+
+ [purpose='feature'] {
+ padding-top: 64px;
+ padding-bottom: 64px;
+ h3 {
+ color: @core-fleet-black;
+ font-size: 24px;
+ font-weight: 800;
+ line-height: @heading-lineheight; /* 120% */
+ margin-bottom: 32px;
+ }
+ &:last-of-type {
+ margin-bottom: 0px;
+ }
+ }
+
+ [purpose='feature'].flex-column {
+ [purpose='feature-text'] {
+ margin-left: 16px;
+ padding-left: 48px;
+ }
+ }
+ [purpose='feature'].flex-column-reverse {
+ [purpose='feature-text'] {
+ margin-right: 16px;
+ padding-right: 48px;
+ }
+ }
+ [purpose='feature-text'] {
+ width: 50%;
+ }
+ [parasails-component='scrollable-tweets'] {
+ [purpose='tweets'] {
+ margin-top: 32px;
+ margin-bottom: 88px;
+ [purpose='quote'] {
+ color: @core-fleet-black-75;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: @text-lineheight;
+ }
+ [purpose='video-link'] {
+ text-decoration: underline;
+ color: @core-fleet-black-75;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: @text-lineheight;
+ text-decoration-line: underline;
+ }
+ }
+ }
+ [purpose='section-heading'] {
+ h4 {
+ margin-bottom: 0px;
+ }
+ [purpose='button-row'] {
+ [purpose='contact-button'] {
+ padding: 16px 32px;
+ }
+ }
+ }
+
+ [parasails-component='parallax-city'] {
+ background: linear-gradient(358deg, #E9F4F4 0%, #FFFFFF 100%);
+ }
+
+ @media (max-width: 991px) {
+ [purpose='page-container'] {
+ padding: 64px 32px;
+ }
+ [purpose='feature-slides'] {
+ height: unset;
+ }
+ [purpose='feature-option'] {
+ width: 33%;
+ padding: 16px 8px;
+ &:last-of-type {
+ padding: 16px 8px;
+ }
+ }
+ [purpose='feature-slide'] {
+ [purpose='feature-text'] {
+ padding-left: 16px;
+ width: 50%;
+ }
+ }
+ [purpose='feature'].flex-column {
+ [purpose='feature-text'] {
+ margin-left: 16px;
+ padding-left: 16px;
+ }
+ }
+ [purpose='feature'].flex-column-reverse {
+ [purpose='feature-text'] {
+ margin-right: 16px;
+ padding-right: 16px;
+ }
+ }
+ }
+ @media (max-width: 767px) {
+ [purpose='page-container'] {
+ padding: 64px 32px;
+ }
+ [purpose='hero-slides'] {
+ [purpose='carousel-headings'] {
+ position: unset;
+ padding-top: 16px;
+ padding-bottom: 16px;
+ margin-left: unset;
+ margin-right: unset;
+ }
+ [purpose='slide-text'] {
+ width: 100%;
+ p {
+ margin-right: 16px;
+ margin-bottom: 0px;
+ }
+ &:not(:last-of-type) {
+ margin-right: unset;
+ margin-bottom: 32px;
+ }
+ }
+ [purpose='feature-slide'] {
+ padding-top: 64px;
+ padding-bottom: 62px;
+ [purpose='feature-text'] {
+ margin-left: 0px;
+ padding-left: 0px;
+ width: 50%;
+ }
+ }
+ }
+ [purpose='feature-switch'] {
+ margin-right: 0;
+ margin-left: auto;
+ border-bottom: none;
+ border-left: 1px solid @core-fleet-black-10;
+ margin-top: 64px;
+ padding-top: 0px;
+ }
+ [purpose='feature-option'] {
+ width: 100%;
+ text-align: center;
+ &.active {
+ border-left: 2px solid @core-fleet-black;
+ border-bottom: none;
+ }
+ }
+ [purpose='feature-slide'] {
+ padding-top: 32px;
+ padding-bottom: 64px;
+ &.invisible {
+ height: 0;
+ padding: 0;
+ }
+ [purpose='feature-text'] {
+ margin-left: 0px;
+ padding-left: 0px;
+ width: 100%;
+ margin-bottom: 32px;
+ }
+ }
+ [purpose='testimonial'] {
+ padding-bottom: 64px;
+ padding-top: 0px;
+ }
+ [purpose='feature'].flex-column {
+ padding-top: 32px;
+ padding-bottom: 48px;
+ [purpose='feature-text'] {
+ margin-left: 0px;
+ padding-left: 0px;
+ }
+ }
+ [purpose='feature'].flex-column-reverse {
+ padding-top: 48px;
+ padding-bottom: 32px;
+ [purpose='feature-text'] {
+ padding-right: 0px;
+ margin-right: 0px;
+ }
+ }
+ [purpose='feature-text'] {
+ width: 100%;
+ }
+ [purpose='feature-image'] {
+ width: 100%;
+ margin-bottom: 32px;
+ }
+ [purpose='section-heading'] {
+ padding: 64px 32px;
+ }
+ }
+ @media (max-width: 576px) {
+ [purpose='page-container'] {
+ padding: 32px 24px;
+ }
+ [purpose='quote'] {
+ padding: 0px 32px 64px 32px;
+ }
+ [purpose='section-heading'] {
+ padding: 40px 32px;
+ }
+ [parasails-component='scrollable-tweets'] {
+ [purpose='tweets'] {
+ margin-bottom: 56px;
+ }
+ }
+ }
+
+ @media (max-width: 376px) {
+ h1 {
+ font-size: 32px;
+ }
+ [purpose='page-container'] {
+ padding: 32px 16px;
+ }
+ [purpose='testimonial'] {
+ padding-bottom: 32px;
+ padding-top: 0px;
+ }
+ [purpose='section-heading'] {
+ padding: 40px 0px;
+ }
+ }
+
+}
diff --git a/website/assets/styles/pages/testimonials.less b/website/assets/styles/pages/testimonials.less
index 7a1f24e14a..79e1e3d8c8 100644
--- a/website/assets/styles/pages/testimonials.less
+++ b/website/assets/styles/pages/testimonials.less
@@ -115,7 +115,7 @@
border-radius: 16px;
border: 1px solid var(--UI-Fleet-Black-10, #E2E4EA);
background: var(--Core-White, #FFF);
- height: min-content;
+ margin-bottom: 24px;
[purpose='logo'] {
img {
max-height: 32px;
@@ -146,6 +146,7 @@
}
[purpose='name'] {
color: @core-fleet-black;
+ text-transform: capitalize;
}
[purpose='profile-picture'] {
margin-right: 16px;
@@ -352,7 +353,22 @@
height: 641px;
}
}
-
+ @media (max-width: 1199px) {
+ [purpose='video-modal'] {
+ [purpose='modal-dialog'] {
+ width: 100%;
+ max-width: 100%;
+ }
+ [purpose='modal-content'] {
+ max-width: 960px;
+ height: 540px;
+ }
+ iframe {
+ width: 960px;
+ height: 540px;
+ }
+ }
+ }
@media (max-width: 991px) {
@@ -362,14 +378,40 @@
[purpose='page-container'] {
padding: 64px 32px;
}
+ [purpose='video-modal'] {
+ [purpose='modal-dialog'] {
+ max-width: 97vw;
+ }
+ [purpose='modal-content'] {
+ max-width: 540px;
+ height: 304px;
+ }
+ iframe {
+ width: 540px;
+ height: 304px;
+ }
+ }
}
@media (max-width: 776px) {
- [purpose='page-container'] {
- padding: 48px 24px;
- }
- [purpose='testimonials-container'] {
- columns: 2;
- }
+ [purpose='page-container'] {
+ padding: 48px 24px;
+ }
+ [purpose='testimonials-container'] {
+ columns: 2;
+ }
+ [purpose='video-modal'] {
+ [purpose='modal-dialog'] {
+ max-width: 97vw;
+ }
+ [purpose='modal-content'] {
+ max-width: 540px;
+ height: 304px;
+ }
+ iframe {
+ width: 540px;
+ height: 304px;
+ }
+ }
}
@media (max-width: 576px) {
@@ -435,7 +477,16 @@
}
}
}
-
+ [purpose='video-modal'] {
+ [purpose='modal-content'] {
+ width: 95vw;
+ height: calc(~'9/16 * 95vw');
+ }
+ iframe {
+ width: 95vw;
+ height: calc(~'9/16 * 95vw');
+ }
+ }
}
}
diff --git a/website/assets/styles/pages/vulnerability-management.less b/website/assets/styles/pages/vulnerability-management.less
deleted file mode 100644
index 87998dd3d2..0000000000
--- a/website/assets/styles/pages/vulnerability-management.less
+++ /dev/null
@@ -1,709 +0,0 @@
-#vulnerability-management-page {
- background: linear-gradient(180deg, #E8F1F6 0%, #FFF 8.76%);
- h1 {
- font-size: 56px;
- font-weight: 800;
- line-height: 54px;
- }
- h2 {
- font-weight: 800;
- font-size: 32px;
- line-height: 38px;
- }
- h3 {
- font-weight: 800;
- font-size: 32px;
- line-height: 120%;
- }
- h4 {
- font-family: 'Roboto Mono';
- font-style: normal;
- font-weight: 400;
- font-size: 14px;
- line-height: 150%;
- text-transform: uppercase;
- color: @core-fleet-black-75;
- margin-bottom: 4px;
- }
- p {
- font-size: 16px;
- line-height: 24px;
- color: @core-fleet-black-75;
- }
- strong {
- colore: @core-fleet-black;
- }
- [purpose='page-content'] {
- max-width: 960px;
- }
-
- [purpose='page-container'] {
- padding-left: 120px;
- padding-right: 120px;
- margin-left: auto;
- margin-right: auto;
- }
-
- [purpose='page-headline'] {
- padding-bottom: 80px;
- max-width: 780px;
- h2 {
- font-size: 48px;
- font-style: normal;
- font-weight: 800;
- line-height: 57.6px;
- margin-bottom: 0px;
- }
- }
- [purpose='hero'] {
- padding-top: 80px;
- padding-bottom: 80px;
- }
- [purpose='hero-image'] {
- max-width: 360px;
- img {
- padding-left: 25px;
- max-width: 100%;
- max-height: 100%;
- width: 360px;
- }
- }
- [purpose='hero-text'] {
- width: 468px;
- margin-left: 40px;
- text-align: left;
- strong {
- margin-bottom: 8px;
- font-weight: 800;
- display: block;
- }
- p {
- margin-bottom: 40px;
- }
- }
-
- [purpose='button-row'] {
- a {
- font-weight: 700;
- font-size: 16px;
- line-height: 24px;
- }
- [purpose='cta-button'] {
- cursor: pointer;
- margin-right: 32px;
- background: @core-vibrant-red;
- border-radius: 8px;
- padding: 16px 32px;
- height: 36px;
- display: flex;
- justify-content: center;
- align-items: center;
- color: #FFF;
- position: relative;
- text-decoration: none;
- overflow: hidden;
- }
- [purpose='cta-button']::before {
- background: linear-gradient(180deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%);
- opacity: 1;
- content: ' ';
- position: absolute;
- top: 0;
- left: -5px;
- width: 70%;
- height: 100%;
- transform: skew(-10deg);
- transition: left 0.5s ease-in, opacity 0.50s ease-in, width 0.5s ease-in;
- }
- [purpose='cta-button']:hover:before {
- opacity: 0;
- left: 160px;
- width: 110%;
- }
-
- }
-
-
- [purpose='testimonial-videos'] {
- width: 468px;
- margin-left: 40px;
- }
- [purpose='testimonials'] {
- margin-bottom: 80px;
- }
- [purpose='testimonial-quote'] {
- width: 380px;
- a {
- text-decoration: none;
- color: unset;
- &:hover {
- text-decoration: none;
- }
- }
- [purpose='quote'] {
- p {
- color: @core-fleet-black-75;
- font-size: 20px;
- font-style: italic;
- font-weight: 400;
- line-height: 30px;
- }
- }
- [purpose='quote-image'] {
- margin-right: 16px;
- img {
- width: 48px;
- height: 48px;
- }
- }
- [purpose='quote-attribution'] {
- display: inline-flex;
- padding: 4px 16px 4px 4px;
- border-radius: 28px;
- width: fit-content;
- margin-top: 8px;
- [purpose='name'] {
- font-size: 12px;
- font-weight: 700;
- line-height: 18px;
- margin-bottom: 0px;
- }
- [purpose='title'] {
- color: @core-fleet-black-75;
- font-size: 12px;
- font-weight: 400;
- line-height: 18px;
- margin-bottom: 0px;
- }
- &:hover {
- background-color: #F9FAFC;
- }
- &:active {
- background-color: #F2F2F5;
- }
- }
-
-
- }
-
- [purpose='testimonial-video'] {
- cursor: pointer;
- width: 223px;
- height: 168px;
- border-radius: 22.265px;
- border: 0.975px solid @core-fleet-black-50;
- display: flex;
- margin-bottom: 0px;
- position: relative;
- span {
- img {
- height: 10.235px;
- width: auto;
- margin-right: 5.5px;
- }
- position: absolute;
- left: 20px;
- bottom: 20px;
- border-radius: 16.698px;
- background: rgba(0, 2, 10, 0.50);
- backdrop-filter: blur(5.566145420074463px);
- display: inline-flex;
- padding: 5.566px 11.132px;
- justify-content: center;
- align-items: center;
- color: #FFF;
- font-size: 11.132px;
- font-style: normal;
- font-weight: 400;
- line-height: 16.698px;
- }
- &:hover {
- box-shadow: 0px 4px 16px 0px #E2E4EA;
- }
- &:first-of-type {
- background: url('/images/video-testimonial-thumbnail-austin-anderson-223x168@2x.jpg');
- background-position: center;
- background-size: cover;
- margin-right: 12px;
- margin-left: 0px;
- }
- &:last-of-type {
- background: url('/images/video-testimonial-thumbnail-andre-shields-223x168@2x.png');
- background-position: center;
- background-size: cover;
- margin-right: 0px;
- margin-left: 12px;
- }
- }
- [purpose='video-modal'] {
- [purpose='modal-dialog'] {
- width: 100%;
- max-width: 100%;
- }
- [purpose='modal-content'] {
- max-width: 1140px;
- height: 641px;
- background-color: transparent;
- box-shadow: none;
- border: none;
- padding: 0px;
- margin-top: 150px;
- margin-left: auto;
- margin-right: auto;
- [purpose='modal-close-button'] {
- top: -40px;
- right: 0px;
- border-radius: 50%;
- width: 32px;
- height: 32px;
- padding: 0px 0px 4px 0px;
- background-color: #192147;
- color: #FFF;
- opacity: 1;
- }
- }
- iframe {
- width: 1140px;
- height: 641px;
- }
- }
- [parasails-component='logo-carousel'] {
- margin-bottom: 80px;
- }
- [purpose='calendar-feature'] {
- margin-bottom: 140px;
- h3 {
- margin-bottom: 24px;
- }
- [purpose='calendar-feature-text'] {
- max-width: 480px;
- }
- [purpose='new-badge'] {
- background-color: #0587FF;
- padding: 4px 8px 3px 8px;
- display: flex;
- align-items: center;
- border-radius: 14px;
- color: #FFF;
- font-size: 12px;
- font-weight: 500;
- line-height: 18px;
- text-transform: uppercase;
- margin-bottom: 12px;
- width: min-content;
- }
- [purpose='calendar-checklist'] {
- margin-top: 8px;
- margin-bottom: 24px;
- p {
- font-size: 14px;
- font-style: normal;
- font-weight: 400;
- line-height: 21px;
- padding-left: 37px;
- text-indent: -37px;
- margin-bottom: 1.5rem;
- &:last-of-type {
- margin-bottom: 0px;
- }
- }
- p::before {
- content: ' ';
- background-image: url('/images/icon-checkmark-green-20x20@2x.png');
- background-size: 20px 20px;
- display: inline-block;
- position: relative;
- top: 5px;
- margin-right: 16px;
- width: 20px;
- height: 20px;
- }
- }
- [purpose='feature-video'] {
- margin-left: 80px;
- max-width: 468px;
- video {
- max-width: 100%;
- max-height: 100%;
- border-radius: 16px;
- }
- }
- [purpose='video-button'] {
- margin-top: 12px;
- cursor: pointer;
- img {
- height: 32px;
- margin-right: 8px;
- }
- font-size: 14px;
- font-weight: 700;
- line-height: 21px;
- }
- }
-
- [purpose='feature'] {
- margin-bottom: 180px;
- h3 {
- margin-bottom: 24px;
- }
- &:last-of-type {
- margin-bottom: 0px;
- }
- }
-
- [purpose='feature'].flex-column {
- [purpose='feature-text'] {
- margin-left: 48px;
- }
- }
- [purpose='feature'].flex-column-reverse {
- [purpose='feature-text'] {
- margin-right: 48px;
- }
- }
- [purpose='feature-image'] {
- max-width: 380px;
- img {
- max-width: 100%;
- max-height: 100%;
- width: 380px;
- }
- }
- [purpose='feature-text'] {
- width: 468px;
- }
- [purpose='checklist'] {
- margin-top: 8px;
- p {
- font-size: 14px;
- font-style: normal;
- font-weight: 400;
- line-height: 21px;
- padding-left: 37px;
- text-indent: -37px;
- margin-bottom: 1.5rem;
- &:last-of-type {
- margin-bottom: 0px;
- }
- }
- p::before {
- content: ' ';
- background-image: url('/images/icon-checkmark-green-20x20@2x.png');
- background-size: 20px 20px;
- display: inline-block;
- position: relative;
- top: 5px;
- margin-right: 16px;
- width: 20px;
- height: 20px;
- }
- }
- [purpose='tweets-container'] {
- padding-top: 200px;
- max-width: 960px;
- }
- [parasails-component='scrollable-tweets'] {
- [purpose='tweets'] {
- margin-top: 40px;
- }
- }
- [purpose='bottom-gradient'] {
- background: linear-gradient(180deg, #FFFFFF 0%, #E9F4F4 100%);
- }
- [purpose='bottom-cloud-city-banner'] {
- background: linear-gradient(180deg, #E9F4F4 0%, #FFFFFF 100%);
- img {
- width: 100%;
- }
- }
-
- @media (max-width: 1199px) {
- [purpose='page-container'] {
- padding-left: 80px;
- padding-right: 80px;
- }
- [purpose='video-modal'] {
- [purpose='modal-dialog'] {
- width: 100%;
- max-width: 100%;
- }
- [purpose='modal-content'] {
- max-width: 960px;
- height: 540px;
- }
- iframe {
- width: 960px;
- height: 540px;
- }
- }
- }
-
- @media (max-width: 991px) {
- [purpose='page-container'] {
- padding-left: 40px;
- padding-right: 40px;
- }
- [purpose='calendar-section'] {
- padding: 0px 40px 40px 40px;
- }
- [purpose='calendar-feature'] {
- [purpose='feature-video'] {
- margin-left: auto;
- margin-right: auto;
- margin-bottom: 40px;
- }
- }
- [purpose='page-content'] {
- max-width: 840px;
- }
- [purpose='tweets-container'] {
- max-width: 840px;
- }
- [purpose='testimonial-videos'] {
- width: 410px;
- }
- [purpose='hero-text'] {
- width: 410px;
- }
- [purpose='feature-text'] {
- width: 410px;
- }
- }
-
- @media (max-width: 767px) {
-
- [purpose='page-container'] {
- padding-left: 40px;
- padding-right: 40px;
- }
- [purpose='page-content'] {
- max-width: 480px;
- }
- [purpose='tweets-container'] {
- max-width: 480px;
- padding-left: 40px;
- padding-right: 40px;
- padding-top: 120px;
- }
- [purpose='page-headline'] {
- padding-bottom: 80px;
- width: 100%;
- h2 {
- font-size: 42px;
- line-height: 50.4px;
- }
- }
- [purpose='hero-image'] {
- margin-right: unset;
- }
- [purpose='hero-text'] {
- width: unset;
- margin-left: auto;
- }
- [purpose='testimonial-videos'] {
- width: unset;
- margin-top: 60px;
- }
- [purpose='button-row'] {
- max-width: 100%;
- [purpose='cta-button'] {
- margin-right: 0px;
- width: 100%;
- margin-bottom: 24px;
- }
- }
- [purpose='calendar-section'] {
- padding: 0px 32px 40px 32px;
- }
- [purpose='calendar-card-body'] {
- width: 100%;
- padding-left: 24px;
- padding-right: 24px;
- padding-bottom: 48px;
- padding-top: 60px;
- margin-right: 0px;
- text-align: center;
- }
- [purpose='calendar-card'] {
- height: unset;
- }
- [purpose='calendar-image'] {
- padding-top: 0;
- padding-bottom: 0;
- height: 420px;
- width: 100%;
- &:before {
- content: '';
- background: none;
- }
- }
- [purpose='feature-text'] {
- width: unset;
- }
- [purpose='feature'] {
- margin-bottom: 120px;
- }
- [purpose='feature'].flex-column {
- [purpose='feature-text'] {
- margin-left: auto;
- }
- }
- [purpose='feature'].flex-column-reverse {
- [purpose='feature-text'] {
- margin-right: auto;
- }
- }
- [purpose='feature-image'] {
- margin-left: auto;
- margin-right: auto;
- margin-bottom: 60px;
- }
- [purpose='hero-image'] {
- margin-bottom: 60px;
- }
- [purpose='testimonial-videos'] {
- margin-left: auto;
- margin-right: auto;
- }
- [purpose='testimonial-quote'] {
- width: 100%;
- [purpose='quote'] {
- img {
- margin-right: auto;
- margin-left: auto;
- }
- text-align: center;
- }
- [purpose='quote-attribution'] {
- margin-right: auto;
- margin-left: auto;
- }
- }
- [purpose='video-modal'] {
- [purpose='modal-dialog'] {
- max-width: 97vw;
- }
- [purpose='modal-content'] {
- max-width: 540px;
- height: 304px;
- }
- iframe {
- width: 540px;
- height: 304px;
- }
- }
- }
-
- @media (max-width: 575px) {
- [purpose='page-container'] {
- padding-left: 40px;
- padding-right: 40px;
- }
- [purpose='tweets-container'] {
- padding-left: 40px;
- padding-right: 40px;
- }
- [purpose='feature-image'] {
- img {
- max-width: 100%;
- }
- }
- [purpose='hero-image'] {
- img {
- max-width: 100%;
- }
- }
- [purpose='testimonial-video'] {
- width: 200px;
- height: 160px;
- &:first-of-type {
- margin-right: 10px;
- margin-left: auto;
- }
- &:last-of-type {
- margin-right: auto;
- margin-left: 10px;
- }
- }
-
- [purpose='video-modal'] {
- [purpose='modal-content'] {
- width: 95vw;
- height: calc(~'9/16 * 95vw');
- }
- iframe {
- width: 95vw;
- height: calc(~'9/16 * 95vw');
- }
- }
- [purpose='calendar-section'] {
- padding: 0px 24px 40px 24px;
- }
- }
- @media (max-width: 472px) {
- [purpose='testimonial-videos'] {
- flex-direction: column;
- }
- [purpose='testimonial-video'] {
- width: 223px;
- height: 168px;
- &:first-of-type {
- margin-right: auto;
- margin-left: auto;
- margin-bottom: 24px;
- }
- &:last-of-type {
- margin-right: auto;
- margin-left: auto;
- }
- }
-
- }
- @media (max-width: 375px) {
- [purpose='page-container'] {
- padding-left: 32px;
- padding-right: 32px;
- }
- [purpose='tweets-container'] {
- padding-left: 32px;
- padding-right: 32px;
- }
- [purpose='hero'] {
- padding-top: 40px;
- padding-bottom: 40px;
- }
- [purpose='hero-image'] {
- max-height: 360px;
- max-width: unset;
- img {
- max-width: 100%;
- max-height: 100%;
- width: 261px;
- }
- }
- [purpose='testimonials'] {
- padding-top: 40px;
- margin-bottom: 80px;
- }
- [purpose='testimonial-videos'] {
- flex-direction: column;
- margin-top: 40px;
- }
- [purpose='calendar-card-body'] {
- padding-left: 16px;
- padding-right: 16px;
- h3 {
- font-size: 24px;
- }
- }
- [purpose='calendar-image'] {
- height: 287px;
- }
- [purpose='calendar-section'] {
- padding: 0px 16px 40px 16px;
- }
- [purpose='feature-image'] {
- margin-bottom: 40px;
- }
- }
-}
diff --git a/website/config/custom.js b/website/config/custom.js
index f791a82e6f..4e0331a1ff 100644
--- a/website/config/custom.js
+++ b/website/config/custom.js
@@ -301,7 +301,6 @@ module.exports.custom = {
// "Secret handbook"
// Standard operating procedures (SOP), etc that would be public handbook content except for that it's confidential.
'README.md': ['mikermcneil'],// « about this repo
- 'cold-outbound-strategy.md': ['mikermcneil', 'sampfluger88'],// « Cold outbound strategy (see fleetdm.com/handbook/company/why-this-way for our vision of a better way to sell)
// GitHub issue templates
'.github/ISSUE_TEMPLATE': ['mikermcneil', 'sampfluger88', 'lukeheath'],// FUTURE: Bust out individual maintainership for issue templates once relevant DRIs are GitHub, markdown, and content design-certified
diff --git a/website/config/policies.js b/website/config/policies.js
index befa1d166c..1681d1d645 100644
--- a/website/config/policies.js
+++ b/website/config/policies.js
@@ -46,7 +46,7 @@ module.exports.policies = {
'deliver-apple-csr': true,
'download-rss-feed': true,
'view-endpoint-ops': true,
- 'view-vulnerability-management': true,
+ 'view-software-management': true,
'deliver-mdm-demo-email': true,
'view-support': true,
'view-integrations': true,
diff --git a/website/config/routes.js b/website/config/routes.js
index d73683ded0..48f47ac732 100644
--- a/website/config/routes.js
+++ b/website/config/routes.js
@@ -236,11 +236,11 @@ module.exports.routes = {
}
},
- 'GET /vulnerability-management': {
- action: 'view-vulnerability-management',
+ 'GET /software-management': {
+ action: 'view-software-management',
locals: {
- pageTitleForMeta: 'Vulnerability management',
- pageDescriptionForMeta: 'Report CVEs, software inventory, security posture, and other risks down to the chipset of any endpoint with Fleet.',
+ pageTitleForMeta: 'Software management',
+ pageDescriptionForMeta: 'Pick from a curated app library or upload your own custom packages. Configure custom installation scripts if you need or let Fleet do it for you.',
currentSection: 'platform',
}
},
@@ -538,12 +538,13 @@ module.exports.routes = {
'GET /try-fleet/waitlist': '/try-fleet',
'GET /endpoint-operations': '/endpoint-ops',// « just in case we type it the wrong way
'GET /example-dep-profile': 'https://github.com/fleetdm/fleet/blob/main/it-and-security/lib/automatic-enrollment.dep.json',
+ 'GET /vulnerability-management': (req,res)=> { let originalQueryString = req.url.match(/\?(.+)$/) ? '?'+req.url.match(/\?(.+)$/)[1] : ''; return res.redirect(301, sails.config.custom.baseUrl+'/software-management'+originalQueryString);},
// Shortlinks for texting friends, radio ads, etc
'GET /mdm': '/device-management?utm_content=mdm',// « alias for radio ad
'GET /it': '/endpoint-ops?utm_content=eo-it',
'GET /seceng': '/endpoint-ops?utm_content=eo-security',
- 'GET /vm': '/vulnerability-management?utm_content=vm',
+ 'GET /vm': '/software-management?utm_content=vm',
// Fleet UI
// =============================================================================================================
diff --git a/website/views/layouts/layout.ejs b/website/views/layouts/layout.ejs
index 0338df7a7b..e33ef86293 100644
--- a/website/views/layouts/layout.ejs
+++ b/website/views/layouts/layout.ejs
@@ -130,7 +130,7 @@
-