From 911835efed4069f0760794be29fe45228d114709 Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Thu, 26 May 2022 15:53:04 +0300 Subject: [PATCH 01/23] started schema migration from MySQL to PostgreSQL --- storage/mysql/schema.sql | 2 +- storage/postgresql/bstoken.go | 36 ++++ storage/postgresql/certauth.go | 51 ++++++ storage/postgresql/migrate.go | 61 +++++++ storage/postgresql/mysql.go | 261 +++++++++++++++++++++++++++ storage/postgresql/push.go | 47 +++++ storage/postgresql/pushcert.go | 62 +++++++ storage/postgresql/queue.go | 131 ++++++++++++++ storage/postgresql/queue_test.go | 101 +++++++++++ storage/postgresql/schema.sql | 299 +++++++++++++++++++++++++++++++ 10 files changed, 1050 insertions(+), 1 deletion(-) create mode 100644 storage/postgresql/bstoken.go create mode 100644 storage/postgresql/certauth.go create mode 100644 storage/postgresql/migrate.go create mode 100644 storage/postgresql/mysql.go create mode 100644 storage/postgresql/push.go create mode 100644 storage/postgresql/pushcert.go create mode 100644 storage/postgresql/queue.go create mode 100644 storage/postgresql/queue_test.go create mode 100644 storage/postgresql/schema.sql diff --git a/storage/mysql/schema.sql b/storage/mysql/schema.sql index e870875..744c9cc 100644 --- a/storage/mysql/schema.sql +++ b/storage/mysql/schema.sql @@ -102,7 +102,7 @@ CREATE TABLE enrollments ( enabled BOOLEAN NOT NULL DEFAULT 1, token_update_tally INTEGER NOT NULL DEFAULT 1, - last_seen_at TIMESTAMP NOT NULL, + last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/storage/postgresql/bstoken.go b/storage/postgresql/bstoken.go new file mode 100644 index 0000000..f21e150 --- /dev/null +++ b/storage/postgresql/bstoken.go @@ -0,0 +1,36 @@ +package mysql + +import ( + "github.com/micromdm/nanomdm/mdm" +) + +func (s *MySQLStorage) StoreBootstrapToken(r *mdm.Request, msg *mdm.SetBootstrapToken) error { + _, err := s.db.ExecContext( + r.Context, + `UPDATE devices SET bootstrap_token_b64 = ?, bootstrap_token_at = CURRENT_TIMESTAMP WHERE id = ? LIMIT 1;`, + nullEmptyString(msg.BootstrapToken.BootstrapToken.String()), + r.ID, + ) + if err != nil { + return err + } + return s.updateLastSeen(r) +} + +func (s *MySQLStorage) 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 = ?;`, + 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/storage/postgresql/certauth.go b/storage/postgresql/certauth.go new file mode 100644 index 0000000..cc64c0e --- /dev/null +++ b/storage/postgresql/certauth.go @@ -0,0 +1,51 @@ +package mysql + +import ( + "context" + "strings" + + "github.com/micromdm/nanomdm/mdm" +) + +// Executes SQL statements that return a single COUNT(*) of rows. +func (s *MySQLStorage) 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 *MySQLStorage) EnrollmentHasCertHash(r *mdm.Request, _ string) (bool, error) { + return s.queryRowContextRowExists( + r.Context, + `SELECT COUNT(*) FROM cert_auth_associations WHERE id = ?;`, + r.ID, + ) +} + +func (s *MySQLStorage) HasCertHash(r *mdm.Request, hash string) (bool, error) { + return s.queryRowContextRowExists( + r.Context, + `SELECT COUNT(*) FROM cert_auth_associations WHERE sha256 = ?;`, + strings.ToLower(hash), + ) +} + +func (s *MySQLStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool, error) { + return s.queryRowContextRowExists( + r.Context, + `SELECT COUNT(*) FROM cert_auth_associations WHERE id = ? AND sha256 = ?;`, + r.ID, strings.ToLower(hash), + ) +} + +func (s *MySQLStorage) AssociateCertHash(r *mdm.Request, hash string) error { + _, err := s.db.ExecContext( + r.Context, ` +INSERT INTO cert_auth_associations (id, sha256) VALUES (?, ?) AS new +ON DUPLICATE KEY +UPDATE sha256 = new.sha256;`, + r.ID, + strings.ToLower(hash), + ) + return err +} diff --git a/storage/postgresql/migrate.go b/storage/postgresql/migrate.go new file mode 100644 index 0000000..1e19804 --- /dev/null +++ b/storage/postgresql/migrate.go @@ -0,0 +1,61 @@ +package mysql + +import ( + "context" + + "github.com/micromdm/nanomdm/mdm" +) + +func (s *MySQLStorage) 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/storage/postgresql/mysql.go b/storage/postgresql/mysql.go new file mode 100644 index 0000000..ad78de6 --- /dev/null +++ b/storage/postgresql/mysql.go @@ -0,0 +1,261 @@ +// Pacakge mysql stores and retrieves MDM data from SQL +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/micromdm/nanomdm/cryptoutil" + "github.com/micromdm/nanomdm/log" + "github.com/micromdm/nanomdm/log/ctxlog" + "github.com/micromdm/nanomdm/mdm" +) + +var ErrNoCert = errors.New("no certificate in MDM Request") + +type MySQLStorage struct { + logger log.Logger + db *sql.DB +} + +type config struct { + driver string + dsn string + db *sql.DB + logger log.Logger +} + +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 New(opts ...Option) (*MySQLStorage, error) { + cfg := &config{logger: log.NopLogger, driver: "mysql"} + 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 &MySQLStorage{db: cfg.db, logger: cfg.logger}, 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 *MySQLStorage) 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 + (?, ?, ?, ?, CURRENT_TIMESTAMP) AS new +ON DUPLICATE KEY +UPDATE + identity_cert = new.identity_cert, + serial_number = new.serial_number, + authenticate = new.authenticate, + authenticate_at = CURRENT_TIMESTAMP;`, + r.ID, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw, + ) + return err +} + +func (s *MySQLStorage) storeDeviceTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error { + query := `UPDATE devices SET token_update = ?, token_update_at = CURRENT_TIMESTAMP` + args := []interface{}{msg.Raw} + // separately store the Unlock Token per MDM spec + if len(msg.UnlockToken) > 0 { + query += `, unlock_token = ?, unlock_token_at = CURRENT_TIMESTAMP` + args = append(args, msg.UnlockToken) + } + query += ` WHERE id = ? LIMIT 1;` + args = append(args, r.ID) + _, err := s.db.ExecContext(r.Context, query, args...) + return err +} + +func (s *MySQLStorage) 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 + (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) AS new +ON DUPLICATE KEY +UPDATE + device_id = new.device_id, + user_short_name = new.user_short_name, + user_long_name = new.user_long_name, + token_update = new.token_update, + token_update_at = CURRENT_TIMESTAMP;`, + r.ID, + r.ParentID, + nullEmptyString(msg.UserShortName), + nullEmptyString(msg.UserLongName), + msg.Raw, + ) + return err +} + +func (s *MySQLStorage) 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 + (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 1) AS new +ON DUPLICATE KEY +UPDATE + device_id = new.device_id, + user_id = new.user_id, + type = new.type, + topic = new.topic, + push_magic = new.push_magic, + token_hex = new.token_hex, + enabled = 1, + last_seen_at = CURRENT_TIMESTAMP, + enrollments.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 *MySQLStorage) RetrieveTokenUpdateTally(ctx context.Context, id string) (int, error) { + var tally int + err := s.db.QueryRowContext( + ctx, + `SELECT token_update_tally FROM enrollments WHERE id = ?;`, + id, + ).Scan(&tally) + return tally, err +} + +func (s *MySQLStorage) 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( + r.Context, ` +INSERT INTO users + (id, device_id, user_short_name, user_long_name, `+colName+`, `+colAtName+`) +VALUES + (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) AS new +ON DUPLICATE KEY +UPDATE + device_id = new.device_id, + user_short_name = new.user_short_name, + user_long_name = new.user_long_name, + `+colName+` = new.`+colName+`, + `+colAtName+` = new.`+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 *MySQLStorage) 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 = 0, token_update_tally = 0, last_seen_at = CURRENT_TIMESTAMP WHERE device_id = ? AND enabled = 1;`, + r.ID, + ) + return err +} + +func (s *MySQLStorage) updateLastSeen(r *mdm.Request) (err error) { + _, err = s.db.ExecContext( + r.Context, + `UPDATE enrollments SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`, + r.ID, + ) + if err != nil { + err = fmt.Errorf("updating last seen: %w", err) + } + return +} diff --git a/storage/postgresql/push.go b/storage/postgresql/push.go new file mode 100644 index 0000000..9fc106c --- /dev/null +++ b/storage/postgresql/push.go @@ -0,0 +1,47 @@ +package mysql + +import ( + "context" + "errors" + "strings" + + "github.com/micromdm/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 *MySQLStorage) RetrievePushInfo(ctx context.Context, ids []string) (map[string]*mdm.Push, error) { + if len(ids) < 1 { + return nil, errors.New("no ids provided") + } + qs := "?" + strings.Repeat(", ?", len(ids)-1) + args := make([]interface{}, len(ids)) + for i, v := range ids { + args[i] = v + } + rows, err := s.db.QueryContext( + ctx, + `SELECT id, topic, push_magic, token_hex FROM enrollments WHERE id IN (`+qs+`);`, + 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/storage/postgresql/pushcert.go b/storage/postgresql/pushcert.go new file mode 100644 index 0000000..aedd364 --- /dev/null +++ b/storage/postgresql/pushcert.go @@ -0,0 +1,62 @@ +package mysql + +import ( + "context" + "crypto/tls" + "strconv" + + "github.com/micromdm/nanomdm/cryptoutil" +) + +func (s *MySQLStorage) 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 = ?;`, + 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 *MySQLStorage) 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 = ?;`, + topic, + ).Scan(&dbStaleToken) + return dbStaleToken != staleTokenInt, err +} + +func (s *MySQLStorage) 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 + (?, ?, ?, 0) AS new +ON DUPLICATE KEY +UPDATE + cert_pem = new.cert_pem, + key_pem = new.key_pem, + push_certs.stale_token = push_certs.stale_token + 1;`, + topic, pemCert, pemKey, + ) + return err +} diff --git a/storage/postgresql/queue.go b/storage/postgresql/queue.go new file mode 100644 index 0000000..4e556fc --- /dev/null +++ b/storage/postgresql/queue.go @@ -0,0 +1,131 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + "github.com/micromdm/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 (?, ?, ?);`, + cmd.CommandUUID, cmd.Command.RequestType, cmd.Raw, + ) + if err != nil { + return err + } + query := `INSERT INTO enrollment_queue (id, command_uuid) VALUES (?, ?)` + query += strings.Repeat(", (?, ?)", len(ids)-1) + args := make([]interface{}, len(ids)*2) + for i, id := range ids { + args[i*2] = id + args[i*2+1] = cmd.CommandUUID + } + _, err = tx.ExecContext(ctx, query+";", args...) + return err +} + +func (m *MySQLStorage) EnqueueCommand(ctx context.Context, ids []string, cmd *mdm.Command) (map[string]error, error) { + tx, err := m.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 *MySQLStorage) StoreCommandReport(r *mdm.Request, result *mdm.CommandResults) error { + if err := s.updateLastSeen(r); err != nil { + return err + } + if result.Status == "Idle" { + return nil + } + notNowConstants := "NULL, 0" + notNowBumpTallySQL := "" + // note that due to the ON DUPLICATE KEY 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 = `, command_results.not_now_tally = command_results.not_now_tally + 1` + } + _, err := s.db.ExecContext( + r.Context, ` +INSERT INTO command_results + (id, command_uuid, status, result, not_now_at, not_now_tally) +VALUES + (?, ?, ?, ?, `+notNowConstants+`) AS new +ON DUPLICATE KEY +UPDATE + status = new.status, + result = new.result`+notNowBumpTallySQL+`;`, + r.ID, + result.CommandUUID, + result.Status, + result.Raw, + ) + return err +} + +func (s *MySQLStorage) 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 = ? AND active = 1 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 *MySQLStorage) ClearQueue(r *mdm.Request) error { + if r.ParentID != "" { + return errors.New("can only clear a device channel queue") + } + // Because we're joining on and WHERE-ing by the enrollments table + // this will clear (mark inactive) the queue of not only this + // device ID, but all user-channel enrollments with a 'parent' ID of + // this device, too. + _, err := s.db.ExecContext( + r.Context, + ` +UPDATE + 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 +SET + q.active = 0 +WHERE + e.device_id = ? AND + active = 1 AND + (r.status IS NULL OR r.status = 'NotNow');`, + r.ID, + ) + return err +} diff --git a/storage/postgresql/queue_test.go b/storage/postgresql/queue_test.go new file mode 100644 index 0000000..f14e9b3 --- /dev/null +++ b/storage/postgresql/queue_test.go @@ -0,0 +1,101 @@ +//go:build integration +// +build integration + +package mysql + +import ( + "context" + "errors" + "flag" + "io/ioutil" + "testing" + + "github.com/micromdm/nanomdm/mdm" + "github.com/micromdm/nanomdm/storage/internal/test" + + _ "github.com/go-sql-driver/mysql" +) + +var flDSN = flag.String("dsn", "", "DSN of test MySQL 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 *MySQLStorage) 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("MySQL DSN flag not provided to test") + } + + storage, err := New(WithDSN(*flDSN)) + if err != nil { + t.Fatal(err) + } + + err = enrollTestDevice(storage) + if err != nil { + t.Fatal(err) + } + + test.TestQueue(t, deviceUDID, storage) +} diff --git a/storage/postgresql/schema.sql b/storage/postgresql/schema.sql new file mode 100644 index 0000000..3d3c5c7 --- /dev/null +++ b/storage/postgresql/schema.sql @@ -0,0 +1,299 @@ +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 TEXT 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, + -- TODO MySQL ON UPDATE CURRENT_TIMESTAMP to pgSQL trigger func + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + 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, + -- TODO MySQL ON UPDATE CURRENT_TIMESTAMP to pgSQL trigger func + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + 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 DEFAULT CURRENT_TIMESTAMP, + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + -- TODO MySQL ON UPDATE CURRENT_TIMESTAMP to pgSQL trigger func + 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 themsevles 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, + -- TODO MySQL ON UPDATE CURRENT_TIMESTAMP to pgSQL trigger func + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + PRIMARY KEY (command_uuid), + + CHECK (command_uuid != '') , + CHECK (request_type != ''), + CHECK (SUBSTRING(command FROM 1 FOR 5) = ' Date: Thu, 26 May 2022 18:54:20 +0300 Subject: [PATCH 02/23] changes MySQL -> PostgreSQL prepared statements ? -> $1... --- storage/postgresql/bstoken.go | 10 ++-- storage/postgresql/certauth.go | 20 ++++---- storage/postgresql/migrate.go | 4 +- .../postgresql/{mysql.go => postgresql.go} | 51 +++++++++---------- storage/postgresql/push.go | 14 +++-- storage/postgresql/pushcert.go | 14 ++--- storage/postgresql/queue.go | 10 ++-- storage/postgresql/queue_test.go | 10 ++-- 8 files changed, 68 insertions(+), 65 deletions(-) rename storage/postgresql/{mysql.go => postgresql.go} (79%) diff --git a/storage/postgresql/bstoken.go b/storage/postgresql/bstoken.go index f21e150..b197b15 100644 --- a/storage/postgresql/bstoken.go +++ b/storage/postgresql/bstoken.go @@ -1,13 +1,13 @@ -package mysql +package postgresql import ( "github.com/micromdm/nanomdm/mdm" ) -func (s *MySQLStorage) StoreBootstrapToken(r *mdm.Request, msg *mdm.SetBootstrapToken) error { +func (s *PgSQLStorage) StoreBootstrapToken(r *mdm.Request, msg *mdm.SetBootstrapToken) error { _, err := s.db.ExecContext( r.Context, - `UPDATE devices SET bootstrap_token_b64 = ?, bootstrap_token_at = CURRENT_TIMESTAMP WHERE id = ? LIMIT 1;`, + `UPDATE devices SET bootstrap_token_b64 = $1, bootstrap_token_at = CURRENT_TIMESTAMP WHERE id = $2;`, nullEmptyString(msg.BootstrapToken.BootstrapToken.String()), r.ID, ) @@ -17,11 +17,11 @@ func (s *MySQLStorage) StoreBootstrapToken(r *mdm.Request, msg *mdm.SetBootstrap return s.updateLastSeen(r) } -func (s *MySQLStorage) RetrieveBootstrapToken(r *mdm.Request, _ *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error) { +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 = ?;`, + `SELECT bootstrap_token_b64 FROM devices WHERE id = $1;`, r.ID, ).Scan(&tokenB64) if err != nil { diff --git a/storage/postgresql/certauth.go b/storage/postgresql/certauth.go index cc64c0e..83a15ff 100644 --- a/storage/postgresql/certauth.go +++ b/storage/postgresql/certauth.go @@ -1,4 +1,4 @@ -package mysql +package postgresql import ( "context" @@ -8,40 +8,40 @@ import ( ) // Executes SQL statements that return a single COUNT(*) of rows. -func (s *MySQLStorage) queryRowContextRowExists(ctx context.Context, query string, args ...interface{}) (bool, error) { +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 *MySQLStorage) EnrollmentHasCertHash(r *mdm.Request, _ string) (bool, error) { +func (s *PgSQLStorage) EnrollmentHasCertHash(r *mdm.Request, _ string) (bool, error) { return s.queryRowContextRowExists( r.Context, - `SELECT COUNT(*) FROM cert_auth_associations WHERE id = ?;`, + `SELECT COUNT(*) FROM cert_auth_associations WHERE id = $1;`, r.ID, ) } -func (s *MySQLStorage) HasCertHash(r *mdm.Request, hash string) (bool, error) { +func (s *PgSQLStorage) HasCertHash(r *mdm.Request, hash string) (bool, error) { return s.queryRowContextRowExists( r.Context, - `SELECT COUNT(*) FROM cert_auth_associations WHERE sha256 = ?;`, + `SELECT COUNT(*) FROM cert_auth_associations WHERE sha256 = $1;`, strings.ToLower(hash), ) } -func (s *MySQLStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool, error) { +func (s *PgSQLStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool, error) { return s.queryRowContextRowExists( r.Context, - `SELECT COUNT(*) FROM cert_auth_associations WHERE id = ? AND sha256 = ?;`, + `SELECT COUNT(*) FROM cert_auth_associations WHERE id = $1 AND sha256 = $2;`, r.ID, strings.ToLower(hash), ) } -func (s *MySQLStorage) AssociateCertHash(r *mdm.Request, hash string) error { +func (s *PgSQLStorage) AssociateCertHash(r *mdm.Request, hash string) error { _, err := s.db.ExecContext( r.Context, ` -INSERT INTO cert_auth_associations (id, sha256) VALUES (?, ?) AS new +INSERT INTO cert_auth_associations (id, sha256) VALUES ($1, $2) AS new ON DUPLICATE KEY UPDATE sha256 = new.sha256;`, r.ID, diff --git a/storage/postgresql/migrate.go b/storage/postgresql/migrate.go index 1e19804..13541e6 100644 --- a/storage/postgresql/migrate.go +++ b/storage/postgresql/migrate.go @@ -1,4 +1,4 @@ -package mysql +package postgresql import ( "context" @@ -6,7 +6,7 @@ import ( "github.com/micromdm/nanomdm/mdm" ) -func (s *MySQLStorage) RetrieveMigrationCheckins(ctx context.Context, c chan<- interface{}) error { +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( diff --git a/storage/postgresql/mysql.go b/storage/postgresql/postgresql.go similarity index 79% rename from storage/postgresql/mysql.go rename to storage/postgresql/postgresql.go index ad78de6..37ed8ac 100644 --- a/storage/postgresql/mysql.go +++ b/storage/postgresql/postgresql.go @@ -1,5 +1,5 @@ -// Pacakge mysql stores and retrieves MDM data from SQL -package mysql +// Package postgresql stores and retrieves MDM data from SQL +package postgresql import ( "context" @@ -15,7 +15,7 @@ import ( var ErrNoCert = errors.New("no certificate in MDM Request") -type MySQLStorage struct { +type PgSQLStorage struct { logger log.Logger db *sql.DB } @@ -53,7 +53,7 @@ func WithDB(db *sql.DB) Option { } } -func New(opts ...Option) (*MySQLStorage, error) { +func New(opts ...Option) (*PgSQLStorage, error) { cfg := &config{logger: log.NopLogger, driver: "mysql"} for _, opt := range opts { opt(cfg) @@ -68,7 +68,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 &PgSQLStorage{db: cfg.db, logger: cfg.logger}, nil } // nullEmptyString returns a NULL string if s is empty. @@ -79,7 +79,7 @@ func nullEmptyString(s string) sql.NullString { } } -func (s *MySQLStorage) StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) error { +func (s *PgSQLStorage) StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) error { var pemCert []byte if r.Certificate != nil { pemCert = cryptoutil.PEMCertificate(r.Certificate.Raw) @@ -89,7 +89,7 @@ func (s *MySQLStorage) StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) INSERT INTO devices (id, identity_cert, serial_number, authenticate, authenticate_at) VALUES - (?, ?, ?, ?, CURRENT_TIMESTAMP) AS new + ($1, $2, $3, $4, CURRENT_TIMESTAMP) AS new ON DUPLICATE KEY UPDATE identity_cert = new.identity_cert, @@ -101,21 +101,20 @@ UPDATE return err } -func (s *MySQLStorage) storeDeviceTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error { - query := `UPDATE devices SET token_update = ?, token_update_at = CURRENT_TIMESTAMP` - args := []interface{}{msg.Raw} +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, r.ID} // separately store the Unlock Token per MDM spec if len(msg.UnlockToken) > 0 { - query += `, unlock_token = ?, unlock_token_at = CURRENT_TIMESTAMP` + query += `, unlock_token = $3, unlock_token_at = CURRENT_TIMESTAMP ` args = append(args, msg.UnlockToken) } - query += ` WHERE id = ? LIMIT 1;` - args = append(args, r.ID) - _, err := s.db.ExecContext(r.Context, query, args...) + _, err := s.db.ExecContext(r.Context, query+where, args...) return err } -func (s *MySQLStorage) storeUserTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error { +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 { @@ -128,7 +127,7 @@ func (s *MySQLStorage) storeUserTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate INSERT INTO users (id, device_id, user_short_name, user_long_name, token_update, token_update_at) VALUES - (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) AS new + ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) AS new ON DUPLICATE KEY UPDATE device_id = new.device_id, @@ -145,7 +144,7 @@ UPDATE return err } -func (s *MySQLStorage) StoreTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error { +func (s *PgSQLStorage) StoreTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error { var err error var deviceId, userId string resolved := (&msg.Enrollment).Resolved() @@ -168,7 +167,7 @@ func (s *MySQLStorage) StoreTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) er INSERT INTO enrollments (id, device_id, user_id, type, topic, push_magic, token_hex, last_seen_at, token_update_tally) VALUES - (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 1) AS new + ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP, 1) AS new ON DUPLICATE KEY UPDATE device_id = new.device_id, @@ -191,17 +190,17 @@ UPDATE return err } -func (s *MySQLStorage) RetrieveTokenUpdateTally(ctx context.Context, id string) (int, error) { +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 = ?;`, + `SELECT token_update_tally FROM enrollments WHERE id = $1;`, id, ).Scan(&tally) return tally, err } -func (s *MySQLStorage) StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthenticate) error { +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) @@ -215,7 +214,7 @@ func (s *MySQLStorage) StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthen INSERT INTO users (id, device_id, user_short_name, user_long_name, `+colName+`, `+colAtName+`) VALUES - (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) AS new + ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) AS new ON DUPLICATE KEY UPDATE device_id = new.device_id, @@ -236,22 +235,22 @@ UPDATE } // Disable can be called for an Authenticate or CheckOut message -func (s *MySQLStorage) Disable(r *mdm.Request) error { +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 = 0, token_update_tally = 0, last_seen_at = CURRENT_TIMESTAMP WHERE device_id = ? AND enabled = 1;`, + `UPDATE enrollments SET enabled = 0, token_update_tally = 0, last_seen_at = CURRENT_TIMESTAMP WHERE device_id = $1 AND enabled = 1;`, r.ID, ) return err } -func (s *MySQLStorage) updateLastSeen(r *mdm.Request) (err error) { +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 = ?`, + `UPDATE enrollments SET last_seen_at = CURRENT_TIMESTAMP WHERE id = $1`, r.ID, ) if err != nil { diff --git a/storage/postgresql/push.go b/storage/postgresql/push.go index 9fc106c..048a6e0 100644 --- a/storage/postgresql/push.go +++ b/storage/postgresql/push.go @@ -1,10 +1,9 @@ -package mysql +package postgresql import ( "context" "errors" - "strings" - + "fmt" "github.com/micromdm/nanomdm/mdm" ) @@ -12,14 +11,19 @@ import ( // // Note that we may return fewer results than input. The user of this // method needs to reconcile that with their requested ids. -func (s *MySQLStorage) RetrievePushInfo(ctx context.Context, ids []string) (map[string]*mdm.Push, error) { +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") } - qs := "?" + strings.Repeat(", ?", len(ids)-1) + //qs := "?" + strings.Repeat(", ?", len(ids)-1) + qs := "" args := make([]interface{}, len(ids)) for i, v := range ids { args[i] = v + if i > 0 { + qs += "," + } + qs += fmt.Sprintf("$%d", i+1) // $1, $2, $3... } rows, err := s.db.QueryContext( ctx, diff --git a/storage/postgresql/pushcert.go b/storage/postgresql/pushcert.go index aedd364..2a3984b 100644 --- a/storage/postgresql/pushcert.go +++ b/storage/postgresql/pushcert.go @@ -1,4 +1,4 @@ -package mysql +package postgresql import ( "context" @@ -8,12 +8,12 @@ import ( "github.com/micromdm/nanomdm/cryptoutil" ) -func (s *MySQLStorage) RetrievePushCert(ctx context.Context, topic string) (*tls.Certificate, string, error) { +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 = ?;`, + `SELECT cert_pem, key_pem, stale_token FROM push_certs WHERE topic = $1;`, topic, ).Scan(&certPEM, &keyPEM, &staleToken) if err != nil { @@ -26,7 +26,7 @@ func (s *MySQLStorage) RetrievePushCert(ctx context.Context, topic string) (*tls return &cert, strconv.Itoa(staleToken), err } -func (s *MySQLStorage) IsPushCertStale(ctx context.Context, topic, staleToken string) (bool, error) { +func (s *PgSQLStorage) IsPushCertStale(ctx context.Context, topic, staleToken string) (bool, error) { var staleTokenInt, dbStaleToken int staleTokenInt, err := strconv.Atoi(staleToken) if err != nil { @@ -34,13 +34,13 @@ func (s *MySQLStorage) IsPushCertStale(ctx context.Context, topic, staleToken st } err = s.db.QueryRowContext( ctx, - `SELECT stale_token FROM push_certs WHERE topic = ?;`, + `SELECT stale_token FROM push_certs WHERE topic = $1;`, topic, ).Scan(&dbStaleToken) return dbStaleToken != staleTokenInt, err } -func (s *MySQLStorage) StorePushCert(ctx context.Context, pemCert, pemKey []byte) error { +func (s *PgSQLStorage) StorePushCert(ctx context.Context, pemCert, pemKey []byte) error { topic, err := cryptoutil.TopicFromPEMCert(pemCert) if err != nil { return err @@ -50,7 +50,7 @@ func (s *MySQLStorage) StorePushCert(ctx context.Context, pemCert, pemKey []byte INSERT INTO push_certs (topic, cert_pem, key_pem, stale_token) VALUES - (?, ?, ?, 0) AS new + ($1, $2, $3, 0) AS new ON DUPLICATE KEY UPDATE cert_pem = new.cert_pem, diff --git a/storage/postgresql/queue.go b/storage/postgresql/queue.go index 4e556fc..340f077 100644 --- a/storage/postgresql/queue.go +++ b/storage/postgresql/queue.go @@ -1,4 +1,4 @@ -package mysql +package postgresql import ( "context" @@ -33,7 +33,7 @@ func enqueue(ctx context.Context, tx *sql.Tx, ids []string, cmd *mdm.Command) er return err } -func (m *MySQLStorage) EnqueueCommand(ctx context.Context, ids []string, cmd *mdm.Command) (map[string]error, error) { +func (m *PgSQLStorage) EnqueueCommand(ctx context.Context, ids []string, cmd *mdm.Command) (map[string]error, error) { tx, err := m.db.BeginTx(ctx, nil) if err != nil { return nil, err @@ -47,7 +47,7 @@ func (m *MySQLStorage) EnqueueCommand(ctx context.Context, ids []string, cmd *md return nil, tx.Commit() } -func (s *MySQLStorage) StoreCommandReport(r *mdm.Request, result *mdm.CommandResults) error { +func (s *PgSQLStorage) StoreCommandReport(r *mdm.Request, result *mdm.CommandResults) error { if err := s.updateLastSeen(r); err != nil { return err } @@ -80,7 +80,7 @@ UPDATE return err } -func (s *MySQLStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.Command, error) { +func (s *PgSQLStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.Command, error) { statusWhere := "status IS NULL" if !skipNotNow { statusWhere = `(` + statusWhere + ` OR status = 'NotNow')` @@ -100,7 +100,7 @@ func (s *MySQLStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*md return command, nil } -func (s *MySQLStorage) ClearQueue(r *mdm.Request) error { +func (s *PgSQLStorage) ClearQueue(r *mdm.Request) error { if r.ParentID != "" { return errors.New("can only clear a device channel queue") } diff --git a/storage/postgresql/queue_test.go b/storage/postgresql/queue_test.go index f14e9b3..229d85d 100644 --- a/storage/postgresql/queue_test.go +++ b/storage/postgresql/queue_test.go @@ -1,7 +1,7 @@ //go:build integration // +build integration -package mysql +package postgresql import ( "context" @@ -13,10 +13,10 @@ import ( "github.com/micromdm/nanomdm/mdm" "github.com/micromdm/nanomdm/storage/internal/test" - _ "github.com/go-sql-driver/mysql" + _ "github.com/lib/pq" ) -var flDSN = flag.String("dsn", "", "DSN of test MySQL instance") +var flDSN = flag.String("dsn", "", "DSN of test PostgreSQL instance") func loadAuthMsg() (*mdm.Authenticate, error) { b, err := ioutil.ReadFile("../../mdm/testdata/Authenticate.2.plist") @@ -62,7 +62,7 @@ func newMdmReq() *mdm.Request { } } -func enrollTestDevice(storage *MySQLStorage) error { +func enrollTestDevice(storage *PgSQLStorage) error { authMsg, err := loadAuthMsg() if err != nil { return err @@ -84,7 +84,7 @@ func enrollTestDevice(storage *MySQLStorage) error { func TestQueue(t *testing.T) { if *flDSN == "" { - t.Fatal("MySQL DSN flag not provided to test") + t.Fatal("PostgreSQL DSN flag not provided to test") } storage, err := New(WithDSN(*flDSN)) From 244f1144d0118c0d7d9b85ae15a3eb590132e7f7 Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Fri, 27 May 2022 12:41:17 +0300 Subject: [PATCH 03/23] continue queries migration MySQL -> PostgreSQL --- go.mod | 1 + go.sum | 2 ++ storage/postgresql/certauth.go | 11 ++++-- storage/postgresql/postgresql.go | 60 ++++++++++++++++---------------- storage/postgresql/pushcert.go | 24 +++++++++---- storage/postgresql/queue.go | 4 +-- storage/postgresql/schema.sql | 3 +- 7 files changed, 62 insertions(+), 43 deletions(-) diff --git a/go.mod b/go.mod index 22b14a8..26c082f 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 4a232ff..9c62244 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/storage/postgresql/certauth.go b/storage/postgresql/certauth.go index 83a15ff..f3fd915 100644 --- a/storage/postgresql/certauth.go +++ b/storage/postgresql/certauth.go @@ -38,12 +38,17 @@ func (s *PgSQLStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool, ) } +// AssociateCertHash +// TODO primary key consists of two to cols: id & sha256 +// so when primary key conflict why to update sha256? 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) AS new -ON DUPLICATE KEY -UPDATE sha256 = new.sha256;`, +INSERT INTO cert_auth_associations (id, sha256) +VALUES ($1, $2) +ON CONFLICT ON CONSTRAINT cert_auth_associations_pkey DO +UPDATE SET + sha256 = EXCLUDED.sha256;`, r.ID, strings.ToLower(hash), ) diff --git a/storage/postgresql/postgresql.go b/storage/postgresql/postgresql.go index 37ed8ac..4c664e1 100644 --- a/storage/postgresql/postgresql.go +++ b/storage/postgresql/postgresql.go @@ -89,12 +89,12 @@ func (s *PgSQLStorage) StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) INSERT INTO devices (id, identity_cert, serial_number, authenticate, authenticate_at) VALUES - ($1, $2, $3, $4, CURRENT_TIMESTAMP) AS new -ON DUPLICATE KEY -UPDATE - identity_cert = new.identity_cert, - serial_number = new.serial_number, - authenticate = new.authenticate, + ($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, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw, ) @@ -127,13 +127,13 @@ func (s *PgSQLStorage) storeUserTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate 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) AS new -ON DUPLICATE KEY -UPDATE - device_id = new.device_id, - user_short_name = new.user_short_name, - user_long_name = new.user_long_name, - token_update = new.token_update, + ($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, @@ -167,18 +167,18 @@ func (s *PgSQLStorage) StoreTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) er 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) AS new -ON DUPLICATE KEY -UPDATE - device_id = new.device_id, - user_id = new.user_id, - type = new.type, - topic = new.topic, - push_magic = new.push_magic, - token_hex = new.token_hex, + ($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 = 1, last_seen_at = CURRENT_TIMESTAMP, - enrollments.token_update_tally = enrollments.token_update_tally + 1;`, + token_update_tally = enrollments.token_update_tally + 1;`, r.ID, deviceId, nullEmptyString(userId), @@ -215,13 +215,13 @@ INSERT INTO users (id, device_id, user_short_name, user_long_name, `+colName+`, `+colAtName+`) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) AS new -ON DUPLICATE KEY -UPDATE - device_id = new.device_id, - user_short_name = new.user_short_name, - user_long_name = new.user_long_name, - `+colName+` = new.`+colName+`, - `+colAtName+` = new.`+colAtName+`;`, +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), diff --git a/storage/postgresql/pushcert.go b/storage/postgresql/pushcert.go index 2a3984b..fdb4d7e 100644 --- a/storage/postgresql/pushcert.go +++ b/storage/postgresql/pushcert.go @@ -50,13 +50,25 @@ func (s *PgSQLStorage) StorePushCert(ctx context.Context, pemCert, pemKey []byte INSERT INTO push_certs (topic, cert_pem, key_pem, stale_token) VALUES - ($1, $2, $3, 0) AS new -ON DUPLICATE KEY -UPDATE - cert_pem = new.cert_pem, - key_pem = new.key_pem, - push_certs.stale_token = push_certs.stale_token + 1;`, + ($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 } + +/* +INSERT INTO push_certs + (topic, cert_pem, key_pem, stale_token) +VALUES + ('test1', '-----BEGIN CERTIFICATE-----testcert', '-----test_key_pem', 0) +ON CONFLICT (topic) DO +UPDATE SET + cert_pem = EXCLUDED.cert_pem, + key_pem = EXCLUDED.key_pem, + stale_token = push_certs.stale_token + 1; +*/ diff --git a/storage/postgresql/queue.go b/storage/postgresql/queue.go index 340f077..23da858 100644 --- a/storage/postgresql/queue.go +++ b/storage/postgresql/queue.go @@ -33,8 +33,8 @@ func enqueue(ctx context.Context, tx *sql.Tx, ids []string, cmd *mdm.Command) er return err } -func (m *PgSQLStorage) EnqueueCommand(ctx context.Context, ids []string, cmd *mdm.Command) (map[string]error, error) { - tx, err := m.db.BeginTx(ctx, nil) +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 } diff --git a/storage/postgresql/schema.sql b/storage/postgresql/schema.sql index 3d3c5c7..66be4f8 100644 --- a/storage/postgresql/schema.sql +++ b/storage/postgresql/schema.sql @@ -274,8 +274,7 @@ CREATE TABLE push_certs updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (topic), - CHECK (topic != '' -) , + CHECK (topic != '') , CHECK (SUBSTRING(cert_pem FROM 1 FOR 27) = '-----BEGIN CERTIFICATE-----'), CHECK (SUBSTRING(key_pem FROM 1 FOR 5) = '-----') From f37203b85a3528aed0bfd189c961fe95dc7b3715 Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Fri, 27 May 2022 17:06:30 +0300 Subject: [PATCH 04/23] triggers added to update_at, cli added postgreSQL --- cmd/cli/cli.go | 19 ++++++++++++++++ storage/postgresql/schema.sql | 43 +++++++++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index d2a9c15..220735d 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -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/postgresql" _ "github.com/go-sql-driver/mysql" + _ "github.com/lib/pq" ) type StringAccumulator []string @@ -72,6 +74,12 @@ func (s *Storage) Parse(logger log.Logger) (storage.AllStorage, error) { return nil, err } mdmStorage = append(mdmStorage, mysqlStorage) + case "postgresql": + postgresqlStorage, err := postgresqlStorageConfig(dsn, options, logger) + if err != nil { + return nil, err + } + mdmStorage = append(mdmStorage, postgresqlStorage) default: return nil, fmt.Errorf("unknown storage: %s", storage) } @@ -108,3 +116,14 @@ func mysqlStorageConfig(dsn, options string, logger log.Logger) (*mysql.MySQLSto } return mysql.New(opts...) } + +func postgresqlStorageConfig(dsn, options string, logger log.Logger) (*postgresql.PgSQLStorage, error) { + if options != "" { + return nil, NoStorageOptions + } + opts := []postgresql.Option{ + postgresql.WithDSN(dsn), + postgresql.WithLogger(logger.With("storage", "postgresql")), + } + return postgresql.New(opts...) +} diff --git a/storage/postgresql/schema.sql b/storage/postgresql/schema.sql index 66be4f8..ed6042a 100644 --- a/storage/postgresql/schema.sql +++ b/storage/postgresql/schema.sql @@ -22,8 +22,7 @@ CREATE TABLE devices bootstrap_token_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - -- TODO MySQL ON UPDATE CURRENT_TIMESTAMP to pgSQL trigger func - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- trigger PRIMARY KEY (id), @@ -61,8 +60,7 @@ CREATE TABLE users user_authenticate_digest_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - -- TODO MySQL ON UPDATE CURRENT_TIMESTAMP to pgSQL trigger func - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- trigger PRIMARY KEY (id, device_id), UNIQUE (id), @@ -296,3 +294,40 @@ CREATE TABLE cert_auth_associations ) , CHECK (sha256 != '') ); + +/* creating function to update current_timestamp, works with triggers to tables + same as MySQL functionality: + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP*/ +CREATE FUNCTION update_current_timestamp() + RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- create triggers to each table containing update_at, can be implemented by one fat func +-- but made separate for readability +CREATE TRIGGER update_at_to_current_timestamp BEFORE UPDATE ON devices + FOR EACH ROW EXECUTE PROCEDURE update_current_timestamp(); + +CREATE TRIGGER update_at_to_current_timestamp BEFORE UPDATE ON users + FOR EACH ROW EXECUTE PROCEDURE update_current_timestamp(); + +CREATE TRIGGER update_at_to_current_timestamp BEFORE UPDATE ON enrollments + FOR EACH ROW EXECUTE PROCEDURE update_current_timestamp(); + +CREATE TRIGGER update_at_to_current_timestamp BEFORE UPDATE ON commands + FOR EACH ROW EXECUTE PROCEDURE update_current_timestamp(); + +CREATE TRIGGER update_at_to_current_timestamp BEFORE UPDATE ON command_results + FOR EACH ROW EXECUTE PROCEDURE update_current_timestamp(); + +CREATE TRIGGER update_at_to_current_timestamp BEFORE UPDATE ON enrollment_queue + FOR EACH ROW EXECUTE PROCEDURE update_current_timestamp(); + +CREATE TRIGGER update_at_to_current_timestamp BEFORE UPDATE ON push_certs + FOR EACH ROW EXECUTE PROCEDURE update_current_timestamp(); + +CREATE TRIGGER update_at_to_current_timestamp BEFORE UPDATE ON cert_auth_associations + FOR EACH ROW EXECUTE PROCEDURE update_current_timestamp(); \ No newline at end of file From be84b010a0e32ec5a59809bdb7f9893a7f3ce523 Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Fri, 27 May 2022 17:50:58 +0300 Subject: [PATCH 05/23] migrating queue, need deep analyse ClearQueue of UPDATE query --- storage/postgresql/postgresql.go | 2 +- storage/postgresql/queue.go | 30 ++++++++++++++++-------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/storage/postgresql/postgresql.go b/storage/postgresql/postgresql.go index 4c664e1..c140fe1 100644 --- a/storage/postgresql/postgresql.go +++ b/storage/postgresql/postgresql.go @@ -214,7 +214,7 @@ func (s *PgSQLStorage) StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthen INSERT INTO users (id, device_id, user_short_name, user_long_name, `+colName+`, `+colAtName+`) VALUES - ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) AS new + ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) ON CONFLICT ON CONSTRAINT users_pkey DO UPDATE SET device_id = EXCLUDED.device_id, diff --git a/storage/postgresql/queue.go b/storage/postgresql/queue.go index 23da858..665a75e 100644 --- a/storage/postgresql/queue.go +++ b/storage/postgresql/queue.go @@ -5,8 +5,6 @@ import ( "database/sql" "errors" "fmt" - "strings" - "github.com/micromdm/nanomdm/mdm" ) @@ -16,18 +14,22 @@ func enqueue(ctx context.Context, tx *sql.Tx, ids []string, cmd *mdm.Command) er } _, err := tx.ExecContext( ctx, - `INSERT INTO commands (command_uuid, request_type, command) VALUES (?, ?, ?);`, + `INSERT INTO commands (command_uuid, request_type, command) VALUES ($1, $2, $3);`, cmd.CommandUUID, cmd.Command.RequestType, cmd.Raw, ) if err != nil { return err } - query := `INSERT INTO enrollment_queue (id, command_uuid) VALUES (?, ?)` - query += strings.Repeat(", (?, ?)", len(ids)-1) + query := `INSERT INTO enrollment_queue (id, command_uuid) VALUES ` args := make([]interface{}, len(ids)*2) for i, id := range ids { - args[i*2] = id - args[i*2+1] = cmd.CommandUUID + if i > 0 { + query += "," + } + ind := i * 2 + query += fmt.Sprintf("($%d, $%d)", ind+1, ind+2) + args[ind] = id + args[ind+1] = cmd.CommandUUID } _, err = tx.ExecContext(ctx, query+";", args...) return err @@ -67,11 +69,11 @@ func (s *PgSQLStorage) StoreCommandReport(r *mdm.Request, result *mdm.CommandRes INSERT INTO command_results (id, command_uuid, status, result, not_now_at, not_now_tally) VALUES - (?, ?, ?, ?, `+notNowConstants+`) AS new -ON DUPLICATE KEY -UPDATE - status = new.status, - result = new.result`+notNowBumpTallySQL+`;`, + ($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, @@ -88,7 +90,7 @@ func (s *PgSQLStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*md command := new(mdm.Command) err := s.db.QueryRowContext( r.Context, - `SELECT command_uuid, request_type, command FROM view_queue WHERE id = ? AND active = 1 AND `+statusWhere+` LIMIT 1;`, + `SELECT command_uuid, request_type, command FROM view_queue WHERE id = $1 AND active = 1 AND `+statusWhere+` LIMIT 1;`, r.ID, ).Scan(&command.CommandUUID, &command.Command.RequestType, &command.Raw) if err != nil { @@ -122,7 +124,7 @@ UPDATE SET q.active = 0 WHERE - e.device_id = ? AND + e.device_id = $1 AND active = 1 AND (r.status IS NULL OR r.status = 'NotNow');`, r.ID, From 283a3e8c5a3fff50b3aff0f926abd068b51a0b25 Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Fri, 27 May 2022 20:37:06 +0300 Subject: [PATCH 06/23] solving bugs and testing fails, TODO TestQueue/notnow --- storage/postgresql/postgresql.go | 22 +++++++++++++++++---- storage/postgresql/queue.go | 34 ++++++++++++++++---------------- storage/postgresql/queue_test.go | 1 + storage/postgresql/schema.sql | 6 ------ 4 files changed, 36 insertions(+), 27 deletions(-) diff --git a/storage/postgresql/postgresql.go b/storage/postgresql/postgresql.go index c140fe1..d9036fa 100644 --- a/storage/postgresql/postgresql.go +++ b/storage/postgresql/postgresql.go @@ -54,7 +54,7 @@ func WithDB(db *sql.DB) Option { } func New(opts ...Option) (*PgSQLStorage, error) { - cfg := &config{logger: log.NopLogger, driver: "mysql"} + cfg := &config{logger: log.NopLogger, driver: "postgres"} for _, opt := range opts { opt(cfg) } @@ -84,6 +84,20 @@ func (s *PgSQLStorage) StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) 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, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw, + )*/ _, err := s.db.ExecContext( r.Context, ` INSERT INTO devices @@ -96,7 +110,7 @@ UPDATE SET serial_number = EXCLUDED.serial_number, authenticate = EXCLUDED.authenticate, authenticate_at = CURRENT_TIMESTAMP;`, - r.ID, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw, + r.ID, nullEmptyString(string(pemCert)), nullEmptyString(msg.SerialNumber), msg.Raw, ) return err } @@ -176,7 +190,7 @@ SET topic = EXCLUDED.topic, push_magic = EXCLUDED.push_magic, token_hex = EXCLUDED.token_hex, - enabled = 1, + enabled = TRUE, last_seen_at = CURRENT_TIMESTAMP, token_update_tally = enrollments.token_update_tally + 1;`, r.ID, @@ -241,7 +255,7 @@ func (s *PgSQLStorage) Disable(r *mdm.Request) error { } _, err := s.db.ExecContext( r.Context, - `UPDATE enrollments SET enabled = 0, token_update_tally = 0, last_seen_at = CURRENT_TIMESTAMP WHERE device_id = $1 AND enabled = 1;`, + `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 diff --git a/storage/postgresql/queue.go b/storage/postgresql/queue.go index 665a75e..cc382b2 100644 --- a/storage/postgresql/queue.go +++ b/storage/postgresql/queue.go @@ -90,7 +90,7 @@ func (s *PgSQLStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*md command := new(mdm.Command) err := s.db.QueryRowContext( r.Context, - `SELECT command_uuid, request_type, command FROM view_queue WHERE id = $1 AND active = 1 AND `+statusWhere+` LIMIT 1;`, + `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 { @@ -113,21 +113,21 @@ func (s *PgSQLStorage) ClearQueue(r *mdm.Request) error { _, err := s.db.ExecContext( r.Context, ` -UPDATE - 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 -SET - q.active = 0 -WHERE - e.device_id = $1 AND - active = 1 AND - (r.status IS NULL OR r.status = 'NotNow');`, - r.ID, - ) +UPDATE enrollment_queue +SET enrollment_queue.active = FALSE +WHERE enrollment_queue.id IN + (SELECT q.id 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 + active = TRUE AND + (r.status IS NULL OR r.status = 'NotNow')) +;`, + r.ID) return err } diff --git a/storage/postgresql/queue_test.go b/storage/postgresql/queue_test.go index 229d85d..a836577 100644 --- a/storage/postgresql/queue_test.go +++ b/storage/postgresql/queue_test.go @@ -67,6 +67,7 @@ func enrollTestDevice(storage *PgSQLStorage) error { if err != nil { return err } + //fmt.Println(*authMsg) err = storage.StoreAuthenticate(newMdmReq(), authMsg) if err != nil { return err diff --git a/storage/postgresql/schema.sql b/storage/postgresql/schema.sql index ed6042a..3fd4d4e 100644 --- a/storage/postgresql/schema.sql +++ b/storage/postgresql/schema.sql @@ -109,7 +109,6 @@ CREATE TABLE enrollments last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - -- TODO MySQL ON UPDATE CURRENT_TIMESTAMP to pgSQL trigger func updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), @@ -146,7 +145,6 @@ CREATE TABLE commands command TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - -- TODO MySQL ON UPDATE CURRENT_TIMESTAMP to pgSQL trigger func updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (command_uuid), @@ -179,7 +177,6 @@ CREATE TABLE command_results not_now_tally INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - -- TODO trigger on change updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id, command_uuid), @@ -210,7 +207,6 @@ CREATE TABLE enrollment_queue priority SMALLINT NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - -- TODO trigger on update updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id, command_uuid), @@ -268,7 +264,6 @@ CREATE TABLE push_certs stale_token INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - -- TODO trigger on update updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (topic), @@ -285,7 +280,6 @@ CREATE TABLE cert_auth_associations sha256 CHAR(64) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - --TODO trigger on update updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id, sha256), From fdef43356cf57319e00733d800d591fbd98f0dcd Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Fri, 27 May 2022 22:19:27 +0300 Subject: [PATCH 07/23] tests pass ok --- storage/mysql/schema.sql | 2 +- storage/postgresql/queue.go | 2 +- storage/postgresql/schema.sql | 8 +++----- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/storage/mysql/schema.sql b/storage/mysql/schema.sql index 744c9cc..e870875 100644 --- a/storage/mysql/schema.sql +++ b/storage/mysql/schema.sql @@ -102,7 +102,7 @@ CREATE TABLE enrollments ( enabled BOOLEAN NOT NULL DEFAULT 1, token_update_tally INTEGER NOT NULL DEFAULT 1, - last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, diff --git a/storage/postgresql/queue.go b/storage/postgresql/queue.go index cc382b2..f8b7ad9 100644 --- a/storage/postgresql/queue.go +++ b/storage/postgresql/queue.go @@ -62,7 +62,7 @@ func (s *PgSQLStorage) StoreCommandReport(r *mdm.Request, result *mdm.CommandRes // not_now_at field. thus it will only represent the first NotNow. if result.Status == "NotNow" { notNowConstants = "CURRENT_TIMESTAMP, 1" - notNowBumpTallySQL = `, command_results.not_now_tally = command_results.not_now_tally + 1` + notNowBumpTallySQL = `, not_now_tally = command_results.not_now_tally + 1` } _, err := s.db.ExecContext( r.Context, ` diff --git a/storage/postgresql/schema.sql b/storage/postgresql/schema.sql index 3fd4d4e..7d017c6 100644 --- a/storage/postgresql/schema.sql +++ b/storage/postgresql/schema.sql @@ -106,7 +106,7 @@ CREATE TABLE enrollments enabled BOOLEAN NOT NULL DEFAULT TRUE, token_update_tally INTEGER NOT NULL DEFAULT 1, - last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP NOT NULL, -- DEFAULT CURRENT_TIMESTAMP, tests pass, but real test push error created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -191,8 +191,7 @@ CREATE TABLE command_results -- considering not enforcing these CHECKs to make sure we always -- capture results in the case they're malformed. - CHECK (status != '' -) , + CHECK (status != '') , CHECK (SUBSTRING(result FROM 1 FOR 5) = ' Date: Fri, 27 May 2022 23:59:11 +0300 Subject: [PATCH 08/23] PostgreSQL added --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 08ab87f..8202b83 100644 --- a/README.md +++ b/README.md @@ -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 backend 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 From 8f553193f5d267d23866cb503d099f84796a2a6f Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Sun, 29 May 2022 13:18:01 +0300 Subject: [PATCH 09/23] PostgreSQL added fix gofmt --- storage/postgresql/postgresql.go | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/storage/postgresql/postgresql.go b/storage/postgresql/postgresql.go index d9036fa..0f62b32 100644 --- a/storage/postgresql/postgresql.go +++ b/storage/postgresql/postgresql.go @@ -85,19 +85,19 @@ func (s *PgSQLStorage) StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) 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, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw, - )*/ + 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, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw, + )*/ _, err := s.db.ExecContext( r.Context, ` INSERT INTO devices From 42a3e2dc116dba779e30734a5fa2a5dd3f0718a5 Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Fri, 3 Jun 2022 15:36:00 +0300 Subject: [PATCH 10/23] Update README.md Co-authored-by: Jesse Peterson --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8202b83..aa96378 100644 --- a/README.md +++ b/README.md @@ -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 and PostgreSQL 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 From 2e792de30777e9636ae241b16b6236778bdead23 Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Fri, 3 Jun 2022 15:37:18 +0300 Subject: [PATCH 11/23] Update storage/postgresql/schema.sql Co-authored-by: Jesse Peterson --- storage/postgresql/schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/storage/postgresql/schema.sql b/storage/postgresql/schema.sql index 7d017c6..72ab52e 100644 --- a/storage/postgresql/schema.sql +++ b/storage/postgresql/schema.sql @@ -259,7 +259,7 @@ CREATE TABLE push_certs * every time push info is requested. This value should be updated * every time a push cert is updated (i.e. renwals) and so all * push services using this table will know the certificate has - * changed and reload it. This is managed by the MySQL backend. */ + * changed and reload it. This is managed by the PgSQL backend. */ stale_token INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, From 657ff26a52403d7a04f532eb46fc50c5c486ff66 Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Fri, 3 Jun 2022 15:37:37 +0300 Subject: [PATCH 12/23] Update storage/postgresql/schema.sql Co-authored-by: Jesse Peterson --- storage/postgresql/schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/storage/postgresql/schema.sql b/storage/postgresql/schema.sql index 72ab52e..cc1b601 100644 --- a/storage/postgresql/schema.sql +++ b/storage/postgresql/schema.sql @@ -257,7 +257,7 @@ CREATE TABLE push_certs /* stale_token is a simple value that coordinates push certificates * across the SQL backend. The push service checks this value * every time push info is requested. This value should be updated - * every time a push cert is updated (i.e. renwals) and so all + * every time a push cert is updated (i.e. renewals) and so all * push services using this table will know the certificate has * changed and reload it. This is managed by the PgSQL backend. */ stale_token INTEGER NOT NULL, From 9f6a7a90c883e858c619d4495ca5b467ecac78ad Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Sat, 4 Jun 2022 14:13:48 +0300 Subject: [PATCH 13/23] Update storage/postgresql/postgresql.go Co-authored-by: Jesse Peterson --- storage/postgresql/postgresql.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/storage/postgresql/postgresql.go b/storage/postgresql/postgresql.go index 0f62b32..9ed7033 100644 --- a/storage/postgresql/postgresql.go +++ b/storage/postgresql/postgresql.go @@ -110,7 +110,7 @@ UPDATE SET serial_number = EXCLUDED.serial_number, authenticate = EXCLUDED.authenticate, authenticate_at = CURRENT_TIMESTAMP;`, - r.ID, nullEmptyString(string(pemCert)), nullEmptyString(msg.SerialNumber), msg.Raw, + r.ID, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw, ) return err } From 98ea9a52a3871df8fb5c1bec2c15a2ec4f031be7 Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Sun, 5 Jun 2022 21:32:43 +0300 Subject: [PATCH 14/23] started --- storage/postgresql/queue_test.go | 1 - storage/postgresql/schema.sql | 80 ++++++++++++++------------------ 2 files changed, 36 insertions(+), 45 deletions(-) diff --git a/storage/postgresql/queue_test.go b/storage/postgresql/queue_test.go index a836577..229d85d 100644 --- a/storage/postgresql/queue_test.go +++ b/storage/postgresql/queue_test.go @@ -67,7 +67,6 @@ func enrollTestDevice(storage *PgSQLStorage) error { if err != nil { return err } - //fmt.Println(*authMsg) err = storage.StoreAuthenticate(newMdmReq(), authMsg) if err != nil { return err diff --git a/storage/postgresql/schema.sql b/storage/postgresql/schema.sql index cc1b601..0a37176 100644 --- a/storage/postgresql/schema.sql +++ b/storage/postgresql/schema.sql @@ -1,42 +1,40 @@ +/* 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, + 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 TEXT NULL, - unlock_token_at TIMESTAMP NULL, + unlock_token TEXT 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, + token_update TEXT NULL, + token_update_at TIMESTAMP NULL, - bootstrap_token_b64 TEXT NULL, - bootstrap_token_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 (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); @@ -50,14 +48,14 @@ CREATE TABLE users user_long_name VARCHAR(255) NULL, -- The last raw TokenUpdate for this user - token_update TEXT NULL, - token_update_at TIMESTAMP NULL, + 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, + 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 @@ -69,13 +67,10 @@ CREATE TABLE users 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 (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 IS NULL OR user_authenticate != ''), CHECK (user_authenticate_digest IS NULL OR user_authenticate_digest != '') ); @@ -106,14 +101,13 @@ CREATE TABLE enrollments enabled BOOLEAN NOT NULL DEFAULT TRUE, token_update_tally INTEGER NOT NULL DEFAULT 1, - last_seen_at TIMESTAMP NOT NULL, -- DEFAULT CURRENT_TIMESTAMP, tests pass, but real test push error + 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 != '' -) , + CHECK (id != ''), FOREIGN KEY (device_id) REFERENCES devices (id) @@ -125,15 +119,13 @@ CREATE TABLE enrollments UNIQUE (user_id), CHECK (type != ''), - CHECK (topic != ''), CHECK (push_magic != ''), CHECK (token_hex != '') ); CREATE INDEX idx_type ON enrollments (type); - -/* Commands stand alone. By themsevles they aren't associated with +/* Commands stand alone. By themselves they aren't associated with * a device, a result (response), etc. Joining other tables is required * for more context. */ @@ -149,7 +141,7 @@ CREATE TABLE commands PRIMARY KEY (command_uuid), - CHECK (command_uuid != '') , + CHECK (command_uuid != ''), CHECK (request_type != ''), CHECK (SUBSTRING(command FROM 1 FOR 5) = ' Date: Sun, 5 Jun 2022 22:39:13 +0300 Subject: [PATCH 15/23] pgsql --- cmd/cli/cli.go | 18 +++---- docs/operations-guide.md | 8 +++ storage/{postgresql => pgsql}/bstoken.go | 2 +- storage/{postgresql => pgsql}/certauth.go | 10 ++-- storage/{postgresql => pgsql}/migrate.go | 2 +- storage/{postgresql => pgsql}/postgresql.go | 20 ++------ storage/{postgresql => pgsql}/push.go | 28 +++++++---- storage/{postgresql => pgsql}/pushcert.go | 14 +----- storage/{postgresql => pgsql}/queue.go | 56 +++++++++++++-------- storage/{postgresql => pgsql}/queue_test.go | 5 +- storage/{postgresql => pgsql}/schema.sql | 0 11 files changed, 80 insertions(+), 83 deletions(-) rename storage/{postgresql => pgsql}/bstoken.go (97%) rename storage/{postgresql => pgsql}/certauth.go (83%) rename storage/{postgresql => pgsql}/migrate.go (98%) rename storage/{postgresql => pgsql}/postgresql.go (91%) rename storage/{postgresql => pgsql}/push.go (63%) rename storage/{postgresql => pgsql}/pushcert.go (82%) rename storage/{postgresql => pgsql}/queue.go (76%) rename storage/{postgresql => pgsql}/queue_test.go (98%) rename storage/{postgresql => pgsql}/schema.sql (100%) diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 220735d..a8dde3a 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -11,7 +11,7 @@ import ( "github.com/micromdm/nanomdm/storage/allmulti" "github.com/micromdm/nanomdm/storage/file" "github.com/micromdm/nanomdm/storage/mysql" - "github.com/micromdm/nanomdm/storage/postgresql" + "github.com/micromdm/nanomdm/storage/pgsql" _ "github.com/go-sql-driver/mysql" _ "github.com/lib/pq" @@ -74,12 +74,12 @@ func (s *Storage) Parse(logger log.Logger) (storage.AllStorage, error) { return nil, err } mdmStorage = append(mdmStorage, mysqlStorage) - case "postgresql": - postgresqlStorage, err := postgresqlStorageConfig(dsn, options, logger) + case "pgsql": + pgsqlStorage, err := pgsqlStorageConfig(dsn, options, logger) if err != nil { return nil, err } - mdmStorage = append(mdmStorage, postgresqlStorage) + mdmStorage = append(mdmStorage, pgsqlStorage) default: return nil, fmt.Errorf("unknown storage: %s", storage) } @@ -117,13 +117,13 @@ func mysqlStorageConfig(dsn, options string, logger log.Logger) (*mysql.MySQLSto return mysql.New(opts...) } -func postgresqlStorageConfig(dsn, options string, logger log.Logger) (*postgresql.PgSQLStorage, error) { +func pgsqlStorageConfig(dsn, options string, logger log.Logger) (*pgsql.PgSQLStorage, error) { if options != "" { return nil, NoStorageOptions } - opts := []postgresql.Option{ - postgresql.WithDSN(dsn), - postgresql.WithLogger(logger.With("storage", "postgresql")), + opts := []pgsql.Option{ + pgsql.WithDSN(dsn), + pgsql.WithLogger(logger.With("storage", "pgsql")), } - return postgresql.New(opts...) + return pgsql.New(opts...) } diff --git a/docs/operations-guide.md b/docs/operations-guide.md index d3d48da..9495956 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -56,6 +56,14 @@ Configures the MySQL storage backend. The `-dsn` flag should be in the [format t *Example:* `-storage mysql -dsn nanomdm:nanomdm/mymdmdb` +#### pgsql storage backend + +* `-storage pgsql` + +Configures the PostgreSQL storage backend. The `-dsn` flag should be in the [format the SQL driver expects](https://github.com/go-sql-driver/mysql#dsn-data-source-name). 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 nanomdm:nanomdm/pgmdmdb` + #### 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. diff --git a/storage/postgresql/bstoken.go b/storage/pgsql/bstoken.go similarity index 97% rename from storage/postgresql/bstoken.go rename to storage/pgsql/bstoken.go index b197b15..4189d96 100644 --- a/storage/postgresql/bstoken.go +++ b/storage/pgsql/bstoken.go @@ -1,4 +1,4 @@ -package postgresql +package pgsql import ( "github.com/micromdm/nanomdm/mdm" diff --git a/storage/postgresql/certauth.go b/storage/pgsql/certauth.go similarity index 83% rename from storage/postgresql/certauth.go rename to storage/pgsql/certauth.go index f3fd915..8182dea 100644 --- a/storage/postgresql/certauth.go +++ b/storage/pgsql/certauth.go @@ -1,4 +1,4 @@ -package postgresql +package pgsql import ( "context" @@ -38,17 +38,13 @@ func (s *PgSQLStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool, ) } -// AssociateCertHash -// TODO primary key consists of two to cols: id & sha256 -// so when primary key conflict why to update sha256? +// 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 -UPDATE SET - sha256 = EXCLUDED.sha256;`, +ON CONFLICT ON CONSTRAINT cert_auth_associations_pkey DO NOTHING;`, r.ID, strings.ToLower(hash), ) diff --git a/storage/postgresql/migrate.go b/storage/pgsql/migrate.go similarity index 98% rename from storage/postgresql/migrate.go rename to storage/pgsql/migrate.go index 13541e6..e5fed0f 100644 --- a/storage/postgresql/migrate.go +++ b/storage/pgsql/migrate.go @@ -1,4 +1,4 @@ -package postgresql +package pgsql import ( "context" diff --git a/storage/postgresql/postgresql.go b/storage/pgsql/postgresql.go similarity index 91% rename from storage/postgresql/postgresql.go rename to storage/pgsql/postgresql.go index 9ed7033..37f4853 100644 --- a/storage/postgresql/postgresql.go +++ b/storage/pgsql/postgresql.go @@ -1,5 +1,5 @@ -// Package postgresql stores and retrieves MDM data from SQL -package postgresql +// Package pgsql stores and retrieves MDM data from SQL +package pgsql import ( "context" @@ -84,20 +84,6 @@ func (s *PgSQLStorage) StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) 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, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw, - )*/ _, err := s.db.ExecContext( r.Context, ` INSERT INTO devices @@ -110,7 +96,7 @@ UPDATE SET serial_number = EXCLUDED.serial_number, authenticate = EXCLUDED.authenticate, authenticate_at = CURRENT_TIMESTAMP;`, - r.ID, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw, + r.ID, nullEmptyString(string(pemCert)), nullEmptyString(msg.SerialNumber), msg.Raw, ) return err } diff --git a/storage/postgresql/push.go b/storage/pgsql/push.go similarity index 63% rename from storage/postgresql/push.go rename to storage/pgsql/push.go index 048a6e0..0287f77 100644 --- a/storage/postgresql/push.go +++ b/storage/pgsql/push.go @@ -1,9 +1,11 @@ -package postgresql +package pgsql import ( "context" "errors" - "fmt" + "strconv" + "strings" + "github.com/micromdm/nanomdm/mdm" ) @@ -15,21 +17,25 @@ func (s *PgSQLStorage) RetrievePushInfo(ctx context.Context, ids []string) (map[ if len(ids) < 1 { return nil, errors.New("no ids provided") } - //qs := "?" + strings.Repeat(", ?", len(ids)-1) - qs := "" + + // 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 += "," + qs.WriteString(",") } - qs += fmt.Sprintf("$%d", i+1) // $1, $2, $3... + // can be a bit faster than fmt.Fprintf(&qs, "$%d", i+1) + qs.WriteString("$") + qs.WriteString(strconv.Itoa(i + 1)) } - rows, err := s.db.QueryContext( - ctx, - `SELECT id, topic, push_magic, token_hex FROM enrollments WHERE id IN (`+qs+`);`, - args..., - ) + qs.WriteString(`);`) + + rows, err := s.db.QueryContext(ctx, qs.String(), args...) if err != nil { return nil, err } diff --git a/storage/postgresql/pushcert.go b/storage/pgsql/pushcert.go similarity index 82% rename from storage/postgresql/pushcert.go rename to storage/pgsql/pushcert.go index fdb4d7e..7077938 100644 --- a/storage/postgresql/pushcert.go +++ b/storage/pgsql/pushcert.go @@ -1,4 +1,4 @@ -package postgresql +package pgsql import ( "context" @@ -60,15 +60,3 @@ UPDATE SET ) return err } - -/* -INSERT INTO push_certs - (topic, cert_pem, key_pem, stale_token) -VALUES - ('test1', '-----BEGIN CERTIFICATE-----testcert', '-----test_key_pem', 0) -ON CONFLICT (topic) DO -UPDATE SET - cert_pem = EXCLUDED.cert_pem, - key_pem = EXCLUDED.key_pem, - stale_token = push_certs.stale_token + 1; -*/ diff --git a/storage/postgresql/queue.go b/storage/pgsql/queue.go similarity index 76% rename from storage/postgresql/queue.go rename to storage/pgsql/queue.go index f8b7ad9..4691374 100644 --- a/storage/postgresql/queue.go +++ b/storage/pgsql/queue.go @@ -1,10 +1,13 @@ -package postgresql +package pgsql import ( "context" "database/sql" "errors" "fmt" + "strconv" + "strings" + "github.com/micromdm/nanomdm/mdm" ) @@ -20,18 +23,30 @@ func enqueue(ctx context.Context, tx *sql.Tx, ids []string, cmd *mdm.Command) er if err != nil { return err } - query := `INSERT INTO enrollment_queue (id, command_uuid) VALUES ` + + 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 += "," + query.WriteString(",") } ind := i * 2 - query += fmt.Sprintf("($%d, $%d)", ind+1, ind+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 } - _, err = tx.ExecContext(ctx, query+";", args...) + query.WriteString(";") + + _, err = tx.ExecContext(ctx, query.String(), args...) return err } @@ -58,7 +73,7 @@ func (s *PgSQLStorage) StoreCommandReport(r *mdm.Request, result *mdm.CommandRes } notNowConstants := "NULL, 0" notNowBumpTallySQL := "" - // note that due to the ON DUPLICATE KEY we don't UPDATE the + // 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" @@ -113,21 +128,20 @@ func (s *PgSQLStorage) ClearQueue(r *mdm.Request) error { _, err := s.db.ExecContext( r.Context, ` -UPDATE enrollment_queue -SET enrollment_queue.active = FALSE -WHERE enrollment_queue.id IN - (SELECT q.id 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 - active = TRUE AND - (r.status IS NULL OR r.status = 'NotNow')) -;`, +UPDATE + 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 +SET + q.active = FALSE +WHERE + e.device_id = $1 AND + active = TRUE AND + (r.status IS NULL OR r.status = 'NotNow');`, r.ID) return err } diff --git a/storage/postgresql/queue_test.go b/storage/pgsql/queue_test.go similarity index 98% rename from storage/postgresql/queue_test.go rename to storage/pgsql/queue_test.go index 229d85d..d2193b3 100644 --- a/storage/postgresql/queue_test.go +++ b/storage/pgsql/queue_test.go @@ -1,7 +1,7 @@ //go:build integration // +build integration -package postgresql +package pgsql import ( "context" @@ -10,10 +10,9 @@ import ( "io/ioutil" "testing" + _ "github.com/lib/pq" "github.com/micromdm/nanomdm/mdm" "github.com/micromdm/nanomdm/storage/internal/test" - - _ "github.com/lib/pq" ) var flDSN = flag.String("dsn", "", "DSN of test PostgreSQL instance") diff --git a/storage/postgresql/schema.sql b/storage/pgsql/schema.sql similarity index 100% rename from storage/postgresql/schema.sql rename to storage/pgsql/schema.sql From f388813daadc94cd31c9620f67bda22598086786 Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Mon, 6 Jun 2022 23:23:14 +0300 Subject: [PATCH 16/23] pgsql update fixed. Ok tested on real devices. Without "deleter" functionality. --- storage/pgsql/postgresql.go | 6 ++++-- storage/pgsql/queue.go | 30 ++++++++++++++---------------- storage/pgsql/schema.sql | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/storage/pgsql/postgresql.go b/storage/pgsql/postgresql.go index 37f4853..2ec56aa 100644 --- a/storage/pgsql/postgresql.go +++ b/storage/pgsql/postgresql.go @@ -104,12 +104,14 @@ UPDATE SET 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, r.ID} + args := []interface{}{msg.Raw} // separately store the Unlock Token per MDM spec if len(msg.UnlockToken) > 0 { - query += `, unlock_token = $3, unlock_token_at = CURRENT_TIMESTAMP ` + 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 } diff --git a/storage/pgsql/queue.go b/storage/pgsql/queue.go index 4691374..2dbc082 100644 --- a/storage/pgsql/queue.go +++ b/storage/pgsql/queue.go @@ -121,27 +121,25 @@ func (s *PgSQLStorage) ClearQueue(r *mdm.Request) error { if r.ParentID != "" { return errors.New("can only clear a device channel queue") } - // Because we're joining on and WHERE-ing by the enrollments table - // this will clear (mark inactive) the queue of not only this - // device ID, but all user-channel enrollments with a 'parent' ID of - // this device, too. + // PostgreSQL UPDATE differs from MySQL, uses "FROM" specific + // to pgsql extension _, err := s.db.ExecContext( r.Context, ` -UPDATE - enrollment_queue AS q +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 -SET - q.active = FALSE -WHERE + 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 - active = TRUE AND - (r.status IS NULL OR r.status = 'NotNow');`, + 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/storage/pgsql/schema.sql b/storage/pgsql/schema.sql index 0a37176..e4bc723 100644 --- a/storage/pgsql/schema.sql +++ b/storage/pgsql/schema.sql @@ -12,7 +12,7 @@ CREATE TABLE devices -- If the (iOS, iPadOS) device sent an UnlockToken in the TokenUpdate -- TODO: Consider using a TEXT field and encoding the binary - unlock_token TEXT NULL, + unlock_token BYTEA NULL, unlock_token_at TIMESTAMP NULL, -- The last raw Authenticate for this device From 362b0a3540cf634df2eecfcb5238b9857d4da39d Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Tue, 7 Jun 2022 13:20:45 +0300 Subject: [PATCH 17/23] mysql deleter functionality --- cmd/cli/cli.go | 34 +++++++++++++++++++--- storage/mysql/mysql.go | 10 ++++++- storage/mysql/queue.go | 57 +++++++++++++++++++++++++++++++++++++ storage/mysql/queue_test.go | 15 ++++++++-- 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index a8dde3a..7d99766 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -107,16 +107,42 @@ 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) { if options != "" { return nil, NoStorageOptions diff --git a/storage/mysql/mysql.go b/storage/mysql/mysql.go index ad78de6..cec2ced 100644 --- a/storage/mysql/mysql.go +++ b/storage/mysql/mysql.go @@ -18,6 +18,7 @@ var ErrNoCert = errors.New("no certificate in MDM Request") type MySQLStorage struct { logger log.Logger db *sql.DB + rm bool } type config struct { @@ -25,6 +26,7 @@ type config struct { dsn string db *sql.DB logger log.Logger + rm bool } type Option func(*config) @@ -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 { @@ -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. diff --git a/storage/mysql/queue.go b/storage/mysql/queue.go index 4e556fc..51acf68 100644 --- a/storage/mysql/queue.go +++ b/storage/mysql/queue.go @@ -47,6 +47,60 @@ 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 @@ -54,6 +108,9 @@ func (s *MySQLStorage) StoreCommandReport(r *mdm.Request, result *mdm.CommandRes 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 diff --git a/storage/mysql/queue_test.go b/storage/mysql/queue_test.go index f14e9b3..49ee203 100644 --- a/storage/mysql/queue_test.go +++ b/storage/mysql/queue_test.go @@ -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) } @@ -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) + }) } From ed36ad12c437721f15e4046728c5dc1ffcc1ff6b Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Tue, 7 Jun 2022 16:16:20 +0300 Subject: [PATCH 18/23] pgsql deleter functionality. todo tests --- cmd/cli/cli.go | 74 +++++++++++++++++++++++++------------ storage/pgsql/postgresql.go | 10 ++++- storage/pgsql/queue.go | 54 +++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 24 deletions(-) diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index 7d99766..f7aca74 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -113,23 +113,62 @@ func mysqlStorageConfig(dsn, options string, logger log.Logger) (*mysql.MySQLSto 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) - } + if ok, err := checkBoolOption(deleteOption, options); ok { + opts = append(opts, mysql.WithDeleteCommands()) + logger.Debug("msg", "deleting commands") + } else if err != nil { + return nil, err } } return mysql.New(opts...) } +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 != "" { + if ok, err := checkBoolOption(deleteOption, options); ok { + opts = append(opts, pgsql.WithDeleteCommands()) + logger.Debug("msg", "deleting commands") + } else if err != nil { + return nil, err + } + } + return pgsql.New(opts...) +} + +type storageOption string + +const ( + deleteOption storageOption = "delete" +) + +// checkBoolOption checks by name for bool option, returns: +// true: "true", "t", or "1" +// false: "false", "f", or "0" +// option name and value case-insensitive +func checkBoolOption(so storageOption, options string) (bool, error) { + for k, v := range splitOptions(options) { + k := strings.ToLower(k) + if so != storageOption(k) { + continue + } + v := strings.ToLower(v) + switch v { + case "true", "t", "1": + return true, nil + case "false", "f", "0": + return false, nil + default: + return false, fmt.Errorf("invalid value for %s option: %q", so, v) + } + } + return false, fmt.Errorf("option not found: %s", so) +} + func splitOptions(s string) map[string]string { out := make(map[string]string) opts := strings.Split(s, ",") @@ -142,14 +181,3 @@ func splitOptions(s string) map[string]string { } return out } - -func pgsqlStorageConfig(dsn, options string, logger log.Logger) (*pgsql.PgSQLStorage, error) { - if options != "" { - return nil, NoStorageOptions - } - opts := []pgsql.Option{ - pgsql.WithDSN(dsn), - pgsql.WithLogger(logger.With("storage", "pgsql")), - } - return pgsql.New(opts...) -} diff --git a/storage/pgsql/postgresql.go b/storage/pgsql/postgresql.go index 2ec56aa..0844dca 100644 --- a/storage/pgsql/postgresql.go +++ b/storage/pgsql/postgresql.go @@ -18,6 +18,7 @@ var ErrNoCert = errors.New("no certificate in MDM Request") type PgSQLStorage struct { logger log.Logger db *sql.DB + rm bool } type config struct { @@ -25,6 +26,7 @@ type config struct { dsn string db *sql.DB logger log.Logger + rm bool } type Option func(*config) @@ -53,6 +55,12 @@ func WithDB(db *sql.DB) Option { } } +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 { @@ -68,7 +76,7 @@ func New(opts ...Option) (*PgSQLStorage, error) { if err = cfg.db.Ping(); err != nil { return nil, err } - return &PgSQLStorage{db: cfg.db, logger: cfg.logger}, nil + return &PgSQLStorage{db: cfg.db, logger: cfg.logger, rm: cfg.rm}, nil } // nullEmptyString returns a NULL string if s is empty. diff --git a/storage/pgsql/queue.go b/storage/pgsql/queue.go index 2dbc082..2a020e8 100644 --- a/storage/pgsql/queue.go +++ b/storage/pgsql/queue.go @@ -64,6 +64,57 @@ func (s *PgSQLStorage) EnqueueCommand(ctx context.Context, ids []string, cmd *md 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 @@ -71,6 +122,9 @@ func (s *PgSQLStorage) StoreCommandReport(r *mdm.Request, result *mdm.CommandRes 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 From 9cb344b527cfeb55d8f4ed79d489910fe6f2c2a0 Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Tue, 7 Jun 2022 16:35:24 +0300 Subject: [PATCH 19/23] pgsql deleter tests and docs --- docs/operations-guide.md | 17 ++++++++++++++--- storage/pgsql/queue_test.go | 15 +++++++++++++-- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/docs/operations-guide.md b/docs/operations-guide.md index 9495956..b9b7e6b 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -44,18 +44,23 @@ 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` #### mysql storage backend * `-storage mysql` - -Configures the MySQL storage backend. The `-dsn` flag should be in the [format the SQL driver expects](https://github.com/go-sql-driver/mysql#dsn-data-source-name). Be sure to create your tables with the [schema.sql](../storage/mysql/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). MySQL 8.0.19 or later is required. + Configures the MySQL storage backend. The `-dsn` flag should be in the [format the SQL driver expects](https://github.com/go-sql-driver/mysql#dsn-data-source-name). Be sure to create your tables with the [schema.sql](../storage/mysql/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). MySQL 8.0.19 or later is required. *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 themselves 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` @@ -64,6 +69,12 @@ Configures the PostgreSQL storage backend. The `-dsn` flag should be in the [for *Example:* `-storage pgsql -dsn nanomdm:nanomdm/pgmdmdb` +Options are specified as a comma-separated list of "key=value" pairs. The pgsql backend supports these options: +* `delete=1`, `delete=0` or `delete=true`, `delete=false` + * 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 nanomdm:nanomdm/pgmdmdb -storage-options delete=true` + #### 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. diff --git a/storage/pgsql/queue_test.go b/storage/pgsql/queue_test.go index d2193b3..f216d90 100644 --- a/storage/pgsql/queue_test.go +++ b/storage/pgsql/queue_test.go @@ -86,7 +86,7 @@ func TestQueue(t *testing.T) { t.Fatal("PostgreSQL DSN flag not provided to test") } - storage, err := New(WithDSN(*flDSN)) + storage, err := New(WithDSN(*flDSN), WithDeleteCommands()) if err != nil { t.Fatal(err) } @@ -96,5 +96,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) + }) } From 1300373a675683f0ccc25b270d5ed8f2af59d4b5 Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Tue, 7 Jun 2022 20:00:23 +0300 Subject: [PATCH 20/23] typos fix --- storage/mysql/mysql.go | 2 +- storage/pgsql/postgresql.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/storage/mysql/mysql.go b/storage/mysql/mysql.go index cec2ced..ea8d0f2 100644 --- a/storage/mysql/mysql.go +++ b/storage/mysql/mysql.go @@ -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 ( diff --git a/storage/pgsql/postgresql.go b/storage/pgsql/postgresql.go index 0844dca..12e0ae8 100644 --- a/storage/pgsql/postgresql.go +++ b/storage/pgsql/postgresql.go @@ -1,4 +1,4 @@ -// Package pgsql stores and retrieves MDM data from SQL +// Package pgsql stores and retrieves MDM data from PostgresSQL package pgsql import ( From 94533a6df6d5518a2cf76cd8d039dbaa229b9eae Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Wed, 22 Jun 2022 00:35:25 +0300 Subject: [PATCH 21/23] conflicts mysql - pgsql solved --- cmd/cli/cli.go | 87 +++++++++++++++++----------------------- docs/operations-guide.md | 14 ++++--- 2 files changed, 44 insertions(+), 57 deletions(-) diff --git a/cmd/cli/cli.go b/cmd/cli/cli.go index f7aca74..d058ba2 100644 --- a/cmd/cli/cli.go +++ b/cmd/cli/cli.go @@ -113,62 +113,23 @@ func mysqlStorageConfig(dsn, options string, logger log.Logger) (*mysql.MySQLSto mysql.WithLogger(logger), } if options != "" { - if ok, err := checkBoolOption(deleteOption, options); ok { - opts = append(opts, mysql.WithDeleteCommands()) - logger.Debug("msg", "deleting commands") - } else if err != nil { - return nil, err + 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 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 != "" { - if ok, err := checkBoolOption(deleteOption, options); ok { - opts = append(opts, pgsql.WithDeleteCommands()) - logger.Debug("msg", "deleting commands") - } else if err != nil { - return nil, err - } - } - return pgsql.New(opts...) -} - -type storageOption string - -const ( - deleteOption storageOption = "delete" -) - -// checkBoolOption checks by name for bool option, returns: -// true: "true", "t", or "1" -// false: "false", "f", or "0" -// option name and value case-insensitive -func checkBoolOption(so storageOption, options string) (bool, error) { - for k, v := range splitOptions(options) { - k := strings.ToLower(k) - if so != storageOption(k) { - continue - } - v := strings.ToLower(v) - switch v { - case "true", "t", "1": - return true, nil - case "false", "f", "0": - return false, nil - default: - return false, fmt.Errorf("invalid value for %s option: %q", so, v) - } - } - return false, fmt.Errorf("option not found: %s", so) -} - func splitOptions(s string) map[string]string { out := make(map[string]string) opts := strings.Split(s, ",") @@ -181,3 +142,27 @@ func splitOptions(s string) map[string]string { } 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...) +} diff --git a/docs/operations-guide.md b/docs/operations-guide.md index b9b7e6b..e5e2c5c 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -51,13 +51,15 @@ Configures the `file` storage backend. This manages enrollment and command data #### mysql storage backend * `-storage mysql` - Configures the MySQL storage backend. The `-dsn` flag should be in the [format the SQL driver expects](https://github.com/go-sql-driver/mysql#dsn-data-source-name). Be sure to create your tables with the [schema.sql](../storage/mysql/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). MySQL 8.0.19 or later is required. + +Configures the MySQL storage backend. The `-dsn` flag should be in the [format the SQL driver expects](https://github.com/go-sql-driver/mysql#dsn-data-source-name). Be sure to create your tables with the [schema.sql](../storage/mysql/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). MySQL 8.0.19 or later is required. *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 themselves will be deleted from the database after enrollments have responded to a command. + * 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` @@ -65,15 +67,15 @@ Options are specified as a comma-separated list of "key=value" pairs. The mysql * `-storage pgsql` -Configures the PostgreSQL storage backend. The `-dsn` flag should be in the [format the SQL driver expects](https://github.com/go-sql-driver/mysql#dsn-data-source-name). 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. +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 nanomdm:nanomdm/pgmdmdb` +*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` or `delete=true`, `delete=false` +* `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 nanomdm:nanomdm/pgmdmdb -storage-options delete=true` +*Example:* `-storage pgsql -dsn postgres://postgres:toor@localhost/nanomdm -storage-options delete=1` #### multi-storage backend From d2a274c6f9bd388509de22fe9cc809517dad73be Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Fri, 15 Jul 2022 23:23:20 +0300 Subject: [PATCH 22/23] Update storage/mysql/mysql.go Co-authored-by: Jesse Peterson --- storage/mysql/mysql.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/storage/mysql/mysql.go b/storage/mysql/mysql.go index ea8d0f2..398f867 100644 --- a/storage/mysql/mysql.go +++ b/storage/mysql/mysql.go @@ -1,4 +1,4 @@ -// Package mysql Package mysql stores and retrieves MDM data from MySQL +// Package mysql stores and retrieves MDM data from MySQL package mysql import ( From 726b2bedd76ef4c26e6ffde75d78b61db64db67e Mon Sep 17 00:00:00 2001 From: Pavlo Sheshenia Date: Sat, 16 Jul 2022 00:36:32 +0300 Subject: [PATCH 23/23] on conflict update time now --- storage/pgsql/certauth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/storage/pgsql/certauth.go b/storage/pgsql/certauth.go index 8182dea..1ceec98 100644 --- a/storage/pgsql/certauth.go +++ b/storage/pgsql/certauth.go @@ -44,7 +44,7 @@ func (s *PgSQLStorage) AssociateCertHash(r *mdm.Request, hash string) error { r.Context, ` INSERT INTO cert_auth_associations (id, sha256) VALUES ($1, $2) -ON CONFLICT ON CONSTRAINT cert_auth_associations_pkey DO NOTHING;`, +ON CONFLICT ON CONSTRAINT cert_auth_associations_pkey DO UPDATE SET updated_at=now();`, r.ID, strings.ToLower(hash), )