diff --git a/cmd/nanomdm/main.go b/cmd/nanomdm/main.go index 5ba2c81..7586282 100644 --- a/cmd/nanomdm/main.go +++ b/cmd/nanomdm/main.go @@ -15,6 +15,7 @@ import ( "github.com/micromdm/nanomdm/cli" mdmhttp "github.com/micromdm/nanomdm/http" httpapi "github.com/micromdm/nanomdm/http/api" + "github.com/micromdm/nanomdm/http/authproxy" httpmdm "github.com/micromdm/nanomdm/http/mdm" "github.com/micromdm/nanomdm/log/stdlogfmt" "github.com/micromdm/nanomdm/push/buford" @@ -34,6 +35,8 @@ const ( endpointMDM = "/mdm" endpointCheckin = "/checkin" + endpointAuthProxy = "/authproxy/" + endpointAPIPushCert = "/v1/pushcert" endpointAPIPush = "/v1/push/" endpointAPIEnqueue = "/v1/enqueue/" @@ -41,6 +44,11 @@ const ( endpointAPIVersion = "/version" ) +const ( + EnrollmentIDHeader = "X-Enrollment-ID" + TraceIDHeader = "X-Trace-ID" +) + func main() { cliStorage := cli.NewStorage() flag.Var(&cliStorage.Storage, "storage", "name of storage backend") @@ -62,6 +70,7 @@ func main() { flMigration = flag.Bool("migration", false, "HTTP endpoint for enrollment migrations") flRetro = flag.Bool("retro", false, "Allow retroactive certificate-authorization association") flDMURLPfx = flag.String("dm", "", "URL to send Declarative Management requests to") + flAuthProxy = flag.String("auth-proxy-url", "", "Reverse proxy URL target for MDM-authenticated HTTP requests") ) flag.Parse() @@ -129,6 +138,17 @@ func main() { mdmService = dump.New(mdmService, os.Stdout) } + // helper for authorizing MDM clients requests + certAuthMiddleware := func(h http.Handler) http.Handler { + h = httpmdm.CertVerifyMiddleware(h, verifier, logger.With("handler", "cert-verify")) + if *flCertHeader != "" { + h = httpmdm.CertExtractPEMHeaderMiddleware(h, *flCertHeader, logger.With("handler", "cert-extract")) + } else { + h = httpmdm.CertExtractMdmSignatureMiddleware(h, logger.With("handler", "cert-extract")) + } + return h + } + // register 'core' MDM HTTP handler var mdmHandler http.Handler if *flCheckin { @@ -138,26 +158,33 @@ func main() { // if we don't use a check-in handler then do both mdmHandler = httpmdm.CheckinAndCommandHandler(mdmService, logger.With("handler", "checkin-command")) } - mdmHandler = httpmdm.CertVerifyMiddleware(mdmHandler, verifier, logger.With("handler", "cert-verify")) - if *flCertHeader != "" { - mdmHandler = httpmdm.CertExtractPEMHeaderMiddleware(mdmHandler, *flCertHeader, logger.With("handler", "cert-extract")) - } else { - mdmHandler = httpmdm.CertExtractMdmSignatureMiddleware(mdmHandler, logger.With("handler", "cert-extract")) - } + mdmHandler = certAuthMiddleware(mdmHandler) mux.Handle(endpointMDM, mdmHandler) if *flCheckin { // if we specified a separate check-in handler, set it up var checkinHandler http.Handler checkinHandler = httpmdm.CheckinHandler(mdmService, logger.With("handler", "checkin")) - checkinHandler = httpmdm.CertVerifyMiddleware(checkinHandler, verifier, logger.With("handler", "cert-verify")) - if *flCertHeader != "" { - checkinHandler = httpmdm.CertExtractPEMHeaderMiddleware(checkinHandler, *flCertHeader, logger.With("handler", "cert-extract")) - } else { - checkinHandler = httpmdm.CertExtractMdmSignatureMiddleware(checkinHandler, logger.With("handler", "cert-extract")) - } + checkinHandler = certAuthMiddleware(checkinHandler) mux.Handle(endpointCheckin, checkinHandler) } + + if *flAuthProxy != "" { + var authProxyHandler http.Handler + authProxyHandler, err = authproxy.New(*flAuthProxy, + authproxy.WithLogger(logger.With("handler", "authproxy")), + authproxy.WithHeaderFunc(EnrollmentIDHeader, httpmdm.GetEnrollmentID), + authproxy.WithHeaderFunc(TraceIDHeader, mdmhttp.GetTraceID), + ) + if err != nil { + stdlog.Fatal(err) + } + logger.Debug("msg", "authproxy setup", "url", *flAuthProxy) + authProxyHandler = http.StripPrefix(endpointAuthProxy, authProxyHandler) + authProxyHandler = httpmdm.CertWithEnrollmentIDMiddleware(authProxyHandler, certauth.HashCert, mdmStorage, true, logger.With("handler", "with-enrollment-id")) + authProxyHandler = certAuthMiddleware(authProxyHandler) + mux.Handle(endpointAuthProxy, authProxyHandler) + } } if *flAPIKey != "" { diff --git a/docs/operations-guide.md b/docs/operations-guide.md index a82bd0c..95f5f1c 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -163,6 +163,12 @@ Print version and exit. NanoMDM supports a MicroMDM-compatible [webhook callback](https://github.com/micromdm/micromdm/blob/main/docs/user-guide/api-and-webhooks.md) option. This switch turns on the webhook and specifies the URL. +### -auth-proxy-url string + +* Reverse proxy URL target for MDM-authenticated HTTP requests + +Enables the authentication proxy and reverse proxies HTTP requests from the server's `/authproxy/` endpoint to this URL if the client provides the device's enrollment authentication. See below for more information. + ## HTTP endpoints & APIs ### MDM @@ -313,6 +319,16 @@ The migration endpoint (as talked about above under the `-migration` switch) is Returns a JSON response with the version of the running NanoMDM server. +### Authentication Proxy + +* Endpoint: `/authproxy/` + +If the `-auth-proxy-url` flag is provided then URLs that begin with `/authproxy/` will be reverse-proxied to the given target URL. Importantly this endpoint will authenticate the incoming request in the same way as other MDM endpoints (i.e. Check-In or Command Report and Response) — including whether we're using TLS client configuration or not (the `-cert-header` flag). Put together this allow you to have MDM-authenticated content retrieval. + +This feature is ostensibly to support Declarative Device Management and in particular the ability for some "Asset" declarations to use "MDM" authentication for their content. For example the `com.apple.asset.data` declaration supports an [Authentication key](https://github.com/apple/device-management/blob/2bb1726786047949b5b1aa923be33b9ba0f83e37/declarative/declarations/assets/data.yaml#L40-L54) for configuring this ability. + +As an example example if this feature is enabled and a request comes to the server as `/authproxy/foo/bar` and the `-auth-proxy-url` was set to, say, `http://[::1]:9008` then NanoMDM will reverse proxy this URL to `http://[::1]:9008/foo/bar`. An HTP 502 Bad Gateway response is sent back to the client for any issues proxying. + # Enrollment Migration (nano2nano) The `nano2nano` tool extracts migration enrollment data from a given storage backend and sends it to a NanoMDM migration endpoint. In this way you can effectively migrate between database backends. For example if you started with a `file` backend you could migrate to a `mysql` backend and vice versa. Note that MDM servers must have *exactly* the same server URL for migrations to operate. diff --git a/http/authproxy/authproxy.go b/http/authproxy/authproxy.go new file mode 100644 index 0000000..fd04287 --- /dev/null +++ b/http/authproxy/authproxy.go @@ -0,0 +1,89 @@ +// Package authproxy is a simple reverse proxy for Apple MDM clients. +package authproxy + +import ( + "context" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/micromdm/nanomdm/log" + "github.com/micromdm/nanomdm/log/ctxlog" +) + +// HeaderFunc takes an HTTP request and returns a string value. +// Ostensibly to be set in a header on the proxy target. +type HeaderFunc func(context.Context) string + +type config struct { + logger log.Logger + fwdSig bool + headerFuncs map[string]HeaderFunc +} + +type Option func(*config) + +// WithLogger sets a logger for error reporting. +func WithLogger(logger log.Logger) Option { + return func(c *config) { + c.logger = logger + } +} + +// WithHeaderFunc configures fn to be called and added as an HTTP header to the proxy target request. +func WithHeaderFunc(header string, fn HeaderFunc) Option { + return func(c *config) { + c.headerFuncs[header] = fn + } +} + +// WithForwardMDMSignature forwards the MDM-Signature header onto the proxy destination. +// This option is off by default because the header adds about two kilobytes to the request. +func WithForwardMDMSignature() Option { + return func(c *config) { + c.fwdSig = true + } +} + +// New creates a new NanoMDM enrollment authenticating reverse proxy. +// This reverse proxy is mostly the standard httputil proxy. It depends +// on middleware HTTP handlers to enforce authentication and set the +// context value for the enrollment ID. +func New(dest string, opts ...Option) (*httputil.ReverseProxy, error) { + config := &config{ + logger: log.NopLogger, + headerFuncs: make(map[string]HeaderFunc), + } + for _, opt := range opts { + opt(config) + } + target, err := url.Parse(dest) + if err != nil { + return nil, err + } + proxy := httputil.NewSingleHostReverseProxy(target) + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + ctxlog.Logger(r.Context(), config.logger).Info("err", err) + // use the same error as the standrad reverse proxy + w.WriteHeader(http.StatusBadGateway) + } + dir := proxy.Director + proxy.Director = func(req *http.Request) { + dir(req) + req.Host = target.Host + if !config.fwdSig { + // save the effort of forwarding this huge header + req.Header.Del("Mdm-Signature") + } + // set any headers we want to forward. + for k, fn := range config.headerFuncs { + if k == "" || fn == nil { + continue + } + if v := fn(req.Context()); v != "" { + req.Header.Set(k, v) + } + } + } + return proxy, nil +} diff --git a/http/http.go b/http/http.go index 9065b0d..4233705 100644 --- a/http/http.go +++ b/http/http.go @@ -51,6 +51,12 @@ func VersionHandler(version string) http.HandlerFunc { type ctxKeyTraceID struct{} +// GetTraceID returns the trace ID from ctx. +func GetTraceID(ctx context.Context) string { + id, _ := ctx.Value(ctxKeyTraceID{}).(string) + return id +} + // TraceLoggingMiddleware sets up a trace ID in the request context and // logs HTTP requests. func TraceLoggingMiddleware(next http.Handler, logger log.Logger, traceID func(*http.Request) string) http.HandlerFunc { diff --git a/http/mdm/mdm_cert.go b/http/mdm/mdm_cert.go index 86e51fb..e73a6d1 100644 --- a/http/mdm/mdm_cert.go +++ b/http/mdm/mdm_cert.go @@ -10,10 +10,13 @@ import ( mdmhttp "github.com/micromdm/nanomdm/http" "github.com/micromdm/nanomdm/log" "github.com/micromdm/nanomdm/log/ctxlog" + "github.com/micromdm/nanomdm/storage" ) type contextKeyCert struct{} +var contextEnrollmentID struct{} + // CertExtractPEMHeaderMiddleware extracts the MDM enrollment identity // certificate from the request into the HTTP request context. It looks // at the request header which should be a URL-encoded PEM certificate. @@ -128,3 +131,70 @@ func CertVerifyMiddleware(next http.Handler, verifier CertVerifier, logger log.L next.ServeHTTP(w, r) } } + +// GetEnrollmentID retrieves the MDM enrollment ID from ctx. +func GetEnrollmentID(ctx context.Context) string { + id, _ := ctx.Value(contextEnrollmentID).(string) + return id +} + +type HashFn func(*x509.Certificate) string + +// CertWithEnrollmentIDMiddleware tries to associate the enrollment ID to the request context. +// It does this by looking up the certificate on the context, hashing it with +// hasher, looking up the hash in storage, and setting the ID on the context. +// +// The next handler will be called even if cert or ID is not found unless +// enforce is true. This way next is able to use the existence of the ID on +// the context to make its own decisions. +func CertWithEnrollmentIDMiddleware(next http.Handler, hasher HashFn, store storage.CertAuthRetriever, enforce bool, logger log.Logger) http.HandlerFunc { + if store == nil || hasher == nil { + panic("store and hasher must not be nil") + } + return func(w http.ResponseWriter, r *http.Request) { + cert := GetCert(r.Context()) + if cert == nil { + if enforce { + ctxlog.Logger(r.Context(), logger).Info( + "err", "missing certificate", + ) + // we cannot send a 401 to the client as it has MDM protocol semantics + // i.e. the device may unenroll + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusBadRequest) + return + } else { + ctxlog.Logger(r.Context(), logger).Debug( + "msg", "missing certificate", + ) + next.ServeHTTP(w, r) + return + } + } + id, err := store.EnrollmentFromHash(r.Context(), hasher(cert)) + if err != nil { + ctxlog.Logger(r.Context(), logger).Info( + "msg", "retreiving enrollment from hash", + "err", err, + ) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if id == "" { + if enforce { + ctxlog.Logger(r.Context(), logger).Info( + "err", "missing enrollment id", + ) + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusBadRequest) + return + } else { + ctxlog.Logger(r.Context(), logger).Debug( + "msg", "missing enrollment id", + ) + next.ServeHTTP(w, r) + return + } + } + ctx := context.WithValue(r.Context(), contextEnrollmentID, id) + next.ServeHTTP(w, r.WithContext(ctx)) + } +} diff --git a/http/mdm/mdm_test.go b/http/mdm/mdm_test.go new file mode 100644 index 0000000..c05414d --- /dev/null +++ b/http/mdm/mdm_test.go @@ -0,0 +1,77 @@ +package mdm + +import ( + "bytes" + "context" + "crypto/x509" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/micromdm/nanomdm/log" +) + +const ( + testHash = "ZZZYYYXXX" + testID = "AAABBBCCC" +) + +func testHashCert(_ *x509.Certificate) string { + return testHash +} + +type testCertAuthRetriever struct{} + +func (c *testCertAuthRetriever) EnrollmentFromHash(ctx context.Context, hash string) (string, error) { + if hash != testHash { + return "", errors.New("invalid test hash") + } + return testID, nil +} + +func TestCertWithEnrollmentIDMiddleware(t *testing.T) { + response := []byte("mock response") + + // mock handler + var handler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write(response) + }) + + handler = CertWithEnrollmentIDMiddleware(handler, testHashCert, &testCertAuthRetriever{}, true, log.NopLogger) + + req, err := http.NewRequest("GET", "/test", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + // we requested enforcement, and did not include a cert, so make sure we get a BadResponse + if have, want := rr.Code, http.StatusBadRequest; have != want { + t.Errorf("have: %d, want: %d", have, want) + } + + req, err = http.NewRequest("GET", "/test", nil) + if err != nil { + t.Fatal(err) + } + + // mock "cert" + req = req.WithContext(context.WithValue(req.Context(), contextKeyCert{}, &x509.Certificate{})) + + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + // now that we have a "cert" included, we should get an OK + if have, want := rr.Code, http.StatusOK; have != want { + t.Errorf("have: %d, want: %d", have, want) + } + + // verify the actual body, too + if !bytes.Equal(rr.Body.Bytes(), response) { + t.Error("body not equal") + } +} diff --git a/service/certauth/certauth.go b/service/certauth/certauth.go index fda00b0..fbe3c84 100644 --- a/service/certauth/certauth.go +++ b/service/certauth/certauth.go @@ -97,7 +97,8 @@ func New(next service.CheckinAndCommandService, storage storage.CertAuthStore, o return certAuth } -func hashCert(cert *x509.Certificate) string { +// HashCert returns the string representation +func HashCert(cert *x509.Certificate) string { hashed := sha256.Sum256(cert.Raw) b := make([]byte, len(hashed)) copy(b, hashed[:]) @@ -112,7 +113,7 @@ func (s *CertAuth) associateNewEnrollment(r *mdm.Request) error { return err } logger := ctxlog.Logger(r.Context, s.logger) - hash := hashCert(r.Certificate) + hash := HashCert(r.Certificate) if hasHash, err := s.storage.HasCertHash(r, hash); err != nil { return err } else if hasHash { @@ -157,7 +158,7 @@ func (s *CertAuth) validateAssociateExistingEnrollment(r *mdm.Request) error { return err } logger := ctxlog.Logger(r.Context, s.logger) - hash := hashCert(r.Certificate) + hash := HashCert(r.Certificate) if isAssoc, err := s.storage.IsCertHashAssociated(r, hash); err != nil { return err } else if isAssoc { diff --git a/storage/all.go b/storage/all.go index bb69851..10b48c0 100644 --- a/storage/all.go +++ b/storage/all.go @@ -7,6 +7,7 @@ type AllStorage interface { PushCertStore CommandEnqueuer CertAuthStore + CertAuthRetriever StoreMigrator TokenUpdateTallyStore } diff --git a/storage/allmulti/certauth.go b/storage/allmulti/certauth.go index 3593c6c..1aca69a 100644 --- a/storage/allmulti/certauth.go +++ b/storage/allmulti/certauth.go @@ -1,6 +1,8 @@ package allmulti import ( + "context" + "github.com/micromdm/nanomdm/mdm" "github.com/micromdm/nanomdm/storage" ) @@ -32,3 +34,10 @@ func (ms *MultiAllStorage) AssociateCertHash(r *mdm.Request, hash string) error }) return err } + +func (ms *MultiAllStorage) EnrollmentFromHash(ctx context.Context, hash string) (string, error) { + val, err := ms.execStores(ctx, func(s storage.AllStorage) (interface{}, error) { + return s.EnrollmentFromHash(ctx, hash) + }) + return val.(string), err +} diff --git a/storage/file/certauth.go b/storage/file/certauth.go index 47ee0d6..5d90fca 100644 --- a/storage/file/certauth.go +++ b/storage/file/certauth.go @@ -2,6 +2,7 @@ package file import ( "bufio" + "context" "errors" "os" "path" @@ -68,3 +69,23 @@ func (s *FileStorage) AssociateCertHash(r *mdm.Request, hash string) error { e := s.newEnrollment(r.ID) return e.writeFile(CertAuthFilename, []byte(hash)) } + +func (s *FileStorage) EnrollmentFromHash(_ context.Context, hash string) (string, error) { + f, err := os.Open(path.Join(s.path, CertAuthAssociationsFilename)) + if err != nil { + return "", err + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + text := scanner.Text() + if strings.Contains(text, hash) { + split := strings.Split(text, ",") + if len(split) < 2 { + return "", errors.New("hash and enrollment id not present on line") + } + return split[0], nil + } + } + return "", nil +} diff --git a/storage/mysql/certauth.go b/storage/mysql/certauth.go index cc64c0e..2f18aea 100644 --- a/storage/mysql/certauth.go +++ b/storage/mysql/certauth.go @@ -2,6 +2,8 @@ package mysql import ( "context" + "database/sql" + "errors" "strings" "github.com/micromdm/nanomdm/mdm" @@ -49,3 +51,16 @@ UPDATE sha256 = new.sha256;`, ) return err } + +func (s *MySQLStorage) EnrollmentFromHash(ctx context.Context, hash string) (string, error) { + var id string + err := s.db.QueryRowContext( + ctx, + `SELECT id FROM cert_auth_associations WHERE sha256 = ? LIMIT 1;`, + hash, + ).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return id, err +} diff --git a/storage/mysql/queue.go b/storage/mysql/queue.go index 2a0be58..54dcb36 100644 --- a/storage/mysql/queue.go +++ b/storage/mysql/queue.go @@ -19,9 +19,6 @@ func enqueue(ctx context.Context, tx *sql.Tx, ids []string, cmd *mdm.Command) er `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) @@ -56,6 +53,9 @@ SELECT command_uuid FROM commands WHERE command_uuid = ? FOR UPDATE; `, uuid, ) + if err != nil { + return err + } // delete command result (i.e. NotNows) and this queued command _, err = tx.ExecContext( ctx, ` diff --git a/storage/pgsql/certauth.go b/storage/pgsql/certauth.go index 1ceec98..fcf9f74 100644 --- a/storage/pgsql/certauth.go +++ b/storage/pgsql/certauth.go @@ -2,6 +2,8 @@ package pgsql import ( "context" + "database/sql" + "errors" "strings" "github.com/micromdm/nanomdm/mdm" @@ -50,3 +52,16 @@ ON CONFLICT ON CONSTRAINT cert_auth_associations_pkey DO UPDATE SET updated_at=n ) return err } + +func (s *PgSQLStorage) EnrollmentFromHash(ctx context.Context, hash string) (string, error) { + var id string + err := s.db.QueryRowContext( + ctx, + `SELECT id FROM cert_auth_associations WHERE sha256 = $1 LIMIT 1;`, + hash, + ).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + return id, err +} diff --git a/storage/storage.go b/storage/storage.go index 22a1976..d813cbc 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -65,6 +65,12 @@ type CertAuthStore interface { AssociateCertHash(r *mdm.Request, hash string) error } +type CertAuthRetriever interface { + // EnrollmentFromHash retrieves an enrollment ID from a cert hash. + // Implementations should return an empty string if no result is found. + EnrollmentFromHash(ctx context.Context, hash string) (string, error) +} + // StoreMigrator retrieves MDM check-ins type StoreMigrator interface { // RetrieveMigrationCheckins sends the (decoded) forms of