Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PostgreSQL storage #50

Closed
wants to merge 22 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ A brief overview of the various command-line switches and HTTP endpoints and API

## Features

- Horizontal scaling: zero/minimal local state. Persistence in storage layers. MySQL backend provided in the box.
- Horizontal scaling: zero/minimal local state. Persistence in storage layers. MySQL and PostgreSQL backends provided in the box.
- Multiple APNs topics: potentially multi-tenant.
- Multi-command targeting: send the same command (or pushes) to multiple enrollments without individually queuing commands.
- Migration endpoint: allow migrating MDM enrollments between storage backends or (supported) MDM servers
Expand Down
66 changes: 62 additions & 4 deletions cmd/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ import (
"github.com/micromdm/nanomdm/storage/allmulti"
"github.com/micromdm/nanomdm/storage/file"
"github.com/micromdm/nanomdm/storage/mysql"
"github.com/micromdm/nanomdm/storage/pgsql"

_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
)

type StringAccumulator []string
Expand Down Expand Up @@ -72,6 +74,12 @@ func (s *Storage) Parse(logger log.Logger) (storage.AllStorage, error) {
return nil, err
}
mdmStorage = append(mdmStorage, mysqlStorage)
case "pgsql":
pgsqlStorage, err := pgsqlStorageConfig(dsn, options, logger)
if err != nil {
return nil, err
}
mdmStorage = append(mdmStorage, pgsqlStorage)
default:
return nil, fmt.Errorf("unknown storage: %s", storage)
}
Expand Down Expand Up @@ -99,12 +107,62 @@ func fileStorageConfig(dsn, options string) (*file.FileStorage, error) {
}

func mysqlStorageConfig(dsn, options string, logger log.Logger) (*mysql.MySQLStorage, error) {
if options != "" {
return nil, NoStorageOptions
}
logger = logger.With("storage", "mysql")
opts := []mysql.Option{
mysql.WithDSN(dsn),
mysql.WithLogger(logger.With("storage", "mysql")),
mysql.WithLogger(logger),
}
if options != "" {
for k, v := range splitOptions(options) {
switch k {
case "delete":
if v == "1" {
opts = append(opts, mysql.WithDeleteCommands())
logger.Debug("msg", "deleting commands")
} else if v != "0" {
return nil, fmt.Errorf("invalid value for delete option: %q", v)
}
default:
return nil, fmt.Errorf("invalid option: %q", k)
}
}
}
return mysql.New(opts...)
}

func splitOptions(s string) map[string]string {
out := make(map[string]string)
opts := strings.Split(s, ",")
for _, opt := range opts {
optKAndV := strings.SplitN(opt, "=", 2)
if len(optKAndV) < 2 {
optKAndV = append(optKAndV, "")
}
out[optKAndV[0]] = optKAndV[1]
}
return out
}

func pgsqlStorageConfig(dsn, options string, logger log.Logger) (*pgsql.PgSQLStorage, error) {
logger = logger.With("storage", "pgsql")
opts := []pgsql.Option{
pgsql.WithDSN(dsn),
pgsql.WithLogger(logger),
}
if options != "" {
for k, v := range splitOptions(options) {
switch k {
case "delete":
if v == "1" {
opts = append(opts, pgsql.WithDeleteCommands())
logger.Debug("msg", "deleting commands")
} else if v != "0" {
return nil, fmt.Errorf("invalid value for delete option: %q", v)
}
default:
return nil, fmt.Errorf("invalid option: %q", k)
}
}
}
return pgsql.New(opts...)
}
23 changes: 22 additions & 1 deletion docs/operations-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The `-storage`, `-dsn`, & `-storage-options` flags together configure the storag

* `-storage file`

Configures the `file` storage backend. This manages enrollment and command data within plain filesystem files and directories. It has zero dependencies and should run out of the box. The `-dsn` flag specifies the filesystem directory for the database.
Configures the `file` storage backend. This manages enrollment and command data within plain filesystem files and directories. It has zero dependencies and should run out of the box. The `-dsn` flag specifies the filesystem directory for the database. The `file` backend has no storage options.

*Example:* `-storage file -dsn /path/to/my/db`

Expand All @@ -56,6 +56,27 @@ Configures the MySQL storage backend. The `-dsn` flag should be in the [format t

*Example:* `-storage mysql -dsn nanomdm:nanomdm/mymdmdb`

Options are specified as a comma-separated list of "key=value" pairs. The mysql backend supports these options:

* `delete=1`, `delete=0`
* This option turns on or off the command and response deleter. It is disabled by default. When enabled (with `delete=1`) command responses, queued commands, and commands themeselves will be deleted from the database after enrollments have responded to a command.

*Example:* `-storage mysql -dsn nanomdm:nanomdm/mymdmdb -storage-options delete=1`

#### pgsql storage backend

* `-storage pgsql`

Configures the PostgreSQL storage backend. The `-dsn` flag should be in the [format the SQL driver expects](https://pkg.go.dev/github.com/lib/pq#pkg-overview). Be sure to create your tables with the [schema.sql](../storage/pgsql/schema.sql) file that corresponds to your NanoMDM version. Also make sure you apply any schema changes for each updated version (i.e. execute the numbered schema change files). PostgreSQL 9.5 or later is required.

*Example:* `-storage pgsql -dsn postgres://postgres:toor@localhost:5432/nanomdm?sslmode=disable`

Options are specified as a comma-separated list of "key=value" pairs. The pgsql backend supports these options:
* `delete=1`, `delete=0`
* This option turns on or off the command and response deleter. It is disabled by default. When enabled (with `delete=1`) command responses, queued commands, and commands themselves will be deleted from the database after enrollments have responded to a command.

*Example:* `-storage pgsql -dsn postgres://postgres:toor@localhost/nanomdm -storage-options delete=1`

#### multi-storage backend

You can configure multiple storage backends to be used simultaneously. Specifying multiple sets of `-storage`, `-dsn`, & `-storage-options` flags will configure the "multi-storage" adapter. The flags must be specified in sets and are related to each other in the order they're specified: for example the first `-storage` flag corresponds to the first `-dsn` flag and so forth.
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ require (
github.com/RobotsAndPencils/buford v0.14.0
github.com/go-sql-driver/mysql v1.6.0
github.com/groob/plist v0.0.0-20220217120414-63fa881b19a5
github.com/lib/pq v1.10.6
go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/groob/plist v0.0.0-20220217120414-63fa881b19a5 h1:saaSiB25B1wgaxrshQhurfPKUGJ4It3OxNJUy0rdOjU=
github.com/groob/plist v0.0.0-20220217120414-63fa881b19a5/go.mod h1:itkABA+w2cw7x5nYUS/pLRef6ludkZKOigbROmCTaFw=
github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 h1:CCriYyAfq1Br1aIYettdHZTy8mBTIPo7We18TuO/bak=
go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
Expand Down
12 changes: 10 additions & 2 deletions storage/mysql/mysql.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Pacakge mysql stores and retrieves MDM data from SQL
// Package mysql Package mysql stores and retrieves MDM data from MySQL
package mysql

import (
Expand All @@ -18,13 +18,15 @@ var ErrNoCert = errors.New("no certificate in MDM Request")
type MySQLStorage 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)
Expand Down Expand Up @@ -53,6 +55,12 @@ func WithDB(db *sql.DB) Option {
}
}

func WithDeleteCommands() Option {
return func(c *config) {
c.rm = true
}
}

func New(opts ...Option) (*MySQLStorage, error) {
cfg := &config{logger: log.NopLogger, driver: "mysql"}
for _, opt := range opts {
Expand All @@ -68,7 +76,7 @@ func New(opts ...Option) (*MySQLStorage, error) {
if err = cfg.db.Ping(); err != nil {
return nil, err
}
return &MySQLStorage{db: cfg.db, logger: cfg.logger}, nil
return &MySQLStorage{db: cfg.db, logger: cfg.logger, rm: cfg.rm}, nil
}

// nullEmptyString returns a NULL string if s is empty.
Expand Down
57 changes: 57 additions & 0 deletions storage/mysql/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,70 @@ func (m *MySQLStorage) EnqueueCommand(ctx context.Context, ids []string, cmd *md
return nil, tx.Commit()
}

func (s *MySQLStorage) deleteCommand(ctx context.Context, tx *sql.Tx, id, uuid string) error {
// delete command result (i.e. NotNows) and this queued command
_, err := tx.ExecContext(
ctx, `
DELETE
q, r
FROM
enrollment_queue AS q
LEFT JOIN command_results AS r
ON q.command_uuid = r.command_uuid AND r.id = q.id
WHERE
q.id = ? AND q.command_uuid = ?;
`,
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
c
FROM
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 = ? AND
q.command_uuid IS NULL AND
r.command_uuid IS NULL;
`,
uuid,
)
return err
}

func (s *MySQLStorage) 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 *MySQLStorage) 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 DUPLICATE KEY we don't UPDATE the
Expand Down
15 changes: 13 additions & 2 deletions storage/mysql/queue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func TestQueue(t *testing.T) {
t.Fatal("MySQL DSN flag not provided to test")
}

storage, err := New(WithDSN(*flDSN))
storage, err := New(WithDSN(*flDSN), WithDeleteCommands())
if err != nil {
t.Fatal(err)
}
Expand All @@ -97,5 +97,16 @@ func TestQueue(t *testing.T) {
t.Fatal(err)
}

test.TestQueue(t, deviceUDID, storage)
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)
})
}
36 changes: 36 additions & 0 deletions storage/pgsql/bstoken.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package pgsql

import (
"github.com/micromdm/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
}
52 changes: 52 additions & 0 deletions storage/pgsql/certauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package pgsql

import (
"context"
"strings"

"github.com/micromdm/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) 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 NOTHING;`,
r.ID,
strings.ToLower(hash),
)
return err
}
Loading