Move nanodep dependency in monorepo (#16984)

This commit is contained in:
Martin Angers 2024-02-26 10:26:00 -05:00 committed by GitHub
parent 762cd076d7
commit 2dfb260850
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 5208 additions and 94 deletions

View File

@ -20,6 +20,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/policies"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
@ -35,7 +36,6 @@ import (
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/hashicorp/go-multierror"
"github.com/micromdm/nanodep/godep"
)
func errHandler(ctx context.Context, logger kitlog.Logger, msg string, err error) {

View File

@ -21,6 +21,8 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
@ -28,8 +30,6 @@ import (
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"github.com/go-kit/log"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/tokenpki"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mozilla.org/pkcs7"

View File

@ -22,11 +22,11 @@ import (
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/google/uuid"
"github.com/micromdm/nanodep/tokenpki"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View File

@ -3,22 +3,22 @@ package main
import (
"context"
"fmt"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
appleMdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/go-git/go-git/v5"
"github.com/micromdm/nanodep/tokenpki"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"os"
"path"
"path/filepath"
"testing"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
appleMdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/go-git/go-git/v5"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
func TestEnterpriseIntegrationsGitops(t *testing.T) {

View File

@ -3,20 +3,20 @@ package main
import (
"context"
"fmt"
"os"
"path"
"testing"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
appleMdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/go-git/go-git/v5"
"github.com/micromdm/nanodep/tokenpki"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"os"
"path"
"testing"
)
func TestIntegrationsGitops(t *testing.T) {

View File

@ -11,9 +11,9 @@ import (
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/micromdm/nanodep/tokenpki"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View File

@ -5,11 +5,6 @@ import (
"context"
"encoding/json"
"fmt"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/test"
nanodepClient "github.com/micromdm/nanodep/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"io"
"net/http"
"net/http/httptest"
@ -19,10 +14,15 @@ import (
"time"
"github.com/fleetdm/fleet/v4/server/datastore/cached_mysql"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
nanodepClient "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/urfave/cli/v2"
)

View File

@ -24,13 +24,13 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
depclient "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
"github.com/fleetdm/fleet/v4/server/sso"
"github.com/fleetdm/fleet/v4/server/worker"
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"github.com/google/uuid"
depclient "github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/storage"
)
func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
@ -69,7 +69,7 @@ func (svc *Service) GetAppleBM(ctx context.Context) (*fleet.AppleBM, error) {
return appleBM, nil
}
func getAppleBMAccountDetail(ctx context.Context, depStorage storage.AllStorage, ds fleet.Datastore, logger kitlog.Logger) (*fleet.AppleBM, error) {
func getAppleBMAccountDetail(ctx context.Context, depStorage storage.AllDEPStorage, ds fleet.Datastore, logger kitlog.Logger) (*fleet.AppleBM, error) {
depClient := apple_mdm.NewDEPClient(depStorage, ds, logger)
res, err := depClient.AccountDetail(ctx, apple_mdm.DEPName)
if err != nil {

View File

@ -8,9 +8,9 @@ import (
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
"github.com/fleetdm/fleet/v4/server/sso"
kitlog "github.com/go-kit/kit/log"
"github.com/micromdm/nanodep/storage"
)
// Service wraps a free Service and implements additional premium functionality on top of it.
@ -22,7 +22,7 @@ type Service struct {
config config.FleetConfig
clock clock.Clock
authz *authz.Authorizer
depStorage storage.AllStorage
depStorage storage.AllDEPStorage
mdmAppleCommander fleet.MDMAppleCommandIssuer
mdmPushCertTopic string
ssoSessionStore sso.SessionStore
@ -37,7 +37,7 @@ func NewService(
config config.FleetConfig,
mailService fleet.MailService,
c clock.Clock,
depStorage storage.AllStorage,
depStorage storage.AllDEPStorage,
mdmAppleCommander fleet.MDMAppleCommandIssuer,
mdmPushCertTopic string,
sso sso.SessionStore,

5
go.mod
View File

@ -43,6 +43,7 @@ require (
github.com/go-sql-driver/mysql v1.7.1
github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055
github.com/golang-jwt/jwt/v4 v4.4.2
github.com/gomodule/oauth1 v0.2.0
github.com/gomodule/redigo v1.8.9
github.com/google/go-cmp v0.6.0
github.com/google/go-github/v37 v37.0.0
@ -67,7 +68,6 @@ require (
github.com/mattermost/xml-roundtrip-validator v0.0.0-20201213122252-bcd7e1b9601e
github.com/mattn/go-sqlite3 v1.14.13
github.com/micromdm/micromdm v1.9.0
github.com/micromdm/nanodep v0.1.0
github.com/mitchellh/go-ps v1.0.0
github.com/mitchellh/gon v0.2.6-0.20231031204852-2d4f161ccecd
github.com/mna/redisc v1.3.2
@ -221,7 +221,6 @@ require (
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gomodule/oauth1 v0.2.0 // indirect
github.com/google/go-github/v39 v39.2.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/rpmpack v0.0.0-20210518075352-dc539ef4f2ea // indirect
@ -320,5 +319,3 @@ require (
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.0.3 // indirect
)
replace github.com/micromdm/nanodep => github.com/fleetdm/nanodep v0.1.1-0.20221221202251-71b67ab1da24

2
go.sum
View File

@ -446,8 +446,6 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fleetdm/nanodep v0.1.1-0.20221221202251-71b67ab1da24 h1:XhczaxKV3J4NjztroidSnYKyq5xtxF+amBYdBWeik58=
github.com/fleetdm/nanodep v0.1.1-0.20221221202251-71b67ab1da24/go.mod h1:QzQrCUTmSr9HotzKZAcfmy+czbEGK8Mq26hA+0DN4ag=
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=

View File

@ -18,9 +18,9 @@ import (
"testing"
"time"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/tokenpki"
"github.com/spf13/cast"
"github.com/spf13/cobra"
"github.com/spf13/viper"

View File

@ -13,13 +13,13 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/micromdm/nanodep/godep"
)
func (ds *Datastore) NewMDMAppleConfigProfile(ctx context.Context, cp fleet.MDMAppleConfigProfile) (*fleet.MDMAppleConfigProfile, error) {

View File

@ -18,6 +18,8 @@ import (
fleetmdm "github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
"github.com/fleetdm/fleet/v4/server/ptr"
@ -25,8 +27,6 @@ import (
"github.com/go-sql-driver/mysql"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/micromdm/nanodep/godep"
"github.com/micromdm/nanodep/tokenpki"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View File

@ -22,11 +22,11 @@ import (
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/micromdm/nanodep/godep"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"

View File

@ -14,13 +14,13 @@ import (
mdm_types "github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/certauth"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/micromdm/nanodep/tokenpki"
"github.com/stretchr/testify/require"
)

View File

@ -8,12 +8,12 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
nanodep_mysql "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage/mysql"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
nanomdm_mysql "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage/mysql"
"github.com/go-kit/log"
"github.com/jmoiron/sqlx"
nanodep_client "github.com/micromdm/nanodep/client"
nanodep_mysql "github.com/micromdm/nanodep/storage/mysql"
)
// NanoMDMStorage wraps a *nanomdm_mysql.MySQLStorage and overrides further functionality.

View File

@ -7,8 +7,8 @@ import (
"testing"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
scep_depot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
"github.com/micromdm/nanodep/tokenpki"
"github.com/stretchr/testify/require"
)

View File

@ -12,7 +12,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/micromdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
)
type MDMAppleCommandIssuer interface {

View File

@ -15,7 +15,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
"github.com/micromdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
)
type CarveStore interface {

View File

@ -10,11 +10,11 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mock"
nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
"github.com/go-kit/log"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/godep"
"github.com/stretchr/testify/require"
)

View File

@ -1,9 +1,9 @@
package logging
import (
nanodep_log "github.com/fleetdm/fleet/v4/server/mdm/nanodep/log"
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/log/level"
nanodep_log "github.com/micromdm/nanodep/log"
)
// NanoDEPLogger is a logger adapter for nanodep.

View File

@ -17,14 +17,14 @@ import (
"github.com/fleetdm/fleet/v4/server/logging"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/log/level"
"github.com/google/uuid"
"github.com/micromdm/nanodep/godep"
nanodep_storage "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
depsync "github.com/fleetdm/fleet/v4/server/mdm/nanodep/sync"
kitlog "github.com/go-kit/kit/log"
nanodep_storage "github.com/micromdm/nanodep/storage"
depsync "github.com/micromdm/nanodep/sync"
)
// DEPName is the identifier/name used in nanodep MySQL storage which
@ -83,7 +83,7 @@ func ResolveAppleSCEPURL(serverURL string) (string, error) {
// checks.
type DEPService struct {
ds fleet.Datastore
depStorage nanodep_storage.AllStorage
depStorage nanodep_storage.AllDEPStorage
syncer *depsync.Syncer
logger kitlog.Logger
}
@ -353,7 +353,7 @@ func (d *DEPService) RunAssigner(ctx context.Context) error {
func NewDEPService(
ds fleet.Datastore,
depStorage nanodep_storage.AllStorage,
depStorage nanodep_storage.AllDEPStorage,
logger kitlog.Logger,
) *DEPService {
depClient := NewDEPClient(depStorage, ds, logger)

View File

@ -13,10 +13,10 @@ import (
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/go-kit/log"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/godep"
"github.com/stretchr/testify/require"
)

View File

@ -11,11 +11,11 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mock"
nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
"github.com/go-kit/log"
"github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/godep"
"github.com/stretchr/testify/require"
)

View File

@ -13,8 +13,8 @@ import (
"os"
"strings"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
"github.com/micromdm/nanodep/tokenpki"
)
const (

View File

@ -0,0 +1,7 @@
Copyright 2022 Jesse Peterson
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,36 @@
# NanoDEP
> The contents of this directory were copied (on February 2024) from https://github.com/fleetdm/nanomdm (the `apple-mdm` branch) which was forked from https://github.com/micromdm/nanodep.
[![Go](https://github.com/micromdm/nanodep/workflows/Go/badge.svg)](https://github.com/micromdm/nanodep/actions) [![Go Reference](https://pkg.go.dev/badge/github.com/micromdm/nanodep.svg)](https://pkg.go.dev/github.com/micromdm/nanodep)
NanoDEP is a set of tools and a Go library powering them for communicating with Apple's Device Enrollment Program (DEP) API servers.
## Getting started & Documentation
- [Quickstart](docs/quickstart.md)
A guide to get NanoDEP up and running quickly.
- [Operations Guide](docs/operations-guide.md)
A brief overview of the various tools and utilities for working with NanoDEP.
## Tools and utilities
NanoDEP contains a few tools and utilities. At a high level:
- **DEP configuration & reverse proxy server.** The primary server component, called `depserver` is used for configuring NanoDEP and talking with Apple's DEP servers. It hosts its own API for configuring MDM server instances used with Apple's servers (called DEP names) and also hosts a transparently authenticating reverse proxy for talking 'directly' to Apple's DEP API endpoints.
- **Device sync & assigner.** The `depsyncer` tool handles the device fetch/sync cursor logic to continually retrieve the assigned devices from one or more Apple DEP MDM server instance(s).
- **Scripts, tools, and helpers.**
- A set of [tools](tools) and utilities for talking to the Apple DEP API services — mostly implemented as shell scripts that communicate to the `depserver`.
- A stand-alone `deptokens` tool for locally working with certificate generation for DEP token decryption.
See the [Operations Guide](docs/operations-guide.md) for more details and usage documentation.
## Go library
NanoDEP is also a Go library for accessing the Apple DEP APIs. There are two components to the Go library:
* The higher-level [godep](https://pkg.go.dev/github.com/micromdm/nanodep/godep) package implements Go methods and structures for talking to the individual DEP API endpoints.
* The lower-level [client](https://pkg.go.dev/github.com/micromdm/nanodep/client) package implements primitives, helpers, and middleware for authenticating to the DEP API and managing sessions tokens.
See the [Go Reference documentation](https://pkg.go.dev/github.com/micromdm/nanodep) (or the Go source itself, of course) for details on these packages.

View File

@ -0,0 +1,122 @@
package client
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/gomodule/oauth1/oauth"
)
// sessionContentType is the exact header required by the `depsim` DEP
// simulator for the /session endpoint.
const sessionContentType = "application/json;charset=UTF8"
// ErrEmptyAuthSessionToken occurs with a valid JSON session response but
// contains an empty session token.
var ErrEmptyAuthSessionToken = errors.New("empty auth session token")
// AuthError encapsulates an HTTP response error from the /session endpoint.
// The API returns error information in the request body.
type AuthError struct {
Body []byte
Status string
StatusCode int
}
func (e *AuthError) Error() string {
return fmt.Sprintf("DEP auth error: %s: %s", e.Status, string(e.Body))
}
// NewAuthError creates and returns a new AuthError from r. Note this reads
// r.Body and you are responsible for Closing it.
func NewAuthError(r *http.Response) error {
body, readErr := io.ReadAll(r.Body)
err := &AuthError{
Body: body,
Status: r.Status,
StatusCode: r.StatusCode,
}
if readErr != nil {
return fmt.Errorf("reading body of DEP auth error: %v: %w", err, readErr)
}
return err
}
// OAuth1Tokens represents the token Apple DEP OAuth1 authentication tokens.
type OAuth1Tokens struct {
ConsumerKey string `json:"consumer_key"`
ConsumerSecret string `json:"consumer_secret"`
AccessToken string `json:"access_token"`
AccessSecret string `json:"access_secret"`
AccessTokenExpiry time.Time `json:"access_token_expiry"`
}
// Valid performs sanity checks to make sure t appears to be valid DEP server OAuth 1 tokens.
func (t *OAuth1Tokens) Valid() bool {
if t == nil {
return false
}
if t.ConsumerKey != "" && t.ConsumerSecret != "" && t.AccessToken != "" && t.AccessSecret != "" {
return true
}
return false
}
// SetAuthorizationHeader sets the OAuth1 Authorization HTTP request header
// using the supplied DEP tokens. Intended for the DEP /session endpoint.
// See https://developer.apple.com/documentation/devicemanagement/device_assignment/authenticating_with_a_device_enrollment_program_dep_server
func SetAuthorizationHeader(tokens *OAuth1Tokens, req *http.Request) error {
consumerCreds := oauth.Credentials{
Token: tokens.ConsumerKey,
Secret: tokens.ConsumerSecret,
}
oauthClient := oauth.Client{
SignatureMethod: oauth.HMACSHA1, // HMAC-SHA1 is required by Apple
TokenRequestURI: req.URL.String(),
Credentials: consumerCreds,
}
accessCreds := oauth.Credentials{
Token: tokens.AccessToken,
Secret: tokens.AccessSecret,
}
return oauthClient.SetAuthorizationHeader(
req.Header,
&accessCreds,
req.Method,
req.URL,
nil,
)
}
// DoAuth performs OAuth1 authentication to the Apple DEP server and returns
// the 'auth_session_token' from the JSON response.
func DoAuth(client Doer, req *http.Request, tokens *OAuth1Tokens) (string, error) {
err := SetAuthorizationHeader(tokens, req)
if err != nil {
return "", err
}
if _, ok := req.Header["Content-Type"]; !ok {
// required for the simulator
req.Header.Set("Content-Type", sessionContentType)
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", NewAuthError(resp)
}
var authSessionToken struct {
AuthSessionToken string `json:"auth_session_token"`
}
err = json.NewDecoder(resp.Body).Decode(&authSessionToken)
if err == nil && authSessionToken.AuthSessionToken == "" {
err = ErrEmptyAuthSessionToken
}
return authSessionToken.AuthSessionToken, err
}

View File

@ -0,0 +1,84 @@
// Package client implements HTTP primitives for talking with and authenticating with the Apple DEP APIs.
package client
import (
"context"
"io"
"net/http"
"net/url"
)
const DefaultBaseURL = "https://mdmenrollment.apple.com/"
// Doer executes an HTTP request.
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
// Config represents the configuration of a DEP name.
type Config struct {
BaseURL string `json:"base_url,omitempty"`
}
type ConfigRetriever interface {
RetrieveConfig(context.Context, string) (*Config, error)
}
// DefaultConfigRetreiver wraps a ConfigRetriever to return a default configuration.
type DefaultConfigRetreiver struct {
next ConfigRetriever
}
func NewDefaultConfigRetreiver(next ConfigRetriever) *DefaultConfigRetreiver {
return &DefaultConfigRetreiver{next: next}
}
// RetrieveConfig retrieves the Config from the wrapped retreiver and returns
// it. If the config is empty a default config is returned.
func (c *DefaultConfigRetreiver) RetrieveConfig(ctx context.Context, name string) (*Config, error) {
config, err := c.next.RetrieveConfig(ctx, name)
if config == nil || config.BaseURL == "" {
config = &Config{BaseURL: DefaultBaseURL}
}
return config, err
}
// RetrieveAndResolveURL retrieves the base URL for a DEP name using store
// and resolves the full DEP request URL using path.
func RetrieveAndResolveURL(ctx context.Context, name string, store ConfigRetriever, path string) (*url.URL, error) {
store = NewDefaultConfigRetreiver(store)
config, err := store.RetrieveConfig(ctx, name)
if err != nil {
return nil, err
}
urlBase, err := url.Parse(config.BaseURL)
if err != nil {
return nil, err
}
urlPath, err := url.Parse(path)
if err != nil {
return nil, err
}
return urlBase.ResolveReference(urlPath), nil
}
// NewDEPRequestWithContext creates a new request for a DEP name. Note that
// path is the relative path of the DEP endpoint name like "account".
func NewRequestWithContext(ctx context.Context, name string, store ConfigRetriever, method, path string, body io.Reader) (*http.Request, error) {
url, err := RetrieveAndResolveURL(ctx, name, store, path)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, method, url.String(), body)
if err != nil {
return req, err
}
return req.WithContext(WithName(req.Context(), name)), nil
}
// NewClient is a helper that returns a copy of client with transport set.
func NewClient(client *http.Client, transport http.RoundTripper) *http.Client {
depClient := *client
depClient.Transport = transport
return &depClient
}

View File

@ -0,0 +1,279 @@
package client
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sync"
)
const (
// HTTP header names
ADMAuthSession = "X-ADM-Auth-Session"
ServerProtocolVersion = "X-Server-Protocol-Version"
DefaultServerProtocolVersion = "3"
SessionEndpoint = "/session"
bodyForbidden = "FORBIDDEN"
)
// ErrMissingName is returned when an HTTP context is missing the DEP name.
var ErrMissingName = errors.New("transport: missing DEP name in HTTP request context")
// ctxKeyName is the context key for the DEP name.
type ctxKeyName struct{}
// WithName creates a new context from ctx with the DEP name associated.
func WithName(ctx context.Context, name string) context.Context {
return context.WithValue(ctx, ctxKeyName{}, name)
}
// GetName retrieves the DEP name from ctx.
func GetName(ctx context.Context) string {
v, _ := ctx.Value(ctxKeyName{}).(string)
return v
}
type AuthTokensRetriever interface {
RetrieveAuthTokens(ctx context.Context, name string) (*OAuth1Tokens, error)
}
type SessionStore interface {
SetSessionToken(context.Context, string, string) error
GetSessionToken(context.Context, string) (string, error)
}
// sessionMap is a simple SessionStore which manages DEP authentication in a
// Go map. Note this potentially means that these DEP sessions are are not
// shared and thus the Apple DEP servers may not support multiple sessions at
// the same time.
type sessionMap struct {
sessions map[string]string
sync.RWMutex
}
// newSessionMap initializes a new sessionMap.
func newSessionMap() *sessionMap {
return &sessionMap{sessions: make(map[string]string)}
}
func (s *sessionMap) SetSessionToken(_ context.Context, name, session string) error {
s.Lock()
defer s.Unlock()
if session == "" {
delete(s.sessions, name)
} else {
s.sessions[name] = session
}
return nil
}
func (s *sessionMap) GetSessionToken(_ context.Context, name string) (token string, err error) {
s.RLock()
defer s.RUnlock()
token = s.sessions[name]
return
}
// Transport is an http.RoundTripper that transparently handles Apple DEP API
// authentication and session token management. See the RoundTrip method for
// more details.
type Transport struct {
// Wrapped transport that we call for actual HTTP RoundTripping.
transport http.RoundTripper
// Used for making the raw requests to the /session endpoint for
// authentication and session token capture.
client Doer
tokens AuthTokensRetriever
sessions SessionStore
// a cached pre-parsed URL of the /session path only (not a full URL)
sessionURL *url.URL
}
// NewTransport creates a new Transport which wraps and calls to t for the
// actual HTTP calls. We call c for executing the authentication endpoint
// /session. The sessions are stored and retrieved using s while auth tokens
// are retrieved using tokens.
// If t is nil then http.DefaultTransport is used. If c is nil then
// http.DefaultClient is used. If s is nil then local-only session management
// is used. A panic will ensue if tokens is nil.
func NewTransport(t http.RoundTripper, c Doer, tokens AuthTokensRetriever, s SessionStore) *Transport {
if t == nil {
t = http.DefaultTransport
}
if c == nil {
c = http.DefaultClient
}
if tokens == nil {
panic("nil token retriever")
}
if s == nil {
s = newSessionMap()
}
url, err := url.Parse(SessionEndpoint)
if err != nil {
// there shouldn't be a valid reason why url.Parse fails on this
panic(err)
}
return &Transport{
transport: t,
client: c,
tokens: tokens,
sessions: s,
sessionURL: url,
}
}
// TeeReadCloser returns an io.ReadCloser that writes to w what it reads from rc.
// See also io.TeeReader as we simply wrap it under the hood here.
func TeeReadCloser(rc io.ReadCloser, w io.Writer) io.ReadCloser {
type readCloser struct {
io.Reader
io.Closer
}
return &readCloser{io.TeeReader(rc, w), rc}
}
// RoundTrip transparently handles DEP server authentication and session token
// management. Practically speaking this means we make up to three individual
// requests for a given single request: the initial request attempt, a
// possible authentication request followed by a re-try of the original, now
// authenticated, request. Note also that we try to be helpful and inject the
// `X-Server-Protocol-Version` into the request headers if it is missing.
// See https://developer.apple.com/documentation/devicemanagement/device_assignment/authenticating_with_a_device_enrollment_program_dep_server
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
name := GetName(req.Context())
if name == "" {
return nil, ErrMissingName
}
// Apple DEP servers support differing requests and responses based on the
// protocol version header. Try to be helpful and use the latest protocol
// version mentioned in the docs.
if _, ok := req.Header[ServerProtocolVersion]; !ok {
req.Header.Set(ServerProtocolVersion, DefaultServerProtocolVersion)
}
// if previous requests have already authenticated try to use that session token
session, err := t.sessions.GetSessionToken(req.Context(), name)
if err != nil {
return nil, fmt.Errorf("transport: retrieving session token: %w", err)
}
var resp *http.Response
var reqBodyBuf *bytes.Buffer
var roundTripped bool
var forbidden bool
if session != "" {
// if we have a session token for this DEP name then try to inject it
req.Header.Set(ADMAuthSession, session)
if req.Body != nil && req.GetBody == nil {
reqBodyBuf = bytes.NewBuffer(make([]byte, 0, req.ContentLength))
// stream the body to both the wrapped transport and our buffer in case we need to retry
req.Body = TeeReadCloser(req.Body, reqBodyBuf)
}
resp, err = t.transport.RoundTrip(req)
if err != nil {
return resp, err
}
roundTripped = true
}
if resp != nil && resp.StatusCode == http.StatusForbidden {
// the DEP simulator depsim showed this specific 403 Forbidden
// "FORBIDDEN" error when you restart the simulator. this indicates,
// I think, an expired/unknown session token but this isn't documented
// for the DEP service. specifically test and handle this error so we
// do not accidentally capture any other 403 errors (e.g. T&C).
// unfortunately this means reading (and replacing) the body, which is
// rather verbose.
respBodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return resp, fmt.Errorf("transport: reading response body: %w", err)
}
resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(respBodyBytes))
if bytes.Contains(respBodyBytes, []byte(bodyForbidden)) {
forbidden = true
}
}
if session == "" || resp.StatusCode == http.StatusUnauthorized || forbidden {
// either we have no session token yet or the DEP server doesn't like
// our provided token. let's authenticate.
tokens, err := t.tokens.RetrieveAuthTokens(req.Context(), name)
if err != nil {
return nil, fmt.Errorf("transport: retrieving auth tokens: %w", err)
}
// assemble the /session URL from the original request "base" URL.
sessionURL := req.URL.ResolveReference(t.sessionURL)
sessionReq, err := http.NewRequestWithContext(
req.Context(),
"GET",
sessionURL.String(),
nil,
)
if err != nil {
return nil, fmt.Errorf("transport: creating session request: %w", err)
}
// use the same version header from the original request (which we
// likely set ourselves anyway)
sessionReq.Header.Set(
ServerProtocolVersion,
req.Header.Get(ServerProtocolVersion),
)
session, err = DoAuth(t.client, sessionReq, tokens)
if err != nil {
return nil, err
}
// save our session token for use by following requests
err = t.sessions.SetSessionToken(req.Context(), name, session)
if err != nil {
return nil, fmt.Errorf("transport: setting auth session token: %w", err)
}
// now that we've received and saved the session token let's use it
// to actually make the (same) request.
req.Header.Set(ADMAuthSession, session)
// reset our body reader if needed
if roundTripped && req.Body != nil {
if req.GetBody != nil {
// (ab)use the 304 redirect body cache if present
req.Body, err = req.GetBody()
if err != nil {
return nil, err
}
} else if reqBodyBuf != nil {
req.Body = io.NopCloser(reqBodyBuf)
}
}
resp, err = t.transport.RoundTrip(req)
if err != nil {
return resp, err
}
}
// check if the session token has changed. Apple says that the session
// token can be updated from the server. save it if so.
if respSession := resp.Header.Get(ADMAuthSession); respSession != "" && session != respSession {
err = t.sessions.SetSessionToken(req.Context(), name, respSession)
if err != nil {
return nil, fmt.Errorf("transport: setting response session token: %w", err)
}
}
return resp, nil
}

View File

@ -0,0 +1,133 @@
package main
import (
"flag"
"fmt"
stdlog "log"
"math/rand"
"net/http"
"os"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
dephttp "github.com/fleetdm/fleet/v4/server/mdm/nanodep/http"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/http/api"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log/stdlogfmt"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/parse"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/proxy"
)
// overridden by -ldflags -X
var version = "unknown"
const (
apiUsername = "depserver"
endpointVersion = "/version"
endpointTokens = "/v1/tokens/" //nolint:gosec
endpointConfig = "/v1/config/"
endpointTokenPKI = "/v1/tokenpki/" //nolint:gosec
endpointAssigner = "/v1/assigner/"
endpointProxy = "/proxy/"
)
func main() {
var (
flDebug = flag.Bool("debug", false, "log debug messages")
flListen = flag.String("listen", ":9001", "HTTP listen address")
flAPIKey = flag.String("api", "", "API key for API endpoints")
flVersion = flag.Bool("version", false, "print version")
flStorage = flag.String("storage", "file", "storage backend")
flDSN = flag.String("storage-dsn", "", "storage data source name")
)
flag.Parse()
if *flVersion {
fmt.Println(version)
return
}
if *flAPIKey == "" {
fmt.Fprintf(flag.CommandLine.Output(), "empty API key\n")
flag.Usage()
os.Exit(1)
}
logger := stdlogfmt.New(stdlog.Default(), *flDebug)
storage, err := parse.Storage(*flStorage, *flDSN)
if err != nil {
logger.Info("msg", "creating storage backend", "err", err)
os.Exit(1)
}
mux := http.NewServeMux()
mux.Handle(endpointVersion, dephttp.VersionHandler(version))
handleStrippedAPI := func(handler http.Handler, endpoint string) {
handler = http.StripPrefix(endpoint, handler)
handler = dephttp.BasicAuthMiddleware(handler, apiUsername, *flAPIKey, "depserver")
mux.Handle(endpoint, handler)
}
tokensMux := dephttp.NewMethodMux()
tokensMux.Handle("PUT", api.StoreAuthTokensHandler(storage, logger.With("handler", "store-auth-tokens")))
tokensMux.Handle("GET", api.RetrieveAuthTokensHandler(storage, logger.With("handler", "retrieve-auth-tokens")))
handleStrippedAPI(tokensMux, endpointTokens)
configMux := dephttp.NewMethodMux()
configMux.Handle("GET", api.RetrieveConfigHandler(storage, logger.With("handler", "retrieve-config")))
configMux.Handle("PUT", api.StoreConfigHandler(storage, logger.With("handler", "store-config")))
handleStrippedAPI(configMux, endpointConfig)
tokenPKIMux := dephttp.NewMethodMux()
tokenPKIMux.Handle("GET", api.GetCertTokenPKIHandler(storage, logger.With("handler", "get-token-pki")))
tokenPKIMux.Handle("PUT", api.DecryptTokenPKIHandler(storage, storage, logger.With("handler", "put-token-pki")))
handleStrippedAPI(tokenPKIMux, endpointTokenPKI)
assignerMux := dephttp.NewMethodMux()
assignerMux.Handle("GET", api.RetrieveAssignerProfileHandler(storage, logger.With("handler", "retrieve-assigner-profile")))
assignerMux.Handle("PUT", api.StoreAssignerProfileHandler(storage, logger.With("handler", "store-assigner-profile")))
handleStrippedAPI(assignerMux, endpointAssigner)
p := proxy.New(
client.NewTransport(http.DefaultTransport, http.DefaultClient, storage, nil),
storage,
logger.With("component", "proxy"),
)
var proxyHandler http.Handler = proxy.ProxyDEPNameHandler(p, logger.With("handler", "proxy"))
proxyHandler = http.StripPrefix(endpointProxy, proxyHandler)
proxyHandler = DelHeaderMiddleware(proxyHandler, "Authorization")
proxyHandler = dephttp.BasicAuthMiddleware(proxyHandler, apiUsername, *flAPIKey, "depserver")
mux.Handle(endpointProxy, proxyHandler)
// init for newTraceID()
rand.Seed(time.Now().UnixNano())
logger.Info("msg", "starting server", "listen", *flListen)
err = http.ListenAndServe(*flListen, dephttp.TraceLoggingMiddleware(mux, logger.With("handler", "log"), newTraceID)) //nolint:gosec
logs := []interface{}{"msg", "server shutdown"}
if err != nil {
logs = append(logs, "err", err)
}
logger.Info(logs...)
}
// newTraceID generates a new HTTP trace ID for context logging.
// Currently this just makes a random string. This would be better
// served by e.g. https://github.com/oklog/ulid or something like
// https://opentelemetry.io/ someday.
func newTraceID() string {
b := make([]byte, 8)
rand.Read(b) //nolint:gosec
return fmt.Sprintf("%x", b)
}
// DelHeaderMiddleware deletes header from the HTTP request headers before calling h.
func DelHeaderMiddleware(h http.Handler, header string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Header.Del(header)
h.ServeHTTP(w, r)
}
}

View File

@ -0,0 +1,194 @@
package main
import (
"context"
"flag"
"fmt"
stdlog "log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log/stdlogfmt"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/parse"
depsync "github.com/fleetdm/fleet/v4/server/mdm/nanodep/sync"
)
// overridden by -ldflags -X
var version = "unknown"
const defaultDuration = 30 * time.Minute
func main() {
var (
flVersion = flag.Bool("version", false, "print version")
flDur = flag.Uint("duration", uint(defaultDuration/time.Second), "duration in seconds between DEP syncs (0 for single sync)")
flLimit = flag.Int("limit", 0, "limit fetch and sync calls to this many devices (0 for server default)")
flDebug = flag.Bool("debug", false, "log debug messages")
flADebug = flag.Bool("debug-assigner", false, "additional debug logging of the device assigner")
flStorage = flag.String("storage", "file", "storage backend")
flDSN = flag.String("storage-dsn", "", "storage data source name")
flWebhook = flag.String("webhook-url", "", "URL to send requests to")
)
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [flags] <DEPname1> [DEPname2 [...]]\nFlags:\n", os.Args[0])
flag.PrintDefaults()
}
flag.Parse()
if *flVersion {
fmt.Println(version)
return
}
if len(flag.Args()) < 1 {
fmt.Fprintf(flag.CommandLine.Output(), "no DEP names provided\n")
flag.Usage()
os.Exit(1)
}
logger := stdlogfmt.New(stdlog.Default(), *flDebug)
storage, err := parse.Storage(*flStorage, *flDSN)
if err != nil {
logger.Info("msg", "creating storage backend", "err", err)
os.Exit(1)
}
var webhook *Webhook
if *flWebhook != "" {
webhook = NewWebhook(*flWebhook)
}
ctx, cancelCtx := context.WithCancel(context.Background())
// we keep an array of channels to broadcast our syncnow signal
// for each DEP name we're running a syncer for
var (
syncNows []chan<- struct{}
syncNowsMu sync.RWMutex
)
registerSyncNow := func(c chan<- struct{}) {
defer syncNowsMu.Unlock()
syncNowsMu.Lock()
syncNows = append(syncNows, c)
}
closeSyncNow := func(c chan<- struct{}) {
defer close(c)
defer syncNowsMu.Unlock()
syncNowsMu.Lock()
for i, syncNow := range syncNows {
if syncNow == c {
// remove c from list by replace-and-truncate
syncNows[i] = syncNows[len(syncNows)-1]
syncNows = syncNows[:len(syncNows)-1]
}
}
}
sendSyncNows := func() {
defer syncNowsMu.RUnlock()
syncNowsMu.RLock()
for _, syncNow := range syncNows {
go func(c chan<- struct{}) { c <- struct{}{} }(syncNow)
}
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGHUP, os.Interrupt, syscall.SIGTERM)
// signal handler
go func() {
for {
sig := <-signals
logger.Debug("msg", "signal received", "signal", sig)
switch sig {
case syscall.SIGHUP:
sendSyncNows()
case os.Interrupt, syscall.SIGTERM:
cancelCtx()
}
}
}()
client := godep.NewClient(storage, http.DefaultClient)
var wg sync.WaitGroup
for _, name := range flag.Args()[0:] {
// create the assigner
assignerOpts := []depsync.AssignerOption{
depsync.WithAssignerLogger(logger.With("component", "assigner")),
}
if *flADebug {
assignerOpts = append(assignerOpts, depsync.WithDebug())
}
assigner := depsync.NewAssigner(
client,
name,
storage,
assignerOpts...,
)
// create the callback (that calls the assigner and webhook)
callback := func(ctx context.Context, isFetch bool, resp *godep.DeviceResponse) error {
go func() {
err := assigner.ProcessDeviceResponse(ctx, resp)
if err != nil {
logger.Info("msg", "assigner process device response", "err", err)
}
}()
if webhook != nil {
go func() {
err := webhook.CallWebhook(ctx, name, isFetch, resp)
if err != nil {
logger.Info("msg", "calling webhook", "err", err)
}
}()
}
return nil
}
syncNow := make(chan struct{})
registerSyncNow(syncNow)
// create the syncer
syncerOpts := []depsync.SyncerOption{
depsync.WithLogger(logger.With("component", "syncer")),
depsync.WithSyncNow(syncNow),
depsync.WithCallback(callback),
}
if *flDur > 0 {
syncerOpts = append(syncerOpts, depsync.WithDuration(time.Duration(*flDur)*time.Second))
}
if *flLimit > 0 {
syncerOpts = append(syncerOpts, depsync.WithLimit(*flLimit))
}
syncer := depsync.NewSyncer(
client,
name,
storage,
syncerOpts...,
)
// start the syncer
wg.Add(1)
go func() {
defer wg.Done()
defer closeSyncNow(syncNow)
err = syncer.Run(ctx)
if err != nil {
logger.Info("msg", "syncer run", "err", err)
}
}()
}
wg.Wait()
}

View File

@ -0,0 +1,74 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
)
// Event is a MicroMDM webhook-ish JSON structure.
// See https://github.com/micromdm/micromdm/blob/main/docs/user-guide/api-and-webhooks.md
type Event struct {
Topic string `json:"topic"`
EventID string `json:"event_id"`
CreatedAt time.Time `json:"created_at"`
DeviceResponseEvent *DeviceResponseEvent `json:"device_response_event,omitempty"`
}
// DeviceResponseEvent represents an event for a DEP sync or fetch response.
type DeviceResponseEvent struct {
DEPName string `json:"dep_name"`
DeviceResponse *godep.DeviceResponse `json:"device_response,omitempty"`
}
// Webhook is a service that calls back to a URL with a JSON presentation of
// the DEP sync or fetch response.
type Webhook struct {
url string
client *http.Client
}
// NewWebhook creates a new Webhook.
func NewWebhook(url string) *Webhook {
return &Webhook{url: url, client: http.DefaultClient}
}
// CallWebhook assembles the JSON body from name, isFetch, and resp and calls
// to the configured service URL.
func (w *Webhook) CallWebhook(ctx context.Context, name string, isFetch bool, resp *godep.DeviceResponse) error {
topic := "dep.SyncDevices"
if isFetch {
topic = "dep.FetchDevices"
}
event := &Event{
Topic: topic,
CreatedAt: time.Now(),
DeviceResponseEvent: &DeviceResponseEvent{
DEPName: name,
DeviceResponse: resp,
},
}
body, err := json.Marshal(event)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.url, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
httpResp, err := w.client.Do(req)
if err != nil {
return err
}
if httpResp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected HTTP status: %s", httpResp.Status)
}
return nil
}

View File

@ -0,0 +1,155 @@
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"flag"
"fmt"
"os"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
)
const (
defaultCN = "deptokens"
defaultDays = 1
)
// overridden by -ldflags -X
var version = "unknown"
func main() {
var (
flCert = flag.String("cert", "cert.pem", "path to certificate")
flKey = flag.String("key", "cert.key", "path to key")
flPassword = flag.String("password", "", "password to encrypt/decrypt private key with")
flTokens = flag.String("token", "", "path to tokens")
flForce = flag.Bool("f", false, "force overwriting the keypair")
flVersion = flag.Bool("version", false, "print version")
)
flag.Parse()
if *flVersion {
fmt.Println(version)
return
}
var err error
if *flTokens == "" {
if *flPassword == "" {
fmt.Println("WARNING: no password provided, private key will be saved in clear text")
}
err = generateKeyPair(*flCert, *flKey, *flPassword, *flForce)
if err == nil {
fmt.Printf("wrote %s, %s\n", *flCert, *flKey)
}
} else {
var jsonBytes []byte
jsonBytes, err = decryptTokens(*flTokens, *flCert, *flKey, *flPassword)
if err == nil {
os.Stdout.Write(jsonBytes)
}
}
if err != nil {
fmt.Printf("error: %v\n", err)
os.Exit(1)
}
}
// encodeEncryptedKeyPEM generates a PEM structure for key optionally
// encrypting it with password.
func encodeEncryptedKeyPEM(key *rsa.PrivateKey, password string) ([]byte, error) {
keyBytes := x509.MarshalPKCS1PrivateKey(key)
var block *pem.Block
if password == "" {
block = &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: keyBytes,
}
} else {
var err error
block, err = x509.EncryptPEMBlock(rand.Reader, "RSA PRIVATE KEY", keyBytes, []byte(password), x509.PEMCipher3DES)
if err != nil {
return nil, err
}
}
return pem.EncodeToMemory(block), nil
}
// decodeEncryptedKeyPEM decodes an private key in pemBytes optionally
// decrypting it with password.
func decodeEncryptedKeyPEM(pemBytes []byte, password string) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(pemBytes)
if block.Type != "RSA PRIVATE KEY" {
return nil, errors.New("PEM type is not RSA PRIVATE KEY")
}
keyBytes := block.Bytes
if x509.IsEncryptedPEMBlock(block) {
if password == "" {
return nil, errors.New("no password supplied for encrypted PEM")
}
var err error
keyBytes, err = x509.DecryptPEMBlock(block, []byte(password))
if err != nil {
return nil, err
}
}
return x509.ParsePKCS1PrivateKey(keyBytes)
}
// generateKeyPair creates and saves a keypair checking whether they exist first.
func generateKeyPair(certFile, keyFile, password string, force bool) error {
if !force {
_, err := os.Stat(certFile)
certExists := err == nil
_, err = os.Stat(keyFile)
keyExists := err == nil
if keyExists || certExists {
return errors.New("cert or key already exist, not overwriting")
}
}
key, cert, err := tokenpki.SelfSignedRSAKeypair(defaultCN, defaultDays)
if err != nil {
return fmt.Errorf("generating keypair: %w", err)
}
err = os.WriteFile(certFile, tokenpki.PEMCertificate(cert.Raw), 0644)
if err != nil {
return fmt.Errorf("writing cert: %w", err)
}
keyPEM, err := encodeEncryptedKeyPEM(key, password)
if err == nil {
err = os.WriteFile(keyFile, keyPEM, 0600)
}
if err != nil {
return fmt.Errorf("writing key: %w", err)
}
return nil
}
// decryptTokens reads tokenFile from disk and decrypts it using certFile and keyfile (with optional password).
func decryptTokens(tokenFile, certFile, keyFile, password string) ([]byte, error) {
keyBytes, err := os.ReadFile(keyFile)
if err != nil {
return nil, err
}
key, err := decodeEncryptedKeyPEM(keyBytes, password)
if err != nil {
return nil, err
}
tokenBytes, err := os.ReadFile(tokenFile)
if err != nil {
return nil, err
}
certBytes, err := os.ReadFile(certFile)
if err != nil {
return nil, err
}
cert, err := tokenpki.CertificateFromPEM(certBytes)
if err != nil {
return nil, err
}
return tokenpki.DecryptTokenJSON(tokenBytes, cert, key)
}

View File

@ -0,0 +1,22 @@
{
"profile_name": "(Required) Human readable name",
"url": "https://mymdm.example.org/mdm/enroll",
"allow_pairing": true,
"auto_advance_setup": false,
"await_device_configured": false,
"configuration_web_url": "(Optional) sso.example.com/?redirect=enroll",
"department": "(Optional) support@example.com",
"is_supervised": false,
"is_multi_user": false,
"is_mandatory": false,
"is_mdm_removable": true,
"language": "(Optional) en",
"org_magic": "(Optional)",
"region": "(Optional) US",
"support_phone_number": "(Optional) +1 408 555 1010",
"support_email_address": "(Optional) support@example.com",
"anchor_certs": [],
"supervising_host_certs": [],
"skip_setup_items": ["AppleID", "Android"],
"devices": ["SERIAL1","SERIAL2"]
}

View File

@ -0,0 +1,287 @@
openapi: 3.0.0
info:
version: 0.1.0
title: NanoDEP depserver API
servers:
- url: http://[::1]:9001/
paths:
/version:
get:
description: Returns the running NanoDEP depserver version
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
properties:
version:
type: string
example: "v0.1.0"
/v1/assigner/{name}:
get:
description: Return the assigner profile UUID for the given DEP name.
security:
- basicAuth: []
responses:
'200':
description: Assigner profile UUID corresponding to the DEP name.
content:
application/json:
schema:
$ref: '#/components/schemas/AssignerProfileUUID'
'401':
$ref: '#/components/responses/UnauthorizedError'
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/JSONAPIError'
put:
description: Assign a profile UUID for assignment for the given DEP name.
security:
- basicAuth: []
responses:
'200':
description: The store assigner profile UUID corresponding to the DEP name.
content:
application/json:
schema:
$ref: '#/components/schemas/AssignerProfileUUID'
'401':
$ref: '#/components/responses/UnauthorizedError'
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/JSONAPIError'
parameters:
- $ref: '#/components/parameters/depName'
- in: query
name: profile_uuid
required: true
schema:
type: string
example: "48E4F9B0DB9B76F1"
/v1/config/{name}:
get:
description: Return the config for the given DEP name.
security:
- basicAuth: []
responses:
'200':
description: Config corresponding to the DEP name.
content:
application/json:
schema:
$ref: '#/components/schemas/Config'
'401':
$ref: '#/components/responses/UnauthorizedError'
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/JSONAPIError'
put:
description: Set the config for the given DEP name.
security:
- basicAuth: []
requestBody:
description: Config for the given DEP name.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Config'
responses:
'200':
description: Config corresponding to the DEP name.
content:
application/json:
schema:
$ref: '#/components/schemas/Config'
'401':
$ref: '#/components/responses/UnauthorizedError'
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/JSONAPIError'
parameters:
- $ref: '#/components/parameters/depName'
/v1/tokens/{name}:
get:
description: Return the DEP OAuth1 tokens for the given DEP name.
security:
- basicAuth: []
responses:
'200':
description: The DEP OAuth1 tokens for the given DEP name.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuth1Tokens'
'401':
$ref: '#/components/responses/UnauthorizedError'
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/JSONAPIError'
put:
description: Upload and store DEP OAuth1 tokens for the given DEP Name.
security:
- basicAuth: []
externalDocs:
description: Apple documentation describing the decrypted DEP server OAuth1 tokens.
url: https://developer.apple.com/documentation/devicemanagement/device_assignment/authenticating_with_a_device_enrollment_program_dep_server/examining_server_tokens
requestBody:
description: OAuth1 tokens.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OAuth1Tokens'
responses:
'200':
description: The parsed and stored OAuth1 tokens are returned.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuth1Tokens'
'401':
$ref: '#/components/responses/UnauthorizedError'
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/JSONAPIError'
parameters:
- $ref: '#/components/parameters/depName'
/v1/tokenpki/{name}:
get:
description: Generate and store a new X.509 certificate and RSA private key (keypair) for exchanging the encrypted DEP OAuth1 tokens via the Apple ABM/ASM/BE portal. Each request generates a new (and overwrites the existing) keypair. The certificate is returned.
security:
- basicAuth: []
responses:
'200':
description: X.509 certificate of the keypair used to encrypted the OAuth1 tokens.
headers:
Content-Disposition:
schema:
type: string
description: Suggested filename of (attachment) of certificate.
content:
application/x-pem-file:
schema:
type: string
example: |-
-----BEGIN CERTIFICATE-----
MIIFdjCCBF6gAwIBAgIIZ7SjAeWsGIwwDQYJKoZIhvcNAQELBQAwgYwxQDA+BgNV
[..snip..]
lL5jy74l8Za59w==
-----END CERTIFICATE-----
'401':
$ref: '#/components/responses/UnauthorizedError'
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/JSONAPIError'
put:
description: Decrypt the OAuth1 tokens from the Apple ABM/ASM/BE portal and store them.
security:
- basicAuth: []
requestBody:
description: The contents of the .p7m file that Apple provides on the ABM/ASM/BE portal after you've uploaded the public key certificate.
required: true
content:
application/pkcs7-mime:
schema:
type: string
example: |-
Content-Type: application/pkcs7-mime; name="smime.p7m"; smime-type=enveloped-data
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="smime.p7m"
Content-Description: S/MIME Encrypted Message
MIAGCSqGSIb3DQEHA6CAMIACAQAxggE1MIIBMQIBADAZMBQxEjAQBgNVBAMTCWRlcHNlcnZlcgIB
[..snip..]
ZZ4DvF5PZOQGA9R6pW0/L29ixfg8H8hPkXoJ7AkYI09sf4DMTzaesQAAAAAAAAAAAAA=
responses:
'200':
description: Newly decrypted OAuth1 tokens corresponding to the DEP name.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuth1Tokens'
'401':
$ref: '#/components/responses/UnauthorizedError'
'400':
$ref: '#/components/responses/BadRequest'
'500':
$ref: '#/components/responses/JSONAPIError'
parameters:
- $ref: '#/components/parameters/depName'
components:
parameters:
depName:
name: name
in: path
description: Name of DEP server instance
required: true
style: simple
schema:
type: string
example: 'mymdmserver'
securitySchemes:
basicAuth:
type: http
scheme: basic
responses:
UnauthorizedError:
description: API key is missing or invalid.
headers:
WWW-Authenticate:
schema:
type: string
BadRequest:
description: There was a problem with the supplied request. The request was in an incorrect format or other request data error.
JSONAPIError:
description: An error occured on this endpoint.
content:
application/json:
schema:
type: object
properties:
error:
type: string
example: "it was sunny outside"
schemas:
AssignerProfileUUID:
type: object
properties:
profile_uuid:
type: string
example: "48E4F9B0DB9B76F1"
Config:
type: object
properties:
base_url:
type: string
format: url
example: "http://127.0.0.1:8080/"
description: The base URL of the Apple Device Assignment Services server to call out to. Typically only overridden when talking to another DEP server such as the `depsim` simulator.
OAuth1Tokens:
type: object
properties:
consumer_key:
type: string
example: "CK_48dd68d198350f51258e885ce9a5c37ab7f98543c4a697323d75682a6c10a32501cb247e3db08105db868f73f2c972bdb6ae77112aea803b9219eb52689d42e6"
consumer_secret:
type: string
example: "CS_34c7b2b531a600d99a0e4edcf4a78ded79b86ef318118c2f5bcfee1b011108c32d5302df801adbe29d446eb78f02b13144e323eb9aad51c79f01e50cb45c3a68"
access_token:
type: string
example: "AT_927696831c59ba510cfe4ec1a69e5267c19881257d4bca2906a99d0785b785a6f6fdeb09774954fdd5e2d0ad952e3af52c6d8d2f21c924ba0caf4a031c158b89"
access_secret:
type: string
example: "AS_c31afd7a09691d83548489336e8ff1cb11b82b6bca13f793344496a556b1f4972eaff4dde6deb5ac9cf076fdfa97ec97699c34d515947b9cf9ed31c99dded6ba"
access_token_expiry:
type: string
format: date-time
example: "2023-06-01T05:59:16Z"

View File

@ -0,0 +1,564 @@
# NanoDEP Operations Guide
This is a brief overview of the various tools and utilities for working with NanoDEP.
## DEP names
NanoDEP supports configuring multiple DEP "MDM servers." These different DEP "MDM servers" are referenced by an arbitrary name string that you specify. This string is used to both configure the DEP connection (like authentication) as well to reference these configuration for actually talking to the Apple DEP API endpoints.
Note that because the name string is used pervasively in URL API paths you probably want to avoid names that include things like forward-slashes "/", spaces, or anything else really that might have trouble in URLs.
## depserver
The `depserver` serves two main purposes:
1. Setup & configuration of the DEP name(s) — that is, the locally-named instances that correspond to the DEP "MDM servers" in the Apple Business Manager (ABM), Apple School Manager (ASM), or Business Essentials (BE) portal. Configuration includes uploading the DEP authentication tokens, configuring the assigner, etc. See the "API endpoints" section below for more.
1. Accessing the actual DEP APIs using a transparently-authenticating reverse proxy. After you've configured the authentication tokens using the above APIs `depserver` provides a reverse proxy to talk to the Apple DEP endpoints where you don't have to worry about session management or token authentication: this's taken care of for you. All you need to do is use a special URL path and normal API (HTTP Basic) authentication and you can talk to the DEP APIs unfiltered. See the "Reverse proxy" section below for more.
### Switches
Command line switches for the `depserver` tool.
#### -api string
* API key for API endpoints
Required. API authentication in NanoDEP is simply HTTP Basic authentication using "depserver" as the username and the API key (from this switch) as the password.
#### -debug
* log debug messages
Enable additional debug logging.
#### -listen string
* HTTP listen address (default ":9001")
Specifies the listen address (interface and port number) for the server to listen on.
#### -storage & -storage-dsn
The `-storage` and `-storage-dsn` flags together configure the storage backend. `-storage` specifies the name of backend type while `-storage-dsn` specifies the backend data source name (e.g. the connection string). If no `-storage` backend is specified then `file` is used as a default.
##### file storage backend
* `-storage file`
Configure the `file` storage backend. This backend manages DEP authentication and configuration data within plain filesystem files and directories. It has zero dependencies and should run out of the box. The `-storage-dsn` flag specifies the filesystem directory for the database. If no `storage-dsn` is specified then `db` is used as a default.
*Example:* `-storage file -storage-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 the storage tables with the [schema.sql](../storage/mysql/schema.sql) file. MySQL 8.0.19 or later is required.
*Example:* `-storage mysql -dsn nanodep:nanodep/mydepdb`
#### -version
* print version
Print version and exit.
### API endpoints
API endpoints for getting and setting the configuration of DEP names. Note that you don't need to use these APIs directly — NanoDEP provides a set of tools and scripts for working with some of these endpoints — see the "Tools and scripts" section, below. Most of the endpoints require specifying the "DEP name" (see above) in the `{name}` part of the URL (without the curly braces, of course).
A brief overview of the endpoints is provided here. For detailed API semantics please see the [OpenAPI documentation for NanoDEP](https://www.jessepeterson.space/swagger/nanodep.html). The OpenAPI source YAML is a part of this project.
#### Version
* Endpoint: `GET /version`
Returns a JSON response with the version of the running NanoDEP server.
#### Token PKI
* Endpoint: `GET, PUT /v1/tokenpki/{name}`
The `/v1/tokenpki/{name}` endpoints deal with the public key exchange using the Apple ABM/ASM/BE portal for acquiring the authentication tokens for talking to the DEP API. For example usage please see the `./tools/cfg-get-cert.sh` and `./tools/cfg-decrypt-tokens.sh` scripts. These scripts are talked about under section "Tools and scripts" below.
#### Tokens
* Endpoint: `GET, PUT /v1/tokens/{name}`
The `/v1/tokens/{name} ` endpoints deal with the raw DEP OAuth tokens in JSON form. I.e. after the PKI exchange you can query for the actual DEP OAuth tokens if you like. This also allows configuring the OAuth1 tokens for a DEP name if you already have the tokens in JSON format. I.e. if you used the `deptokens` tool or you're using the DEP simulator `depsim`.
#### Assigner
* Endpoint: `GET, PUT /v1/assigner/{name}`
The `/v1/assigner/{name}` endpoints deal with storing and retrieving the assigner profile UUID. This is used for the assigner tool `depsyncer` (see below for documentation on that tool). For example usage please see the `./tools/cfg-set-assigner.sh` script. This script is talked about under section "Tools and scripts" below.
#### Config
* Endpoint: `GET, PUT /v1/config/{name}`
The `/v1/config/{name}` endpoints deal with storing and retrieving configuration for a given DEP name. At this time the only configuration available is the base URL of the DEP name. This is really only useful when talking to the DEP simulator `depsim` or perhaps directing DEP server requests through another reverse proxy.
### Reverse proxy
In addition to individually handling some of various Apple DEP API endpoints in its `godep` library NanoDEP provides a transparently-authenticating HTTP reverse proxy to the Apple DEP servers. This allows us to simply provide `depserver` with the Apple DEP endpoint, the NanoDEP "DEP name" and the API key, and we can talk to any of the Apple DEP endpoint APIs (including the Roster, Class, and People Management). `depserver` will authenticate to the Apple DEP server and keep track of session management transparently behind the scenes. To be clear: this means you do not have to call to the `/session` endpoint to authenticate nor to manage and update the session tokens with each request. NanoDEP does this for you.
The proxy URL is accessible as: `/proxy/{name}/endpoint` where `/endpoint` is the Apple DEP API endpoint you want to access. The proxy will automatically translate this URL to ``https://mdmenrollment.apple.com/endpoint` and use `{name}` for retrieving the DEP authentication tokens. Note that in some cases, for some endpoints, various HTTP headers are added or removed:
* For any proxy request the API authentication header is removed before passing to the underlying DEP server.
* If not provided in the incoming HTTP request the DEP header `X-Server-Protocol-Version` is set to a default (currently "3").
* For the `/session` endpoint we use a default `Content-Type`. However because NanoDEP handles authentication for you, you shouldn't have to worry about this (or even need to call to the `/session` endpoint).
Note that for simple cases you don't need to use this proxy directly — NanoDEP provides a set of tools and scripts for working with some of the DEP endpoints — see the "Tools and scripts" section, below.
#### Example usage
You can see this example alternatively as `./tools/dep-account-detail.sh` under the "Tools and scripts" section, below, but we'll duplicate it for illustrative purposes:
```bash
% curl -v -u depserver:supersecret 'http://[::1]:9001/proxy/mdmserver1/account'
* Trying ::1...
* TCP_NODELAY set
* Connected to ::1 (::1) port 9001 (#0)
* Server auth using Basic with user 'depserver'
> GET /proxy/mdmserver1/account HTTP/1.1
> Host: [::1]:9001
> Authorization: Basic ZGVwc2VydmVyOnN1cGVyc2VjcmV0
> User-Agent: curl/7.64.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Length: 321
< Content-Type: application/json;charset=UTF8
< Date: 2022-07-04T15:06:54-07:00
< X-Adm-Auth-Session: 982B2965F9C9D9672EDA4BAF7902755657480328585B9871D1022898C04A3419BA24771734DB2031FF122E0E789EE347AF89E80EBDA521A429C2F90FE7F9031E
<
* Connection #0 to host ::1 left intact
{
"server_name": "Example Server",
"server_uuid": "677cab70-fe18-11e2-b778-0800200c9a66",
"facilitator_id": "facilitator@example.com",
"org_phone": "111-222-3333",
"org_name": "Example Inc",
"org_email": "orgadmin@example.com",
"org_address": "123 Main St. Anytown, USA",
"admin_id": "admin@example.com"
}
* Closing connection 0
```
This request was 'translated' from `GET /proxy/mdmserver1/account` to `GET /account` at the `https://mdmenrollment.apple.com` URL and authenticated using the `mdmserver1` DEP name (assuming it was already configured, of course). You can also see the returned `X-Adm-Auth-Session` header which contains the response session token (which you can ignore because NanoDEP handles dealing with this header under the hood).
## Tools and scripts
The NanoDEP project includes some tools and scripts that use the above APIs in `depserver` for performing some typical DEP device management tasks. These are basically just shell scripts that utilize `curl` and `jq` to drive the `depserver` API and/or Apple DEP API endpoints (and so, obviously, those tools are requirements for the scripts to work). These tools and scripts also have their own documentation under the `./tools` directory of the project as noted below.
Generally, the scripts are split into two types indicated by the script prefix:
* Scripts starting with `cfg-` configure `depserver` and its properties.
* Scripts starting with `dep-` use the Reverse Proxy described above to perform operations with the Apple DEP server.
### Scripts
These scripts require setting up a few environment variables before use. Please see the [tools](../tools) for more documentation. But generally you'll need to set these environment variables for the scripts to work:
```bash
# the URL of the running depserver
export BASE_URL='http://[::1]:9001'
# should match the -api switch of the depserver
export APIKEY=supersecret
# the DEP name (instance) you want to use
export DEP_NAME=mdmserver1
```
The [Quickstart Guide](quickstart.md) also documents some usage of these scripts, too.
#### cfg-get-cert.sh
For the DEP "MDM server" in the environment variable $DEP_NAME (see above) this script generates and retrieves the public key certificate for use when downloading the DEP authentication tokens from the ABM/ASM/BE portal. The `curl` call will dump the PEM-encoded certificate to stdout so you'll likely want to redirect it somewhere useful so it can be uploaded to the portal.
##### Example usage
```bash
$ ./tools/cfg-get-cert.sh > $DEP_NAME.pem
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 1001 100 1001 0 0 4509 0 --:--:-- --:--:-- --:--:-- 4509
$ head -2 $DEP_NAME.pem
-----BEGIN CERTIFICATE-----
MIICtTCCAZ2gAwIBAgIBATANBgkqhkiG9w0BAQsFADAUMRIwEAYDVQQDEwlkZXBz
```
#### cfg-decrypt-tokens.sh
For the DEP "MDM server" in the environment variable $DEP_NAME (see above) this script uploads the encrypted tokens that were downloaded from the ABM/ASM/BE portal to `depserver` where it is decrypted and the resulting OAuth tokens stored with the MDM instance.
**The first argument is required** and specifies the path to the token file downloaded from the Apple portal.
##### Example usage
```bash
$ ./cfg-decrypt-tokens.sh ~/Downloads/mdmserver1_Token_2022-07-01T22-18-53Z_smime.p7m
{"consumer_key":"CK_9af2f8218b150c351ad802c6f3d66abe","consumer_secret":"CS_9af2f8218b150c351ad802c6f3d66abe","access_token":"AT_9af2f8218b150c351ad802c6f3d66abe","access_secret":"AS_9af2f8218b150c351ad802c6f3d66abe","access_token_expiry":"2023-07-01T22:18:53Z"}
```
#### dep-account-detail.sh
For the DEP "MDM server" in the environment variable $DEP_NAME (see above) this script queries the DEP API [Get Account Detail](https://developer.apple.com/documentation/devicemanagement/get_account_detail) endpoint and returns the data.
##### Example usage
```bash
$ ./dep-account-detail.sh
{
"server_name": "Example Server",
"server_uuid": "677cab70-fe18-11e2-b778-0800200c9a66",
"facilitator_id": "facilitator@example.com",
"org_phone": "111-222-3333",
"org_name": "Example Inc",
"org_email": "orgadmin@example.com",
"org_address": "123 Main St. Anytown, USA",
"admin_id": "admin@example.com"
}
```
#### dep-define-profile.sh
For the DEP "MDM server" in the environment variable $DEP_NAME (see above) this script uploads a [DEP profile](https://developer.apple.com/documentation/devicemanagement/profile) in JSON form to the Apple DEP API [Define A Profile](https://developer.apple.com/documentation/devicemanagement/define_a_profile) endpoint. Some important notes:
* **The first argument is required** and specifies the path to a DEP profile JSON file. We provide a sample DEP profile in the [docs](../docs) of the NanoMDM project to get you started.
* *You will need to (possibly heavily) modify this example* including MDM server URL, adding or removing optional parameters, devices serial numbers to assign to, etc. See the Apple [DEP profile](https://developer.apple.com/documentation/devicemanagement/profile) documentation and test extensively. Note some properties in the profile are mutually exclusive and the DEP service doesn't always given good feedback. Trial and error is sometimes need to get your first DEP profile uploaded successfully.
* You can directly include `devices` key in the JSON here to assign this profile *during this operation* to those devices. This means you can skip a separate device assign step which would be required.
* Once uploaded to Apple the profile will have a UUID associated with it. This identifies this exact uploaded profile to Apple for future reference. You may want to note this profile UUID if, for example, you want to use it to automatically assign devices with the `depsyncer` tool.
##### Example usage
```bash
$ ./dep-define-profile.sh ../docs/dep-profile.example.json
{
"profile_uuid": "43277A13FBCA0CFC",
"devices": {
"07AAD449616F566C12": "SUCCESS"
}
}
```
#### dep-device-details.sh
For the DEP "MDM server" in the environment variable $DEP_NAME (see above) this script queries the Apple DEP API [Get Device Details](https://developer.apple.com/documentation/devicemanagement/get_device_details) endpoint for a given serial number.
**The first argument is required** and specifies the serial number of the device you want to query.
Note that the API itself supports querying multiple devices at a time if you're able to assemble the appropriate JSON. This script only supports one serial number, however.
##### Example usage
```bash
$ ./dep-device-details.sh 07AAD449616F566C12
{
"devices": {
"07AAD449616F566C12": {
"serial_number": "07AAD449616F566C12",
"profile_uuid": "43277A13FBCA0CFC",
...
```
#### dep-get-profile.sh
For the DEP "MDM server" in the environment variable $DEP_NAME (see above) this script queries the Apple DEP API [Get a Profile](https://developer.apple.com/documentation/devicemanagement/get_a_profile) endpoint for a given DEP Profile UUID.
**The first argument is required** and specifies the UUID of the profile that was previously defined via the API.
#####
```bash
$ ./dep-get-profile.sh 43277A13FBCA0CFC
{
"profile_uuid": "43277A13FBCA0CFC",
...
```
#### cfg-set-assigner.sh
For the DEP "MDM server" in the environment variable $DEP_NAME (see above) this script saves the 'assigner' profile UUID in the `depserver` storage backend. This is the profile UUID that the automatic DEP profile assigner in the `depsyncer` tool uses to assign serial numbers to as it syncs new devices. By itself this command doesn't actually assign profiles to anything — it only *configures* the assigner profile UUID. The endpoint responds with the profile UUID in JSON. See the `depsyncer` tool documentation for more information.
**The first argument is required** and specifies the UUID of the profile that `depsyncer` will use to automatically assign serial numbers to.
##### Example usage
```bash
$ ./cfg-set-assigner.sh 43277A13FBCA0CFC
{"profile_uuid":"43277A13FBCA0CFC"}
```
#### dep-remove-profile.sh
For the DEP "MDM server" in the environment variable $DEP_NAME (see above) this script calls to the Apple DEP API [Remove a Profile](https://developer.apple.com/documentation/devicemanagement/remove_a_profile-c2c) endpoint to remove a serial number from being assigned to a DEP profile UUID. Note this is **NOT** the [disown](https://developer.apple.com/documentation/devicemanagement/disown_devices) endpoint and profiles can be re-assigned at any time after using this script.
**The first argument is required** and specifies the serial number of the device to remove DEP profile assignment from.
Note that the API itself supports un-assigning multiple devices at a time if you're able to assemble the appropriate JSON. This script only supports one serial number, however.
##### Example usage
```bash
$ ./dep-remove-profile.sh 07AAD449616F566C12
{
"devices": {
"07AAD449616F566C12": "SUCCESS"
}
}
```
### Troubleshooting
Sometimes something goes wrong with the API or the scripts. Sometimes the API will tell you exactly the problem you have and how you can fix the input. Other times you may need to inspect the HTTP request details. To do that you can turn on `curl` verbose output by setting the `CURL_OPTS` environment variable which all the scripts utilize:
```bash
export CURL_OPTS=-v
```
And then run the script again. This should give detailed HTTP response data including headers, etc.
## depsyncer
`depsyncer` is a stand-alone tool for syncing devices from the Apple DEP service. It operates by continuously syncing the list of the devices from the Apple DEP "MDM server" configurations. `depsyncer` can optionally assign DEP profiles to newly added devices as it syncs devices. `depsyncer` can also optionally send a webhook HTTP call to a webserver with the synced device information.
Note that `depsyncer` does not itself save any of the synced device information. The synced devices are either assigned a DEP profile or sent off to a webhook URL — ostensibly for any custom processing or saving to databases or such.
### Assignment
`depsyncer` can optionally assign DEP profiles to newly added devices as it syncs them. For each set of synced devices the auto-assigner will read the storage backend's configured assigner profile UUID for the given DEP name and attempt to assign the devices to it as they are synced.
You can set the assigner profile UUID using either the `./tools/cfg-set-assigner.sh` script (which talks to `depserver`) or using the `depserver` API endpoint `/v1/assigner/{name}` directly. See above for documentation on either of these options. The assigner can be set or changed at any time — even if `depsyncer` has already started: it reads the profile UUID every sync cycle. Note also that the assigner profile UUID applies only to the specific associated DEP name.
### Usage
At minimum you must specify at least one DEP name to start syncing devices from:
```bash
$ ./depsyncer-darwin-amd64 -h
Usage: ./depsyncer-darwin-amd64 [flags] <DEPname1> [DEPname2 [...]]
Flags:
...
```
Other than the switches (flags) documented below you just specify the DEP names that you'd like to sync (and assign) devices from. Multiple syncers will start up for each DEP name provided.
Examples in the "Example usage" section are below.
## Signals
When run in "continuous" mode (the default) `depsyncer` waits for a duration between syncing devices. During this wait period you can request an explicit sync by sending the `depsyncer` process a Signal hangup (SIGHUP). For example if your system has the `killall` command and your `depsyncer` binary is called `depsyncer-darwin-amd64` you could:
```bash
$ killall -SIGHUP depsyncer-darwin-amd64
```
You should then see in the running `depsyncer` process:
```bash
2022/07/06 15:40:14 level=debug component=syncer name=depsim msg=device sync: explicit sync requested
```
Whereby the next sync should be immediately started. Naturally signal handling is OS dependent and so this feature will not work on Microsoft Windows. `depsyncer` also tries to handle the Interrupt and Terminate (SIGTERM) signals to try to cleanly stop the syncer(s) and shutdown the process.
### Switches (flags)
#### -debug
* log debug messages
Enable additional debug logging.
#### -debug-assigner
* additional debug logging of the device assigner
Enable extra debug logging for the device assigner component specifically.
#### -duration uint
* duration in seconds between DEP syncs (0 for single sync) (default 1800)
If `-duration` is 0 then `depsyncer` only performs a single sync (and assign) cycle for each provided DEP name and then exits ("sync once" mode). Because `depsyncer` saves the cursor provided by the Apple DEP API it knows how to pick up where it left off if it is run again.
If `-duration` is greater than 0 (the default) then `depsyncer` will never exit and continually run barring any errors ("continuous" mode). For each provided DEP name it will start an initial sync (and assign) cycle, then wait until the given duration has passed and start another sync cycle picking up where it left off.
In the "sync once" mode (duration of 0) `depsyncer` could be run from, say, a cron job or other task schedular. Note the sync is technically more efficient when run in "continuous" mode, API-wise, as it skips the "fetch" step once it has been completed once during each startup. Of course this could be offset by the lower resource utilization or greater flexibility of using the "sync once" mode.
#### -limit int
* limit fetch and sync calls to this many devices (0 for server default)
The limit flag specifies how many devices to fetch at a time from the Apple DEP API. [Apple's documentation](https://developer.apple.com/documentation/devicemanagement/syncdevicerequest) says there is a server-side default of 100 an upper limit of 1000.
#### -storage & -storage-dsn
See the "-storage & -storage-dsn" section, above, for `depserver`. The syntax and capabilities are the same.
#### -version
* print version
Print version and exit.
#### -webhook-url string
* URL to send requests to
For each synced set of devices `depsyncer` supports sending the sync result to a webhook URL. This switch turns on the webhook and specifies the URL. This is somewhat compatible with the webhook support in NanoMDM as well as the [MicroMDM webhook](https://github.com/micromdm/micromdm/blob/main/docs/user-guide/api-and-webhooks.md).
##### Webhook data
The data is sent as an HTTP POST method with JSON data as the raw body. The JSON structure is similar to other open source webhook styles with a few differences:
* The top-level "topic" key will be a string of either `dep.SyncDevices` or `dep.FetchDevices` depending on the type of DEP API request used.
* The top-level "device_response_event" object will contain specific detail about this sync.
* The key "dep_name" corresponds to the NanoDEP DEP name from which devices were synced.
* The key "device_response" will be an object that corresponds to the Apple DEP API [FetchDeviceResponse](https://developer.apple.com/documentation/devicemanagement/fetchdeviceresponse) structure and includes the list of [Device](https://developer.apple.com/documentation/devicemanagement/device)(s) that were synced, if any.
With this information you could, for example, take device-specific actions by calling back into the `depserver` DEP APIs. For example to assign different DEP profiles depending on groups of serial numbers that you maintain or *not* assigning some serial numbers. It's all up to you with the DEP sync data provided.
##### Example data
Example JSON webhook body data:
```json
{
"topic": "dep.SyncDevices",
"event_id": "",
"created_at": "2022-07-08T01:17:52.778653-07:00",
"device_response_event": {
"dep_name": "mdmserver1",
"device_response": {
"cursor": "MTY1NzI2ODE5Ny0x",
"fetched_until": "0001-01-01T00:00:00Z",
"more_to_follow": false,
"devices": [
{
"serial_number": "07AAD449616F566C12",
"op_type": "added",
...
}
]
}
}
}
```
### Example usage
For the simplest invocation you can start `depsyncer` with is just a DEP name:
```bash
$ ./depsyncer-darwin-amd64 mdmserver1
2022/07/06 22:27:18 level=info component=syncer name=mdmserver1 msg=device sync phase=fetch more=false cursor=MTY1NzE0NzA5My0x devices=1 fetched_until=2022-07-06 15:38:13 -0700 PDT
2022/07/06 22:27:19 level=info component=syncer name=mdmserver1 msg=device sync phase=sync more=false cursor=MTY1NzE3MTU3Mi0w devices=0
```
If we wanted to specify a more complex startup we might:
* Set the device limit to 200 (over the Apple server default of 100)
* Double the default sync duration to an hour
* Ask for debug logging output
* Specify two DEP names to sync devices from.
```bash
$ ./depsyncer-darwin-amd64 -debug -limit 200 -duration 3600 mdmserver1 mdmserver2
2022/07/06 23:32:06 level=debug component=syncer name=mdmserver2 msg=starting timer duration=1h0m0s
2022/07/06 23:32:06 level=debug component=syncer name=mdmserver1 msg=starting timer duration=1h0m0s
2022/07/06 23:32:06 level=debug component=syncer name=mdmserver2 msg=cursor returned all devices previously phase=fetch cursor=MTY1NzE3MjcxNy0x
2022/07/06 23:32:06 level=debug component=syncer name=mdmserver1 msg=cursor returned all devices previously phase=fetch cursor=MTY1NzE3NTI2My0w
2022/07/06 23:32:06 level=info component=syncer name=mdmserver1 msg=device sync phase=sync more=false cursor=MTY1NzE3NTQ1OS0w devices=0
2022/07/06 23:32:06 level=info component=syncer name=mdmserver2 msg=device sync phase=sync more=false cursor=MTY1NzE3MTU3Mi0w devices=2 fetched_until=2022-06-27 22:37:58 +0000 UTC op_type_added=2
```
Here we can see that both syncers started and the syncer for DEP name "mdmserver2" had two added devices.
To perform a single sync which then exits we can use the `-duration 0` switch (notice no timer being started):
```bash
$ ./depsyncer-darwin-amd64 -debug -duration 0 depsim
2022/07/07 00:05:45 level=debug component=syncer name=depsim msg=cursor returned all devices previously phase=fetch cursor=MTY1NzE3NTczOC0w
2022/07/07 00:05:45 level=info component=syncer name=depsim msg=device sync phase=sync more=false cursor=MTY1NzE3NzQ3OC0w devices=0
```
### Troubleshooting
The `depsyncer` tool has two debug switches: one for general debug logging (`-debug`) and another debug logging specifically for the assigner (`-debug-assigner`). Turning these options on may give extra detail into the which devices the syncer is seeing and which it is considering for assignment.
If you're moving devices from an existing MDM server in ABM/ASM/BE to your NanoDEP server then you may encounter a situation where, after moving the device over, you have `op_type_modified=X` but *not* `op_type_added=Y` device log lines (the former are required fot the assigner to work). This appears to be an oddity with the ABM/ASM/BE portal that [the MicroMDM project has documented](https://github.com/micromdm/micromdm/wiki/DEP-auto-assignment#reassignment-oddities) with a (kludgy) workaround.
## deptokens
The `deptokens` tool is an *optional* small stand-alone utility for decrypting the DEP OAuth tokens from the ABM/ASM/BE portal. It operates in one of two modes depending on if the `-token` switch is provided.
In "keypair generation" mode (that is, without specifying the `-token` switch) it will generate an RSA private key and certificate and save them both to disk (the private key optionally encrypted with the `-password` switch). The path to the certificate and key are provided in the `-cert` and `-key` switches, respectively.
In "decrypt and decode tokens" mode (that is, by specifying the path to the downloaded tokens file with the `-token` switch) it will attempt to use the certificate and key on disk (specified by `-cert` and `-key` switches, respectively, with an optional password for an encrypted private key specified with `-password`) to decrypt the tokens and display them. They can then be stored in `depserver` by using the "raw" token API (documented above).
**Note: `deptokens` is not required to use NanoDEP: `depserver` contains this functionality built-in using the tools/scripts (or via the API) directly. See above documentation.**
### Switches
Command line switches for the `deptokens` tool.
#### -cert string
* path to certificate (default "cert.pem")
The file path to read or save the X.509 certificate that contains the public key that the DEP OAuth tokens will be encrypted to.
#### -f
* force overwriting the keypair
By default `deptokens` tries not to overwrite the certificate or private key if a file exists at those paths. If this switch is provided it will happily overwrite them.
#### -key string
* path to key (default "cert.key")
The file path to read or save the RSA private key that corresponds to the public key that the OAuth tokens will be encrypted to.
#### -password string
* password to encrypt/decrypt private key with
A password to encrypt or decrypt RSA private key on disk with. Note this is password is just to protect the private key itself and does not play a role in the token PKI exchange with Apple.
#### -token string
* path to tokens
The file path to the ".p7m" file that Apple generates when retrieving the encrypted OAuth tokens from the ABM/ASM/BE portal.
If this switch is missing (the default) `deptokens` operates in "keypair generation" mode. If this switch is provided `deptokens` operates in "decrypt and decode tokens" mode. This follows the two-step upload-certificate then download-token process required for retrieving the DEP OAuth tokens.
#### -version
* print version
Print version and exit.
### Example usage
#### Keypair generation
```bash
$ ./deptokens-darwin-amd64 -password supersecret
wrote cert.pem, cert.key
```
Here `deptokens` wrote a PEM encoded certificate to `cert.pem` and a password encrypted private key to `cert.key`. We can upload `cert.pem` to Apple's ASM/ABM/BE portal as usual (see above or the quick start guide).
#### Decrypt and decode tokens
```bash
$ ./deptokens-darwin-amd64 -password supersecret -token /Users/negacctbal/Downloads/mdmserver1_Token_2022-07-06T06-03-00Z_smime.p7m
{"consumer_key":"CK_9af2f8218b150c351ad802c6f3d66abe","consumer_secret":"CS_9af2f8218b150c351ad802c6f3d66abe","access_token":"AT_9af2f8218b150c351ad802c6f3d66abe","access_secret":"AS_9af2f8218b150c351ad802c6f3d66abe","access_token_expiry":"2023-07-01T22:18:53Z"}
```
Here `deptokens` has read the default paths for the certificate and private key (`cert.pem` and `cert.key` respectively), decrypted the private key using the `-password` switch and using this private key decrypted the token file provided using the `-token` switch. It dumped the decrypted OAuth tokens JSON to stdout.

View File

@ -0,0 +1,151 @@
# NanoDEP Quick Start Guide
A guide to getting NanoDEP up and running quickly. For more in-depth documentation please see the [Operations Guide](operations-guide.md).
## Requirements
* An Apple Business Manager (ABM), Apple School Manager (ASM), or Business Essentials (BE) login account with at least Device Management permissions/abilities.
* Devices already present in your ABM/ASM/BE system to assign.
* For the [tools](../tools) you'll need `curl`, `jq`, and of course a shell script interpreter.
* Outbound internet access to talk to Apple's DEP APIs.
## Guide to creating a DEP profile for a device
What follows is a step-by-step guide to creating a DEP profile and assigning it to a device. This should allow a device to use [Automated Device Enrollment (ADE)](https://support.apple.com/en-us/HT204142).
### Get NanoDEP and other tools
First, get a copy of NanoDEP by downloading and extracting a [recent release](https://github.com/micromdm/nanodep/releases) or compiling from source. You'll also want to make sure you have `jq` and `curl` installed. As well you'll want to have the shell scripts from the `tools/` directory downloaded and available which are included in the release.
### Start depserver
Start `depserver`. Note the port (default 9001) it started on. We also set an API key here.
```bash
$ ./depserver-darwin-amd64 -api supersecret
2022/07/02 14:14:18 level=info msg=starting server listen=:9001
```
### Setup environment
We need to setup our environment so our [tools](../tools) can talk to this running depserver.
```bash
export BASE_URL='http://[::1]:9001'
export APIKEY=supersecret
export DEP_NAME=mdmserver1
```
Note here the "DEP name" of `mdmserver1` is arbitrary and can be anything you like (but avoid forward-slashes "/" as the APIs use this name as part of the URL). The depserver and related tools support multiple DEP server configurations so this uniquely identifies the DEP server we want to work with.
### Generate and retrieve the DEP token public key
The ABM/ASM/BE portal uses a public key to encrypt the OAuth1 tokens. To generate a new keypair and retrieve the public key (in an X.509 Certificate):
```bash
$ ./tools/cfg-get-cert.sh > $DEP_NAME.pem
```
Note this should create a new file called "mdmserver1.pem" (or whatever you set `$DEP_NAME` to, above).
### Upload the public key to ABM/ASM/BE
Login to https://business.apple.com/ or https://school.apple.com/ in a browser then navigate to the list of MDM servers. As of July 2022 this is done by navigating to the lower-left menu by clicking on your login name and selecting "Preferences." Under the separator there's a list titled "Your MDM Servers."
Create a new MDM server by clicking the "+" or "Add" button by the list header. Give it a name: perhaps something related to the "mdmserver1" name so you can remember these are associated. Then, upload the public key certificate generated in the last step. Click "Save".
### Download Token
Next, we'll want to download the token. From within the ABM/ASM/BE portal navigate to your newly created (or modified) MDM server. As of July 2022 there's a top menu for the MDM server which contains a button/link to "Download Token." Click this to download the token which should download a file with the extension ".p7m" and named after the MDM server you created: this downloaded token is the encrypted OAuth tokens for DEP access.
### Decrypt tokens
To decrypt the OAuth tokens and save them to the DEP server for use:
```bash
$ ./tools/cfg-decrypt-tokens.sh ~/Downloads/mdmserver1_Token_2022-07-01T22-18-53Z_smime.p7m
{"consumer_key":"CK_9af2f8218b150c351ad802c6f3d66abe","consumer_secret":"CS_9af2f8218b150c351ad802c6f3d66abe","access_token":"AT_9af2f8218b150c351ad802c6f3d66abe","access_secret":"AS_9af2f8218b150c351ad802c6f3d66abe","access_token_expiry":"2023-07-01T22:18:53Z"}
```
The server's reply is the decrypted OAuth tokens. `depserver` should now have authenticated access to talk to Apple's API!
### Request account detail
As a test of our DEP authentication, let's request account detail:
```bash
$ ./tools/dep-account-detail.sh
{
"server_name": "Example Server",
"server_uuid": "677cab70-fe18-11e2-b778-0800200c9a66",
"facilitator_id": "facilitator@example.com",
"org_phone": "111-222-3333",
"org_name": "Example Inc",
"org_email": "orgadmin@example.com",
"org_address": "123 Main St. Anytown, USA",
"admin_id": "admin@example.com"
}
```
If you received no response here then you can e.g. set `export CURL_OPTS=-v` to give us more detail and check the `depserver` logs if necessary. See the [operations guide](../docs/operations-guide.md) for more.
Otherwise: congratulations! The token exchanged was successful and you can use the tokens to communicate with Apple's DEP API. **Note: you will need renew these tokens yearly or whenever the Apple Terms and Conditions are updated by following this same procedure.**
### Assign a device in the portal
Now that we've verified API connectivity using your DEP server you need to assign a device in the ABM/ASM/BE portal. To do so login to the portal and navigate to the "Devices" section. Select (or search for) the device you want to use with DEP by settings its MDM server. As of July, 2022 there is a link/button in the top navigation of a device called "Edit MDM Server" — clicking this brings up a dialog to either assign or un-assign the device. When assigning a drop-menu appears of the setup MDM servers. We'll want to select our newly created server "mdmserver1" then click the "Continue" button. The device should then be assigned to your MDM server and available for a DEP profile to be assigned to it.
### Define a DEP Profile and assign a device
DEP works by associating devices (serial numbers) with DEP profiles. A DEP profile is a set of properties associated to a UUID and, importantly, specifies the URL location of our MDM server our device enrolls into. We can define a DEP profile with its properties *and* associate serial numbers in one step.
First adjust the [example DEP profile](../docs/dep-profile.example.json) or make a copy of it. Critically you'll need to point the profile at your MDM using the `url` or `configuration_web_url` properties. See the [Apple docs](https://developer.apple.com/documentation/devicemanagement/profile) for the various configuration options. For the below example I adjust a few parameters, made sure my MDM URL is correct, and added the serial `07AAD449616F566C12` to the `devices` array in the profile (note only serial number adjustment shown here, you *will* need to adjust other parameters of the profile):
```diff
--- a/docs/dep-profile.example.json
+++ b/docs/dep-profile.example.json
@@ -18,5 +18,5 @@
"anchor_certs": [],
"supervising_host_certs": [],
"skip_setup_items": ["AppleID", "Android"],
- "devices": ["SERIAL1","SERIAL2"]
+ "devices": ["07AAD449616F566C12"]
}
```
Then, we assign the profile:
```bash
$ ./tools/dep-define-profile.sh ./docs/dep-profile.example.json
{
"profile_uuid": "43277A13FBCA0CFC",
"devices": {
"07AAD449616F566C12": "SUCCESS"
}
}
```
Here the API has responded telling us the profile UUID `43277A13FBCA0CFC` has been defined and that it had success in assigning the serial number `07AAD449616F566C12` this DEP profile.
### Verify device ADE
Now that you have a device assigned to a DEP profile you can proceed to verifying and testing Automated Device Enrollment (ADE) by enrolling a device (likely erasing it first).
Note that getting ADE working on devices including the appropriate properties in the DEP profile is outside the scope of this document (and this project) as it requires integration with MDM enrollment and the details of how your enrollment profile is available — none of which this project is aware of.
That said, please check out these [MicroMDM](https://github.com/micromdm/micromdm) project resources for troubleshooting DEP on macOS if you believe your DEP profile is defined correctly:
* https://micromdm.io/blog/troubleshoot-dep/
* https://github.com/micromdm/micromdm/wiki/Troubleshooting-MDM#dep--mdm-testing--troubleshooting
### Next steps
Here's a few ideas on where to proceed next:
* Read the [Operations Guide](../docs/operations-guide.md) for more details on configuration, troubleshooting, etc.
* Setup an assigner to *automatically assign* serial numbers to DEP profiles when they're added to DEP (see the operations guide)
* Setup more than one DEP server — with the tools scripts this really just means changing the `$DEP_NAME` environment variable.
* A proper deployment
* Behind HTTPS/proxies
* Behind firewalls or in a private cloud/VPC
* In a container environment like Docker, Kubernetes, etc. or even just running as a service with systemctl.

View File

@ -0,0 +1,56 @@
package godep
import (
"context"
"net/http"
)
// AccountResponse corresponds to the Apple DEP API "AccountDetail" structure.
// See https://developer.apple.com/documentation/devicemanagement/accountdetail
type AccountResponse struct {
AdminID string `json:"admin_id"`
FacilitatorID string `json:"facilitator_id"`
OrgAddress string `json:"org_address"`
OrgEmail string `json:"org_email"`
OrgID string `json:"org_id"`
OrgIDHash string `json:"org_id_hash"`
OrgName string `json:"org_name"`
OrgPhone string `json:"org_phone"`
OrgType string `json:"org_type"`
OrgVersion string `json:"org_version"`
ServerName string `json:"server_name"`
ServerUUID string `json:"server_uuid"`
URLs []URL `json:"urls"`
}
// URL corresponds to the Apple DEP API "Url" structure.
// See https://developer.apple.com/documentation/devicemanagement/url
type URL struct {
HTTPMethod []string `json:"http_method"`
Limit *Limit `json:"limit"`
URI string `json:"uri"`
}
// Limit corresponds to the Apple DEP API "Limit" structure.
// See https://developer.apple.com/documentation/devicemanagement/limit
type Limit struct {
Default int `json:"default"`
Maximum int `json:"maximum"`
}
// AccountDetail uses the Apple "Get Account Detail" API endpoint to get the
// account details for the current DEP authentication token.
// See https://developer.apple.com/documentation/devicemanagement/get_account_detail
func (c *Client) AccountDetail(ctx context.Context, name string) (*AccountResponse, error) {
resp := new(AccountResponse)
return resp, c.doWithAfterHook(ctx, name, http.MethodGet, "/account", nil, resp)
}
// IsTermsNotSigned returns true if err is a DEP "terms and conditions not
// signed" error. Per Apple this indicates the Terms and Conditions must be
// accepted by the user.
// See https://developer.apple.com/documentation/devicemanagement/device_assignment/authenticating_with_a_device_enrollment_program_dep_server/interpreting_error_codes
func IsTermsNotSigned(err error) bool {
return httpErrorContains(err, http.StatusForbidden, "T_C_NOT_SIGNED") ||
authErrorContains(err, http.StatusForbidden, "T_C_NOT_SIGNED")
}

View File

@ -0,0 +1,175 @@
// Package godep provides Go methods and structures for talking to individual DEP API endpoints.
package godep
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
depclient "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
)
const (
mediaType = "application/json;charset=UTF8"
userAgent = "nanodep-godep/0"
)
// HTTPError encapsulates an HTTP response error from the DEP requests.
// The API returns error information in the request body.
type HTTPError struct {
Body []byte
Status string
StatusCode int
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("DEP HTTP error: %s: %s", e.Status, string(e.Body))
}
// NewHTTPError creates and returns a new HTTPError from r. Note this reads
// all of r.Body and the caller is responsible for closing it.
func NewHTTPError(r *http.Response) error {
body, readErr := io.ReadAll(r.Body)
err := &HTTPError{
Body: body,
Status: r.Status,
StatusCode: r.StatusCode,
}
if readErr != nil {
return fmt.Errorf("reading body of DEP HTTP error: %v: %w", err, readErr)
}
return err
}
// httpErrorContains checks if err is an HTTPError and contains body and a
// matching status code. With the depsim DEP simulator the body strings are
// returned with surrounding quotes. i.e. `"INVALID_CURSOR"` vs. just
// `INVALID_CURSOR` so we search the body data for the string vs. matching.
func httpErrorContains(err error, status int, s string) bool {
var httpErr *HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == status && bytes.Contains(httpErr.Body, []byte(s)) {
return true
}
return false
}
// authErrorContains is the same as httpErrorContains except that it checks if
// err is an depclient.AuthError instead of HTTPError.
func authErrorContains(err error, status int, s string) bool {
var authErr *depclient.AuthError
if errors.As(err, &authErr) && authErr.StatusCode == status && bytes.Contains(authErr.Body, []byte(s)) {
return true
}
return false
}
// ClientStorage provides the required data needed to connect to the Apple DEP APIs.
type ClientStorage interface {
depclient.AuthTokensRetriever
depclient.ConfigRetriever
}
// Client represents an Apple DEP API client identified by a single DEP name.
type Client struct {
store ClientStorage
// an HTTP client that handles DEP API authentication and session management
client depclient.Doer
afterHook func(ctx context.Context, err error) error
}
// ClientOption defines the functional options type for NewClient.
type ClientOption func(*Client)
// WithAfterHook installs a hook function that is called with the error
// resulting from any request, after transformation of the response's body to
// an HTTPError if needed. It gets called regardless of success or failure of
// the request, with a nil error if it succeeded. It can return a new error to
// be returned by the Client, or the original error.
func WithAfterHook(hook func(ctx context.Context, err error) error) ClientOption {
return func(c *Client) {
c.afterHook = hook
}
}
// NewClient creates new Client and reads authentication and config data
// from store. The provided client is copied and modified by wrapping its
// transport in a new NanoDEP transport (which transparently handles
// authentication and session management). If client is nil then
// http.DefaultClient is used.
func NewClient(store ClientStorage, client *http.Client, opts ...ClientOption) *Client {
if client == nil {
client = http.DefaultClient
}
t := depclient.NewTransport(client.Transport, client, store, nil)
client = depclient.NewClient(client, t)
depClient := &Client{
store: store,
client: client,
}
for _, opt := range opts {
opt(depClient)
}
return depClient
}
func (c *Client) doWithAfterHook(ctx context.Context, name, method, path string, in interface{}, out interface{}) error {
err := c.do(ctx, name, method, path, in, out)
if c.afterHook != nil {
err = c.afterHook(ctx, err)
}
return err
}
// do executes the HTTP request using the client's HTTP client which
// should be using the NanoDEP transport (which handles authentication).
// This frees us to only be concerned about the actual DEP API request.
// We encode in to JSON and decode any returned body as JSON to out.
func (c *Client) do(ctx context.Context, name, method, path string, in interface{}, out interface{}) error {
var body io.Reader
if in != nil {
bodyBytes, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewBuffer(bodyBytes)
}
req, err := depclient.NewRequestWithContext(ctx, name, c.store, method, path, body)
if err != nil {
return err
}
req.Header.Set("User-Agent", userAgent)
if body != nil {
req.Header.Set("Content-Type", mediaType)
}
if out != nil {
req.Header.Set("Accept", mediaType)
}
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("unhandled auth error: %w", depclient.NewAuthError(resp))
} else if resp.StatusCode != http.StatusOK {
return NewHTTPError(resp)
}
if out != nil {
err := json.NewDecoder(resp.Body).Decode(out)
if err != nil {
return err
}
}
return nil
}

View File

@ -0,0 +1,111 @@
package godep
import (
"context"
"net/http"
"time"
)
// Device corresponds to the Apple DEP API "Device" structure.
// See https://developer.apple.com/documentation/devicemanagement/device
type Device struct {
SerialNumber string `json:"serial_number"`
Model string `json:"model"`
Description string `json:"description"`
Color string `json:"color"`
AssetTag string `json:"asset_tag,omitempty"`
ProfileStatus string `json:"profile_status"`
ProfileUUID string `json:"profile_uuid,omitempty"`
ProfileAssignTime time.Time `json:"profile_assign_time,omitempty"`
ProfilePushTime time.Time `json:"profile_push_time,omitempty"`
DeviceAssignedDate time.Time `json:"device_assigned_date,omitempty"`
DeviceAssignedBy string `json:"device_assigned_by,omitempty"`
OS string `json:"os,omitempty"`
DeviceFamily string `json:"device_family,omitempty"`
// fetch/sync-only fields
OpType string `json:"op_type,omitempty"`
OpDate time.Time `json:"op_date,omitempty"`
}
// deviceRequest corresponds to the Apple DEP API "FetchDeviceRequest" and
// "SyncDeviceRequest" structures.
// See https://developer.apple.com/documentation/devicemanagement/fetchdevicerequest
// and https://developer.apple.com/documentation/devicemanagement/syncdevicerequest
type deviceRequest struct {
Cursor string `json:"cursor,omitempty"`
Limit int `json:"limit,omitempty"`
}
// DeviceResponse corresponds to the Apple DEP "FetchDeviceResponse" structure.
// See https://developer.apple.com/documentation/devicemanagement/fetchdeviceresponse
type DeviceResponse struct {
Cursor string `json:"cursor,omitempty"`
FetchedUntil time.Time `json:"fetched_until,omitempty"`
MoreToFollow bool `json:"more_to_follow"`
Devices []Device `json:"devices,omitempty"`
}
type DeviceRequestOption func(*deviceRequest)
// WithCursor includes a cursor in the fetch or sync request. The initial
// fetch request should omit this option.
func WithCursor(cursor string) DeviceRequestOption {
return func(d *deviceRequest) {
d.Cursor = cursor
}
}
// WithCursor includes a device limit in the fetch or sync request.
// Per Apple the API has a default of 100 and a maximum of 1000.
func WithLimit(limit int) DeviceRequestOption {
return func(d *deviceRequest) {
d.Limit = limit
}
}
// FetchDevices uses the Apple "Get a List of Devices" API endpoint to retrieve
// a list of all devices corresponding to this configured DEP server (DEP name).
// The name parameter specifies the configured DEP name to use.
// You should provide a cursor that is returned from previous FetchDevices
// call responses on any subsequent calls.
// See https://developer.apple.com/documentation/devicemanagement/get_a_list_of_devices
func (c *Client) FetchDevices(ctx context.Context, name string, opts ...DeviceRequestOption) (*DeviceResponse, error) {
req := new(deviceRequest)
for _, opt := range opts {
opt(req)
}
resp := new(DeviceResponse)
return resp, c.doWithAfterHook(ctx, name, http.MethodPost, "/server/devices", req, resp)
}
// SyncDevices uses the Apple "Sync the List of Devices" API endpoint to get
// updates about the list of devices corresponding to this configured DEP
// server (DEP name).
// The name parameter specifies the configured DEP name to use.
// You should provide a cursor that is returned from previous FetchDevices or
// SyncDevices call responses.
// See https://developer.apple.com/documentation/devicemanagement/sync_the_list_of_devices
func (c *Client) SyncDevices(ctx context.Context, name string, opts ...DeviceRequestOption) (*DeviceResponse, error) {
req := new(deviceRequest)
for _, opt := range opts {
opt(req)
}
resp := new(DeviceResponse)
return resp, c.doWithAfterHook(ctx, name, http.MethodPost, "/devices/sync", req, resp)
}
// IsCursorExhausted returns true if err is a DEP "exhausted cursor" error.
func IsCursorExhausted(err error) bool {
return httpErrorContains(err, http.StatusBadRequest, "EXHAUSTED_CURSOR")
}
// IsCursorInvalid returns true if err is a DEP "invalid cursor" error.
func IsCursorInvalid(err error) bool {
return httpErrorContains(err, http.StatusBadRequest, "INVALID_CURSOR")
}
// IsCursorExpired returns true if err is a DEP "expired cursor" error.
// Per Apple this indicates the cursor is older than 7 days.
func IsCursorExpired(err error) bool {
return httpErrorContains(err, http.StatusBadRequest, "EXPIRED_CURSOR")
}

View File

@ -0,0 +1,75 @@
package godep
import (
"context"
"net/http"
)
// Profile corresponds to the Apple DEP API "Profile" structure.
// See https://developer.apple.com/documentation/devicemanagement/profile
type Profile struct {
ProfileName string `json:"profile_name"`
URL string `json:"url"`
AllowPairing bool `json:"allow_pairing,omitempty"`
IsSupervised bool `json:"is_supervised,omitempty"`
IsMultiUser bool `json:"is_multi_user,omitempty"`
IsMandatory bool `json:"is_mandatory,omitempty"`
AwaitDeviceConfigured bool `json:"await_device_configured,omitempty"`
IsMDMRemovable bool `json:"is_mdm_removable"` // default true
SupportPhoneNumber string `json:"support_phone_number,omitempty"`
AutoAdvanceSetup bool `json:"auto_advance_setup,omitempty"`
SupportEmailAddress string `json:"support_email_address,omitempty"`
OrgMagic string `json:"org_magic"`
AnchorCerts []string `json:"anchor_certs,omitempty"`
SupervisingHostCerts []string `json:"supervising_host_certs,omitempty"`
Department string `json:"department,omitempty"`
Devices []string `json:"devices,omitempty"`
Language string `json:"language,omitempty"`
Region string `json:"region,omitempty"`
ConfigurationWebURL string `json:"configuration_web_url,omitempty"`
// See https://developer.apple.com/documentation/devicemanagement/skipkeys
SkipSetupItems []string `json:"skip_setup_items,omitempty"`
// additional undocumented key only returned when requesting a profile from Apple.
ProfileUUID string `json:"profile_uuid,omitempty"`
}
// ProfileResponse corresponds to the Apple DEP API "AssignProfileResponse" structure.
// See https://developer.apple.com/documentation/devicemanagement/assignprofileresponse
type ProfileResponse struct {
ProfileUUID string `json:"profile_uuid"`
Devices map[string]string `json:"devices"`
}
// AssignProfiles uses the Apple "Assign a profile to a list of devices" API
// endpoint to assign a DEP profile UUID to a list of serial numbers.
// The name parameter specifies the configured DEP name to use.
// Note we use HTTP PUT for compatibility despite modern documentation
// listing HTTP POST for this endpoint.
// See https://developer.apple.com/documentation/devicemanagement/assign_a_profile
func (c *Client) AssignProfile(ctx context.Context, name, uuid string, serials ...string) (*ProfileResponse, error) {
req := &struct {
ProfileUUID string `json:"profile_uuid"`
Devices []string `json:"devices"`
}{
ProfileUUID: uuid,
Devices: serials,
}
resp := new(ProfileResponse)
// historically this has been an HTTP PUT and the DEP simulator depsim
// requires this. however modern Apple documentation says this is a POST
// now. we still use PUT here for compatibility.
return resp, c.doWithAfterHook(ctx, name, http.MethodPut, "/profile/devices", req, resp)
}
// DefineProfile uses the Apple "Define a Profile" command to attempt to create a profile.
// This service defines a profile with Apple's servers that can then be assigned to specific devices.
// This command provides information about the MDM server that is assigned to manage one or more devices,
// information about the host that the managed devices can pair with, and various attributes that control
// the MDM association behavior of the device.
// See https://developer.apple.com/documentation/devicemanagement/define_a_profile
func (c *Client) DefineProfile(ctx context.Context, name string, profile *Profile) (*ProfileResponse, error) {
resp := new(ProfileResponse)
return resp, c.doWithAfterHook(ctx, name, http.MethodPost, "/profile", profile, resp)
}

View File

@ -0,0 +1,17 @@
// Package api implements HTTP handlers for the NanoDEP API.
package api
import (
"encoding/json"
"net/http"
)
// jsonError writes err as JSON and to w.
func jsonError(w http.ResponseWriter, err error) {
jsonErr := &struct {
Err string `json:"error"`
}{Err: err.Error()}
w.Header().Set("Content-type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(jsonErr)
}

View File

@ -0,0 +1,90 @@
package api
import (
"context"
"encoding/json"
"net/http"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log/ctxlog"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/sync"
)
// RetrieveAssignerProfileHandler returns the assigner profile UUID for the
// given DEP name.
//
// Note the whole URL path is used as the DEP name. This necessitates
// stripping the URL prefix before using this handler. Also note we expose Go
// errors to the output as this is meant for "API" users.
func RetrieveAssignerProfileHandler(store sync.AssignerProfileRetriever, logger log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger := ctxlog.Logger(r.Context(), logger)
if r.URL.Path == "" {
logger.Info("msg", "DEP name check", "err", "missing DEP name")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
logger = logger.With("name", r.URL.Path)
profileUUID, _, err := store.RetrieveAssignerProfile(r.Context(), r.URL.Path)
if err != nil {
logger.Info("msg", "retrieving assigner profile", "err", err)
jsonError(w, err)
return
}
logger = logger.With("profile_uuid", profileUUID)
w.Header().Set("Content-type", "application/json")
profile := &struct {
ProfileUUID string `json:"profile_uuid,omitempty"`
}{ProfileUUID: profileUUID}
err = json.NewEncoder(w).Encode(profile)
if err != nil {
logger.Info("msg", "encoding response body", "err", err)
return
}
}
}
type AssignerProfileStorer interface {
StoreAssignerProfile(ctx context.Context, name string, profileUUID string) error
}
// StoreAssignerProfileHandler saves the assigner profile UUID for the
// given DEP name.
//
// Note the whole URL path is used as the DEP name. This necessitates
// stripping the URL prefix before using this handler. Also note we expose Go
// errors to the output as this is meant for "API" users.
func StoreAssignerProfileHandler(store AssignerProfileStorer, logger log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger := ctxlog.Logger(r.Context(), logger)
if r.URL.Path == "" {
logger.Info("msg", "DEP name check", "err", "missing DEP name")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
logger = logger.With("name", r.URL.Path)
profileUUID := r.URL.Query().Get("profile_uuid")
if profileUUID == "" {
logger.Info("msg", "reading profile UUID", "err", "empty profile UUID")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
logger = logger.With("profile_uuid", profileUUID)
err := store.StoreAssignerProfile(r.Context(), r.URL.Path, profileUUID)
if err != nil {
logger.Info("msg", "storing assigner profile", "err", err)
jsonError(w, err)
return
}
logger.Debug("msg", "stored assigner profile")
w.Header().Set("Content-type", "application/json")
profile := &struct {
ProfileUUID string `json:"profile_uuid,omitempty"`
}{ProfileUUID: profileUUID}
err = json.NewEncoder(w).Encode(profile)
if err != nil {
logger.Info("msg", "encoding response body", "err", err)
return
}
}
}

View File

@ -0,0 +1,84 @@
package api
import (
"context"
"encoding/json"
"net/http"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log/ctxlog"
)
// RetrieveConfigHandler returns the DEP server config for the DEP
// name in the path.
//
// Note the whole URL path is used as the DEP name. This necessitates
// stripping the URL prefix before using this handler. Also note we expose Go
// errors to the output as this is meant for "API" users.
func RetrieveConfigHandler(store client.ConfigRetriever, logger log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger := ctxlog.Logger(r.Context(), logger)
if r.URL.Path == "" {
logger.Info("msg", "DEP name check", "err", "missing DEP name")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
logger = logger.With("name", r.URL.Path)
config, err := store.RetrieveConfig(r.Context(), r.URL.Path)
if err != nil {
logger.Info("msg", "retrieving config", "err", err)
jsonError(w, err)
return
}
w.Header().Set("Content-type", "application/json")
err = json.NewEncoder(w).Encode(config)
if err != nil {
logger.Info("msg", "encoding response body", "err", err)
return
}
}
}
type ConfigStorer interface {
StoreConfig(ctx context.Context, name string, config *client.Config) error
}
// StoreConfigHandler stores the DEP server config for the DEP
// name in the path.
//
// Note the whole URL path is used as the DEP name. This necessitates
// stripping the URL prefix before using this handler. Also note we expose Go
// errors to the output as this is meant for "API" users.
func StoreConfigHandler(store ConfigStorer, logger log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger := ctxlog.Logger(r.Context(), logger)
if r.URL.Path == "" {
logger.Info("msg", "DEP name check", "err", "missing DEP name")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
logger = logger.With("name", r.URL.Path)
config := new(client.Config)
err := json.NewDecoder(r.Body).Decode(config)
if err != nil {
logger.Info("msg", "decoding request body", "err", err)
jsonError(w, err)
return
}
defer r.Body.Close()
err = store.StoreConfig(r.Context(), r.URL.Path, config)
if err != nil {
logger.Info("msg", "storing config", "err", err)
jsonError(w, err)
return
}
logger.Debug("msg", "stored config")
w.Header().Set("Content-type", "application/json")
err = json.NewEncoder(w).Encode(config)
if err != nil {
logger.Info("msg", "encoding response body", "err", err)
return
}
}
}

View File

@ -0,0 +1,137 @@
package api
import (
"context"
"encoding/json"
"io"
"net/http"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log/ctxlog"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
)
type TokenPKIRetriever interface {
RetrieveTokenPKI(ctx context.Context, name string) (pemCert []byte, pemKey []byte, err error)
}
type TokenPKIStorer interface {
StoreTokenPKI(ctx context.Context, name string, pemCert []byte, pemKey []byte) error
}
const (
defaultCN = "depserver"
defaultDays = 1
)
// GetCertTokenPKIHandler generates a new private key and certificate for
// the token PKI exchange with the ABM/ASM/BE portal. Every call to this
// handler generates a new keypair and stores it. The PEM-encoded certificate
// is returned.
//
// Note the whole URL path is used as the DEP name. This necessitates
// stripping the URL prefix before using this handler. Also note we expose Go
// errors to the output as this is meant for "API" users.
func GetCertTokenPKIHandler(store TokenPKIStorer, logger log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger := ctxlog.Logger(r.Context(), logger)
if r.URL.Path == "" {
logger.Info("msg", "DEP name check", "err", "missing DEP name")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
logger = logger.With("name", r.URL.Path)
key, cert, err := tokenpki.SelfSignedRSAKeypair(defaultCN, defaultDays)
if err != nil {
logger.Info("msg", "generating token keypair", "err", err)
jsonError(w, err)
return
}
pemCert := tokenpki.PEMCertificate(cert.Raw)
err = store.StoreTokenPKI(r.Context(), r.URL.Path, pemCert, tokenpki.PEMRSAPrivateKey(key))
if err != nil {
logger.Info("msg", "storing token keypair", "err", err)
jsonError(w, err)
return
}
w.Header().Set("Content-Type", "application/x-pem-file")
w.Header().Set("Content-Disposition", `attachment; filename="`+r.URL.Path+`.pem"`)
_, _ = w.Write(pemCert)
}
}
// DecryptTokenPKIHandler reads the Apple-provided encrypted token ".p7m" file
// from the request body and decrypts it with the keypair generated from
// GetCertTokenPKIHandler.
//
// Note the whole URL path is used as the DEP name. This necessitates
// stripping the URL prefix before using this handler. Also note we expose Go
// errors to the output as this is meant for "API" users.
func DecryptTokenPKIHandler(store TokenPKIRetriever, tokenStore AuthTokensStorer, logger log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger := ctxlog.Logger(r.Context(), logger)
if r.URL.Path == "" {
logger.Info("msg", "DEP name check", "err", "missing DEP name")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
logger = logger.With("name", r.URL.Path)
bodyBytes, err := io.ReadAll(r.Body)
if err != nil {
logger.Info("msg", "reading request body", "err", err)
jsonError(w, err)
return
}
defer r.Body.Close()
certBytes, keyBytes, err := store.RetrieveTokenPKI(r.Context(), r.URL.Path)
if err != nil {
logger.Info("msg", "retrieving token keypair", "err", err)
jsonError(w, err)
return
}
cert, err := tokenpki.CertificateFromPEM(certBytes)
if err != nil {
logger.Info("msg", "decoding retrieved certificate", "err", err)
jsonError(w, err)
return
}
key, err := tokenpki.RSAKeyFromPEM(keyBytes)
if err != nil {
logger.Info("msg", "decoding retrieved private key", "err", err)
jsonError(w, err)
return
}
tokenJSON, err := tokenpki.DecryptTokenJSON(bodyBytes, cert, key)
if err != nil {
logger.Info("msg", "decrypting auth tokens", "err", err)
jsonError(w, err)
return
}
tokens := new(client.OAuth1Tokens)
err = json.Unmarshal(tokenJSON, tokens)
if err != nil {
logger.Info("msg", "decoding decrypted auth tokens", "err", err)
jsonError(w, err)
return
}
if !tokens.Valid() {
logger.Info("msg", "checking auth token validity", "err", "invalid tokens")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
err = tokenStore.StoreAuthTokens(r.Context(), r.URL.Path, tokens)
if err != nil {
logger.Info("msg", "storing auth tokens", "err", err)
jsonError(w, err)
return
}
logger.Debug("msg", "stored auth tokens")
w.Header().Set("Content-type", "application/json")
err = json.NewEncoder(w).Encode(tokens)
if err != nil {
logger.Info("msg", "encoding response body", "err", err)
return
}
}
}

View File

@ -0,0 +1,89 @@
package api
import (
"context"
"encoding/json"
"net/http"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log/ctxlog"
)
type AuthTokensStorer interface {
StoreAuthTokens(ctx context.Context, name string, tokens *client.OAuth1Tokens) error
}
// RetrieveAuthTokensHandler returns the DEP server OAuth1 tokens for the DEP
// name in the path.
//
// Note the whole URL path is used as the DEP name. This necessitates
// stripping the URL prefix before using this handler. Also note we expose Go
// errors to the output as this is meant for "API" users.
func RetrieveAuthTokensHandler(store client.AuthTokensRetriever, logger log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger := ctxlog.Logger(r.Context(), logger)
if r.URL.Path == "" {
logger.Info("msg", "DEP name check", "err", "missing DEP name")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
logger = logger.With("name", r.URL.Path)
tokens, err := store.RetrieveAuthTokens(r.Context(), r.URL.Path)
if err != nil {
logger.Info("msg", "retrieving auth tokens", "err", err)
jsonError(w, err)
return
}
w.Header().Set("Content-type", "application/json")
err = json.NewEncoder(w).Encode(tokens)
if err != nil {
logger.Info("msg", "encoding response body", "err", err)
return
}
}
}
// StoreAuthTokensHandler reads DEP server OAuth1 tokens as a JSON body and
// saves them using store.
//
// Note the whole URL path is used as the DEP name. This necessitates
// stripping the URL prefix before using this handler. Also note we expose Go
// errors to the output as this is meant for "API" users.
func StoreAuthTokensHandler(store AuthTokensStorer, logger log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
logger := ctxlog.Logger(r.Context(), logger)
if r.URL.Path == "" {
logger.Info("msg", "DEP name check", "err", "missing DEP name")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
logger = logger.With("name", r.URL.Path)
tokens := new(client.OAuth1Tokens)
err := json.NewDecoder(r.Body).Decode(tokens)
if err != nil {
logger.Info("msg", "decoding request body", "err", err)
jsonError(w, err)
return
}
defer r.Body.Close()
if !tokens.Valid() {
logger.Info("msg", "checking auth token validity", "err", "invalid tokens")
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
err = store.StoreAuthTokens(r.Context(), r.URL.Path, tokens)
if err != nil {
logger.Info("msg", "storing auth tokens", "err", err)
jsonError(w, err)
return
}
logger.Debug("msg", "stored auth tokens")
w.Header().Set("Content-type", "application/json")
err = json.NewEncoder(w).Encode(tokens)
if err != nil {
logger.Info("msg", "encoding response body", "err", err)
return
}
}
}

View File

@ -0,0 +1,80 @@
// Package http includes handlers and utilties
package http
import (
"bytes"
"context"
"crypto/subtle"
"io"
"io/ioutil"
"net"
"net/http"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log/ctxlog"
)
// ReadAllAndReplaceBody reads all of r.Body and replaces it with a new byte buffer.
func ReadAllAndReplaceBody(r *http.Request) ([]byte, error) {
b, err := ioutil.ReadAll(r.Body)
if err != nil {
return b, err
}
defer r.Body.Close()
r.Body = io.NopCloser(bytes.NewBuffer(b))
return b, nil
}
// BasicAuthMiddleware is a simple HTTP plain authentication middleware.
func BasicAuthMiddleware(next http.Handler, username, password, realm string) http.HandlerFunc {
uBytes := []byte(username)
pBytes := []byte(password)
return func(w http.ResponseWriter, r *http.Request) {
u, p, ok := r.BasicAuth()
if !ok || subtle.ConstantTimeCompare([]byte(u), uBytes) != 1 || subtle.ConstantTimeCompare([]byte(p), pBytes) != 1 {
w.Header().Set("WWW-Authenticate", `Basic realm="`+realm+`"`)
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
}
}
// VersionHandler returns a simple JSON response from a version string.
func VersionHandler(version string) http.HandlerFunc {
bodyBytes := []byte(`{"version":"` + version + `"}`)
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(bodyBytes)
}
}
type ctxKeyTraceID struct{}
// TraceLoggingMiddleware sets up a trace ID in the request context and
// logs HTTP requests.
func TraceLoggingMiddleware(next http.Handler, logger log.Logger, newTrace func() string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), ctxKeyTraceID{}, newTrace())
ctx = ctxlog.AddFunc(ctx, ctxlog.SimpleStringFunc("trace_id", ctxKeyTraceID{}))
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
logs := []interface{}{
"addr", host,
"method", r.Method,
"path", r.URL.Path,
"agent", r.UserAgent(),
}
if fwdedFor := r.Header.Get("X-Forwarded-For"); fwdedFor != "" {
logs = append(logs, "x_forwarded_for", fwdedFor)
}
ctxlog.Logger(ctx, logger).Info(logs...)
next.ServeHTTP(w, r.WithContext(ctx))
}
}

View File

@ -0,0 +1,50 @@
package http
import (
"net/http"
"sync"
)
// MethodMux is an HTTP request multiplexer.
// It matches the HTTP method of each incoming request against a list of
// registered HTTP method names and calls the handler that matches.
type MethodMux struct {
methodsMu sync.RWMutex
methods map[string]http.Handler
}
// NewMethodMux creates a new MethodMux.
func NewMethodMux() *MethodMux { return new(MethodMux) }
// Handle registers the handler for the given method.
// If handler already exists for the given method, Handle panics.
func (mux *MethodMux) Handle(method string, handler http.Handler) {
if method == "" {
panic("http: invalid method")
}
if handler == nil {
panic("http: nil handler")
}
mux.methodsMu.Lock()
defer mux.methodsMu.Unlock()
if mux.methods == nil {
mux.methods = make(map[string]http.Handler)
} else if _, exists := mux.methods[method]; exists {
panic("http: multiple registrations for " + method)
}
mux.methods[method] = handler
}
func (mux *MethodMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var next http.Handler
mux.methodsMu.RLock()
if mux.methods != nil {
next = mux.methods[r.Method]
}
mux.methodsMu.RUnlock()
if next == nil {
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return
}
next.ServeHTTP(w, r)
}

View File

@ -0,0 +1,72 @@
// Package ctxlog allows logging data stored with a context.
package ctxlog
import (
"context"
"sync"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log"
)
// CtxKVFunc creates logger key-value pairs from a context.
// CtxKVFuncs should aim to be be as efficient as possible—ideally only
// doing the minimum to read context values and generate KV pairs. Each
// associated CtxKVFunc is called every time we adapt a logger with
// Logger.
type CtxKVFunc func(context.Context) []interface{}
// ctxKeyFuncs is the context key for storing and retriveing
// a funcs{} struct on a context.
type ctxKeyFuncs struct{}
// funcs holds the associated CtxKVFunc functions to run.
type funcs struct {
sync.RWMutex
funcs []CtxKVFunc
}
// AddFunc associates a new CtxKVFunc function to a context.
func AddFunc(ctx context.Context, f CtxKVFunc) context.Context {
if ctx == nil {
return ctx
}
ctxFuncs, ok := ctx.Value(ctxKeyFuncs{}).(*funcs)
if !ok || ctxFuncs == nil {
ctxFuncs = &funcs{}
}
ctxFuncs.Lock()
ctxFuncs.funcs = append(ctxFuncs.funcs, f)
ctxFuncs.Unlock()
return context.WithValue(ctx, ctxKeyFuncs{}, ctxFuncs)
}
// Logger runs the associated CtxKVFunc functions and returns a new
// logger with the results.
func Logger(ctx context.Context, logger log.Logger) log.Logger {
if ctx == nil {
return logger
}
ctxFuncs, ok := ctx.Value(ctxKeyFuncs{}).(*funcs)
if !ok || ctxFuncs == nil {
return logger
}
var acc []interface{}
ctxFuncs.RLock()
for _, f := range ctxFuncs.funcs {
acc = append(acc, f(ctx)...)
}
ctxFuncs.RUnlock()
return logger.With(acc...)
}
// SimpleStringFunc is a helper that generates a simple CtxKVFunc that
// returns a key-value pair if found on the context.
func SimpleStringFunc(logKey string, ctxKey interface{}) CtxKVFunc {
return func(ctx context.Context) (out []interface{}) {
v, _ := ctx.Value(ctxKey).(string)
if v != "" {
out = []interface{}{logKey, v}
}
return
}
}

View File

@ -0,0 +1,17 @@
package log
// Pacakge log is embedded (not imported) from:
// https://github.com/jessepeterson/go-log
// Logger is a generic logging interface to a structured, leveled, nest-able logger
type Logger interface {
// Info logs using the info level
Info(...interface{})
// Debug logs using the debug level
Debug(...interface{})
// With nests the Logger
// Usually for adding logging context to a sub-logger
With(...interface{}) Logger
}

View File

@ -0,0 +1,21 @@
package log
// Pacakge log is embedded (not imported) from:
// https://github.com/jessepeterson/go-log
// nopLogger does nothing
type nopLogger struct{}
// Info does nothing
func (*nopLogger) Info(_ ...interface{}) {}
// Debug does nothing
func (*nopLogger) Debug(_ ...interface{}) {}
// With returns (the same) logger
func (logger *nopLogger) With(_ ...interface{}) Logger {
return logger
}
// NopLogger is a Logger that does nothing
var NopLogger = &nopLogger{}

View File

@ -0,0 +1,60 @@
package stdlogfmt
import (
stdlog "log"
"strings"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log"
)
// Logger wraps a standard library logger and adapts it to pkg/log.
type Logger struct {
stdLogger *stdlog.Logger
context []interface{}
logDebug bool
}
// New creates a new logger that adapts the standard log package to pkg/log.
func New(logger *stdlog.Logger, logDebug bool) *Logger {
return &Logger{
stdLogger: logger,
logDebug: logDebug,
}
}
func (l *Logger) print(args ...interface{}) {
f := strings.Repeat(" %s=%v", len(args)/2)[1:]
if len(args)%2 == 1 {
f += " UNKNOWN=%v"
}
l.stdLogger.Printf(f, args...)
}
// Info logs using the "info" level
func (l *Logger) Info(args ...interface{}) {
logs := []interface{}{"level", "info"}
logs = append(logs, l.context...)
logs = append(logs, args...)
l.print(logs...)
}
// Info logs using the "debug" level
func (l *Logger) Debug(args ...interface{}) {
if l.logDebug {
logs := []interface{}{"level", "debug"}
logs = append(logs, l.context...)
logs = append(logs, args...)
l.print(logs...)
}
}
// With creates a new logger using args as context
func (l *Logger) With(args ...interface{}) log.Logger {
newLogger := &Logger{
stdLogger: l.stdLogger,
context: l.context,
logDebug: l.logDebug,
}
newLogger.context = append(newLogger.context, args...)
return newLogger
}

View File

@ -0,0 +1,28 @@
package parse
import (
"fmt"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage/file"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage/mysql"
_ "github.com/go-sql-driver/mysql"
)
// Storage parses a storage name and dsn to determine which and return a storage backend.
func Storage(storageName, dsn string) (storage.AllDEPStorage, error) {
var store storage.AllDEPStorage
var err error
switch storageName {
case "file":
if dsn == "" {
dsn = "db"
}
store, err = file.New(dsn)
case "mysql":
store, err = mysql.New(mysql.WithDSN(dsn))
default:
return nil, fmt.Errorf("unknown storage: %q", storageName)
}
return store, err
}

View File

@ -0,0 +1,160 @@
// Pacakge proxy provides a reverse proxy for talking to Apple DEP APIs
// based on the standard Go reverse proxy.
package proxy
import (
"errors"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"sync"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log/ctxlog"
)
// New creates new NanoDEP ReverseProxy. It dispatches requests using transport
// which should be a NanoDEP RoundTripper transport (which handles
// authentication and session management). DEP name configurations are retrieved
// using store and logger is used for logging.
func New(transport http.RoundTripper, store client.ConfigRetriever, logger log.Logger) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Transport: transport,
Director: newDirector(store, logger.With("function", "director")),
ErrorHandler: newErrorHandler(logger.With("msg", "proxy error")),
}
}
// newErrorHandler creates a new function for ReverseProxy.ErrorHandler.
func newErrorHandler(logger log.Logger) func(http.ResponseWriter, *http.Request, error) {
return func(rw http.ResponseWriter, req *http.Request, err error) {
// use the same error as the standrad reverse proxy
rw.WriteHeader(http.StatusBadGateway)
logger := ctxlog.Logger(req.Context(), logger)
var depErr *client.AuthError
if errors.As(err, &depErr) {
logger.Info(
"err", "DEP auth error",
"status", depErr.Status,
"body", string(depErr.Body),
)
// write the same body content to try and give some clue of what
// happened to the proxy user
_, _ = rw.Write(depErr.Body)
return
}
logger.Info("err", err)
}
}
// newDirector creates a new httputil.ReverseProxy director which dynamically
// resolves the destination server based on the config. The config name is
// retrieved from the request context using client.GetName. We also implement
// a parsed URL cache (which means the proxy may not be aware of underlying
// config changes).
func newDirector(store client.ConfigRetriever, logger log.Logger) func(*http.Request) {
urlCache := make(map[string]*url.URL)
urlCacheMu := sync.RWMutex{}
store = client.NewDefaultConfigRetreiver(store)
return func(req *http.Request) {
name := client.GetName(req.Context())
if name == "" {
ctxlog.Logger(req.Context(), logger).Info("err", "missing name")
// this will probably lead to a very broken proxy.
// but we can't really do anything about it here.
return
}
// attempt to read the URL from urlCache, or retreive it from store
urlCacheMu.RLock()
url := urlCache[name]
urlCacheMu.RUnlock()
if url == nil {
logger := ctxlog.Logger(req.Context(), logger).With("name", name)
config, err := store.RetrieveConfig(req.Context(), name)
if err != nil {
logger.Info("msg", "retrieve config", "err", err)
}
url, err = url.Parse(config.BaseURL)
if err != nil {
logger.Info("msg", "parse", "err", err)
// this will probably lead to a very broken proxy.
// but we can't really do anything about it here.
return
}
urlCacheMu.Lock()
urlCache[name] = url
urlCacheMu.Unlock()
}
// perform our actual request modifications (i.e. swapping in the
// correct DEP URL components based on the context)
req.URL.Scheme = url.Scheme
req.URL.Host = url.Host
req.Host = url.Host
}
}
// newCopiedRequest makes a copy of r with a new copy of r.URL and returns it.
func newCopiedRequest(r *http.Request) *http.Request {
r2 := new(http.Request)
*r2 = *r
r2.URL = new(url.URL)
*r2.URL = *r.URL
return r2
}
// ProxyDEPNameHandler tries to extract the DEP name from the request URL path
// and replaces it with the just the endpoint and embeds the name as a context
// value.
//
// For example if the request URL path is "hello/world/" then "hello" is the
// DEP name and is set in the request context and "/world/" is then set in the
// HTTP request passed onto p.
//
// Note the very beginning of the URL path is used as the DEP name. This
// necessitates stripping the URL prefix before using this handler. Note also
// that DEP names with a "/" or "%2F" are likely to cause issues as we naively
// search and cut by "/" in the path.
func ProxyDEPNameHandler(p *httputil.ReverseProxy, logger log.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r2 := newCopiedRequest(r)
name, endpoint, found := CutIncl(r.URL.Path, "/")
if found {
r2.URL.Path = endpoint
}
logger := ctxlog.Logger(r.Context(), logger)
if name == "" {
logger.Info("msg", "extracting DEP name", "err", "name not found in path")
http.NotFound(w, r)
return
}
// try to perform the same extraction on the RawPath as we did for Path
if r.URL.RawPath != "" {
if _, endpoint, found = CutIncl(r.URL.RawPath, "/"); found {
r2.URL.RawPath = endpoint
}
}
logger.Debug("msg", "proxy serve", "name", name, "endpoint", endpoint)
p.ServeHTTP(w, r2.WithContext(client.WithName(r2.Context(), name)))
}
}
// CutIncl is like strings.Cut but keeps sep in after.
func CutIncl(s, sep string) (before, after string, found bool) {
if i := strings.Index(s, sep); i >= 0 {
return s[:i], s[i:], true
}
return s, "", false
}

View File

@ -0,0 +1,200 @@
package file
import (
"context"
"encoding/json"
"errors"
"io/fs"
"os"
"path"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
)
const defaultFileMode = 0644
// FileStorage implements filesystem-based storage for DEP services.
type FileStorage struct {
path string
}
var _ storage.AllDEPStorage = (*FileStorage)(nil)
// New creates a new FileStorage backend.
func New(path string) (*FileStorage, error) {
err := os.Mkdir(path, 0755)
if err != nil {
if errors.Is(err, os.ErrExist) {
f, err := os.Stat(path)
if err != nil {
return nil, err
}
if !f.IsDir() {
return nil, errors.New("path is not a directory")
}
} else {
return nil, err
}
}
return &FileStorage{path: path}, nil
}
func (s *FileStorage) tokensFilename(name string) string {
return path.Join(s.path, name+".tokens.json")
}
func (s *FileStorage) configFilename(name string) string {
return path.Join(s.path, name+".config.json")
}
func (s *FileStorage) profileFilename(name string) string {
return path.Join(s.path, name+".profile.txt")
}
func (s *FileStorage) cursorFilename(name string) string {
return path.Join(s.path, name+".cursor.txt")
}
func (s *FileStorage) tokenpkiFilename(name, kind string) string {
return path.Join(s.path, name+".tokenpki."+kind+".txt")
}
// RetrieveAuthTokens reads the JSON DEP OAuth tokens from disk for name DEP name.
func (s *FileStorage) RetrieveAuthTokens(_ context.Context, name string) (*client.OAuth1Tokens, error) {
tokens := new(client.OAuth1Tokens)
err := decodeJSONfile(s.tokensFilename(name), tokens)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, storage.ErrNotFound
}
return nil, err
}
return tokens, nil
}
// StoreAuthTokens saves the DEP OAuth tokens to disk as JSON for name DEP name.
func (s *FileStorage) StoreAuthTokens(_ context.Context, name string, tokens *client.OAuth1Tokens) error {
f, err := os.Create(s.tokensFilename(name))
if err != nil {
return err
}
defer f.Close()
return json.NewEncoder(f).Encode(tokens)
}
func decodeJSONfile(filename string, v interface{}) error {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
return json.NewDecoder(f).Decode(v)
}
// RetrieveConfig reads the JSON DEP config from disk for name DEP name.
//
// Returns an empty config if the config does not exist (to support a fallback default config).
func (s *FileStorage) RetrieveConfig(_ context.Context, name string) (*client.Config, error) {
config := new(client.Config)
err := decodeJSONfile(s.configFilename(name), config)
if err != nil && errors.Is(err, os.ErrNotExist) {
// an 'empty' config is valid
return &client.Config{}, nil
}
return config, err
}
// StoreConfig saves the DEP config to disk as JSON for name DEP name.
func (s *FileStorage) StoreConfig(_ context.Context, name string, config *client.Config) error {
f, err := os.Create(s.configFilename(name))
if err != nil {
return err
}
defer f.Close()
return json.NewEncoder(f).Encode(config)
}
// RetrieveAssignerProfile reads the assigner profile UUID and its configured
// timestamp from disk for name DEP name.
//
// Returns an empty profile if it does not exist.
func (s *FileStorage) RetrieveAssignerProfile(_ context.Context, name string) (string, time.Time, error) {
profileBytes, err := os.ReadFile(s.profileFilename(name))
if err != nil && errors.Is(err, os.ErrNotExist) {
// an 'empty' profile is valid
return "", time.Time{}, nil
}
modTime := time.Time{}
if err == nil {
var stat fs.FileInfo
stat, err = os.Stat(s.profileFilename(name))
if err == nil {
modTime = stat.ModTime()
}
}
return strings.TrimSpace(string(profileBytes)), modTime, err
}
// StoreAssignerProfile saves the assigner profile UUID to disk for name DEP name.
func (s *FileStorage) StoreAssignerProfile(_ context.Context, name string, profileUUID string) error {
return os.WriteFile(s.profileFilename(name), []byte(profileUUID+"\n"), defaultFileMode)
}
// RetrieveCursor reads the reads the DEP fetch and sync cursor from disk
// for name DEP name. We return an empty cursor if the cursor does not exist
// on disk.
func (s *FileStorage) RetrieveCursor(_ context.Context, name string) (string, time.Time, error) {
cursorBytes, err := os.ReadFile(s.cursorFilename(name))
if err != nil && errors.Is(err, os.ErrNotExist) {
// an 'empty' cursor is valid
return "", time.Time{}, nil
}
modTime := time.Time{}
if err == nil {
var stat fs.FileInfo
stat, err = os.Stat(s.profileFilename(name))
if err == nil {
modTime = stat.ModTime()
}
}
return strings.TrimSpace(string(cursorBytes)), modTime, err
}
// StoreCursor saves the DEP fetch and sync cursor to disk for name DEP name.
func (s *FileStorage) StoreCursor(_ context.Context, name, cursor string) error {
return os.WriteFile(s.cursorFilename(name), []byte(cursor+"\n"), defaultFileMode)
}
// StoreTokenPKI stores the PEM bytes in pemCert and pemKey to disk for name DEP name.
func (s *FileStorage) StoreTokenPKI(_ context.Context, name string, pemCert []byte, pemKey []byte) error {
if err := os.WriteFile(s.tokenpkiFilename(name, "cert"), pemCert, 0664); err != nil { //nolint:gosec
return err
}
if err := os.WriteFile(s.tokenpkiFilename(name, "key"), pemKey, 0664); err != nil { //nolint:gosec
return err
}
return nil
}
// RetrieveTokenPKI reads and returns the PEM bytes for the DEP token exchange
// certificate and private key from disk using name DEP name.
func (s *FileStorage) RetrieveTokenPKI(_ context.Context, name string) ([]byte, []byte, error) {
certBytes, err := os.ReadFile(s.tokenpkiFilename(name, "cert"))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil, storage.ErrNotFound
}
return nil, nil, err
}
keyBytes, err := os.ReadFile(s.tokenpkiFilename(name, "key"))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil, storage.ErrNotFound
}
return nil, nil, err
}
return certBytes, keyBytes, err
}

View File

@ -0,0 +1,18 @@
package file
import (
"testing"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage/storagetest"
)
func TestFileStorage(t *testing.T) {
storagetest.Run(t, func(t *testing.T) storage.AllDEPStorage {
s, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
return s
})
}

View File

@ -0,0 +1,23 @@
---
version: "2"
services:
mysql:
image: ${NANODEP_MYSQL_IMAGE:-mysql:8.0.19}
platform: ${NANODEP_MYSQL_PLATFORM:-linux/x86_64}
command:
[
"mysqld",
"--datadir=/tmp/mysqldata",
"--log-bin=bin.log",
"--server-id=master-01"
]
environment:
MYSQL_ROOT_PASSWORD: toor
MYSQL_DATABASE: nanodep
MYSQL_USER: nanodep
MYSQL_PASSWORD: insecure
tmpfs:
- /var/lib/mysql:rw,noexec,nosuid
- /tmpfs
ports:
- "4242:3306"

View File

@ -0,0 +1,323 @@
package mysql
import (
"context"
"database/sql"
_ "embed"
"errors"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
)
// Schema contains the MySQL schema for the DEP storage.
//
//go:embed schema.sql
var Schema string
// MySQLStorage implements a storage.AllStorage using MySQL.
type MySQLStorage struct {
db *sql.DB
}
var _ storage.AllDEPStorage = (*MySQLStorage)(nil)
type config struct {
driver string
dsn string
db *sql.DB
}
// Option allows configuring a MySQLStorage.
type Option func(*config)
// WithDSN sets the storage MySQL data source name.
func WithDSN(dsn string) Option {
return func(c *config) {
c.dsn = dsn
}
}
// WithDriver sets a custom MySQL driver for the storage.
//
// Default driver is "mysql".
// Value is ignored if WithDB is used.
func WithDriver(driver string) Option {
return func(c *config) {
c.driver = driver
}
}
// WithDB sets a custom MySQL *sql.DB to the storage.
//
// If set, driver passed via WithDriver is ignored.
func WithDB(db *sql.DB) Option {
return func(c *config) {
c.db = db
}
}
// New creates and returns a new MySQLStorage.
func New(opts ...Option) (*MySQLStorage, error) {
cfg := &config{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}, nil
}
// RetrieveAuthTokens reads the DEP OAuth tokens for name DEP name.
func (s *MySQLStorage) RetrieveAuthTokens(ctx context.Context, name string) (*client.OAuth1Tokens, error) {
var (
consumerKey sql.NullString
consumerSecret sql.NullString
accessToken sql.NullString
accessSecret sql.NullString
accessTokenExpiry sql.NullTime
)
err := s.db.QueryRowContext(
ctx, `
SELECT
consumer_key,
consumer_secret,
access_token,
access_secret,
access_token_expiry
FROM
nano_dep_names
WHERE
name = ?;`,
name,
).Scan(
&consumerKey,
&consumerSecret,
&accessToken,
&accessSecret,
&accessTokenExpiry,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, storage.ErrNotFound
}
return nil, err
}
if !consumerKey.Valid { // all auth token fields are set together
return nil, storage.ErrNotFound
}
if err != nil {
return nil, err
}
return &client.OAuth1Tokens{
ConsumerKey: consumerKey.String,
ConsumerSecret: consumerSecret.String,
AccessToken: accessToken.String,
AccessSecret: accessSecret.String,
AccessTokenExpiry: accessTokenExpiry.Time,
}, nil
}
// StoreAuthTokens saves the DEP OAuth tokens for the DEP name.
func (s *MySQLStorage) StoreAuthTokens(ctx context.Context, name string, tokens *client.OAuth1Tokens) error {
_, err := s.db.ExecContext(
ctx, `
INSERT INTO nano_dep_names
(name, consumer_key, consumer_secret, access_token, access_secret, access_token_expiry)
VALUES
(?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
consumer_key = VALUES(consumer_key),
consumer_secret = VALUES(consumer_secret),
access_token = VALUES(access_token),
access_secret = VALUES(access_secret),
access_token_expiry = VALUES(access_token_expiry);`,
name,
tokens.ConsumerKey,
tokens.ConsumerSecret,
tokens.AccessToken,
tokens.AccessSecret,
tokens.AccessTokenExpiry,
)
return err
}
// RetrieveConfig reads the DEP config for the DEP name.
//
// Returns an empty config if the config does not exist (to support a fallback default config).
func (s *MySQLStorage) RetrieveConfig(ctx context.Context, name string) (*client.Config, error) {
var baseURL sql.NullString
err := s.db.QueryRowContext(
ctx,
`SELECT config_base_url FROM nano_dep_names WHERE name = ?;`,
name,
).Scan(
&baseURL,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// an 'empty' config is valid
return &client.Config{}, nil
}
return nil, err
}
var config client.Config
if baseURL.Valid {
config.BaseURL = baseURL.String
}
return &config, nil
}
// StoreConfig saves the DEP config for name DEP name.
func (s *MySQLStorage) StoreConfig(ctx context.Context, name string, config *client.Config) error {
_, err := s.db.ExecContext(
ctx, `
INSERT INTO nano_dep_names
(name, config_base_url)
VALUES
(?, ?)
ON DUPLICATE KEY UPDATE
config_base_url = VALUES(config_base_url);`,
name,
config.BaseURL,
)
return err
}
// RetrieveAssignerProfile reads the assigner profile UUID and its timestamp for name DEP name.
//
// Returns an empty profile if it does not exist.
func (s *MySQLStorage) RetrieveAssignerProfile(ctx context.Context, name string) (profileUUID string, modTime time.Time, err error) {
var (
profileUUID_ sql.NullString
modTime_ sql.NullTime
)
if err := s.db.QueryRowContext(
ctx,
`SELECT assigner_profile_uuid, assigner_profile_uuid_at FROM nano_dep_names WHERE name = ?;`,
name,
).Scan(
&profileUUID_, &modTime_,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// an 'empty' profile is valid
return "", time.Time{}, nil
}
return "", time.Time{}, err
}
if profileUUID_.Valid {
profileUUID = profileUUID_.String
}
if modTime_.Valid {
modTime = modTime_.Time
}
return profileUUID, modTime, nil
}
// StoreAssignerProfile saves the assigner profile UUID for name DEP name.
func (s *MySQLStorage) StoreAssignerProfile(ctx context.Context, name string, profileUUID string) error {
_, err := s.db.ExecContext(
ctx, `
INSERT INTO nano_dep_names
(name, assigner_profile_uuid, assigner_profile_uuid_at)
VALUES
(?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
assigner_profile_uuid = VALUES(assigner_profile_uuid),
assigner_profile_uuid_at = VALUES(assigner_profile_uuid_at);`,
name,
profileUUID,
)
return err
}
// RetrieveCursor reads the reads the DEP fetch and sync cursor for name DEP name.
//
// Returns an empty cursor if the cursor does not exist.
func (s *MySQLStorage) RetrieveCursor(ctx context.Context, name string) (cursor string, modTime time.Time, err error) {
var (
cursor_ sql.NullString
cursorAt sql.NullTime
)
if err := s.db.QueryRowContext(
ctx,
`SELECT syncer_cursor, syncer_cursor_at FROM nano_dep_names WHERE name = ?;`,
name,
).Scan(
&cursor_, &cursorAt,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", time.Time{}, nil
}
return "", time.Time{}, err
}
if !cursor_.Valid {
return "", time.Time{}, nil
}
return cursor_.String, cursorAt.Time, nil
}
// StoreCursor saves the DEP fetch and sync cursor for name DEP name.
func (s *MySQLStorage) StoreCursor(ctx context.Context, name, cursor string) error {
_, err := s.db.ExecContext(
ctx, `
INSERT INTO nano_dep_names
(name, syncer_cursor, syncer_cursor_at)
VALUES
(?, ?, CURRENT_TIMESTAMP)
ON DUPLICATE KEY UPDATE
syncer_cursor = VALUES(syncer_cursor),
syncer_cursor_at = VALUES(syncer_cursor_at);`,
name,
cursor,
)
return err
}
// StoreTokenPKI stores the PEM bytes in pemCert and pemKey for name DEP name.
func (s *MySQLStorage) StoreTokenPKI(ctx context.Context, name string, pemCert []byte, pemKey []byte) error {
_, err := s.db.ExecContext(
ctx, `
INSERT INTO nano_dep_names
(name, tokenpki_cert_pem, tokenpki_key_pem)
VALUES
(?, ?, ?)
ON DUPLICATE KEY UPDATE
tokenpki_cert_pem = VALUES(tokenpki_cert_pem),
tokenpki_key_pem = VALUES(tokenpki_key_pem);`,
name,
pemCert,
pemKey,
)
return err
}
// RetrieveTokenPKI reads the PEM bytes for the DEP token exchange certificate
// and private key using name DEP name.
func (s *MySQLStorage) RetrieveTokenPKI(ctx context.Context, name string) (pemCert []byte, pemKey []byte, err error) {
if err := s.db.QueryRowContext(
ctx,
`SELECT tokenpki_cert_pem, tokenpki_key_pem FROM nano_dep_names WHERE name = ?;`,
name,
).Scan(
&pemCert, &pemKey,
); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, storage.ErrNotFound
}
return nil, nil, err
}
if pemCert == nil { // tokenpki_cert_pem and tokenpki_key_pem are set together
return nil, nil, storage.ErrNotFound
}
return pemCert, pemKey, nil
}

View File

@ -0,0 +1,87 @@
package mysql
import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage/storagetest"
_ "github.com/go-sql-driver/mysql"
)
func TestMySQLStorage(t *testing.T) {
testDSN := os.Getenv("NANODEP_MYSQL_STORAGE_TEST")
if testDSN == "" {
t.Skip("NANODEP_MYSQL_STORAGE_TEST not set")
}
storagetest.Run(t, func(t *testing.T) storage.AllDEPStorage {
dbName := initTestDB(t)
testDSN := fmt.Sprintf("nanodep:insecure@tcp(localhost:4242)/%s?charset=utf8mb4&loc=UTC&parseTime=true", dbName)
s, err := New(WithDSN(testDSN))
if err != nil {
t.Fatal(err)
}
return s
})
}
// initTestDB clears any existing data from the database.
func initTestDB(t *testing.T) string {
rootDSN := "root:toor@tcp(localhost:4242)/?charset=utf8mb4&loc=UTC"
db, err := sql.Open("mysql", rootDSN)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()
for {
err := db.PingContext(ctx)
if err == nil {
break
}
t.Logf("failed to connect: %s, retrying connection", err)
select {
case <-time.After(1 * time.Second):
// OK, continue.
case <-ctx.Done():
t.Fatal("timeout connecting to MySQL")
}
}
defer func() {
if err := db.Close(); err != nil {
t.Fatal(err)
}
}()
dbName := dbName(t)
_, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s;", dbName))
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(fmt.Sprintf("CREATE DATABASE %s;", dbName))
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(fmt.Sprintf("USE %s;", dbName))
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(Schema)
if err != nil {
t.Fatal(err)
}
_, err = db.Exec(fmt.Sprintf("GRANT ALL PRIVILEGES ON %s.* TO 'nanodep';", dbName))
if err != nil {
t.Fatal(err)
}
return dbName
}
func dbName(t *testing.T) string {
return strings.ReplaceAll(strings.ReplaceAll(t.Name(), "/", "_"), "-", "_")
}

View File

@ -0,0 +1,34 @@
CREATE TABLE nano_dep_names (
name VARCHAR(255) NOT NULL,
-- OAuth1 Tokens
consumer_key TEXT NULL,
consumer_secret TEXT NULL,
access_token TEXT NULL,
access_secret TEXT NULL,
access_token_expiry TIMESTAMP NULL,
-- Config
config_base_url VARCHAR(255) NULL,
-- Token PKI
tokenpki_cert_pem TEXT NULL,
tokenpki_key_pem TEXT NULL,
-- Syncer
-- From Apple docs: "The string can be up to 1000 characters".
syncer_cursor VARCHAR(1024) NULL,
syncer_cursor_at TIMESTAMP NULL,
-- Assigner
assigner_profile_uuid TEXT NULL,
assigner_profile_uuid_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (name),
CHECK (tokenpki_cert_pem IS NULL OR SUBSTRING(tokenpki_cert_pem FROM 1 FOR 27) = '-----BEGIN CERTIFICATE-----'),
CHECK (tokenpki_key_pem IS NULL OR SUBSTRING(tokenpki_key_pem FROM 1 FOR 5) = '-----')
);

View File

@ -0,0 +1,27 @@
package storage
import (
"errors"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/http/api"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/sync"
)
// ErrNotFound is returned by AllStorage when a requested resource is not found.
var ErrNotFound = errors.New("resource not found")
// AllDEPStorage represents all possible required storage used by NanoDEP.
// Renamed from AllStorage to avoid ambiguity with the nanomdm AllStorage
// interface, which our mockimpl tool cannot resolve correctly.
type AllDEPStorage interface {
client.AuthTokensRetriever
client.ConfigRetriever
sync.AssignerProfileRetriever
sync.CursorStorage
api.AuthTokensStorer
api.ConfigStorer
api.TokenPKIStorer
api.TokenPKIRetriever
api.AssignerProfileStorer
}

View File

@ -0,0 +1,281 @@
// Package storagetest offers a battery of tests for storage.AllStorage implementations.
package storagetest
import (
"bytes"
"context"
"errors"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
)
// Run runs a battery of tests on the storage.AllStorage returned by storageFn.
func Run(t *testing.T, storageFn func(t *testing.T) storage.AllDEPStorage) {
ctx := context.Background()
// Test retrieval methods on empty storage.
t.Run("empty", func(t *testing.T) {
const name = "empty"
s := storageFn(t)
pemCert, pemKey, err := s.RetrieveTokenPKI(ctx, name)
if !errors.Is(err, storage.ErrNotFound) {
t.Fatalf("unexpected error: %s", err)
}
if pemCert != nil {
t.Fatal("expected nil cert pem")
}
if pemKey != nil {
t.Fatal("expected nil key pem")
}
tokens, err := s.RetrieveAuthTokens(ctx, name)
if !errors.Is(err, storage.ErrNotFound) {
t.Fatalf("unexpected error: %s", err)
}
if tokens != nil {
t.Fatal("expected nil tokens")
}
config, err := s.RetrieveConfig(ctx, name)
checkErr(t, err)
emptyConfig := client.Config{}
if config == nil || *config != emptyConfig {
t.Fatalf("expected empty config: %+v", config)
}
// Profile assigner storing and retrieval.
profileUUID, modTime, err := s.RetrieveAssignerProfile(ctx, name)
checkErr(t, err)
if profileUUID != "" {
t.Fatal("expected empty profileUUID")
}
if !modTime.IsZero() {
t.Fatal("expected zero modTime")
}
cursor, cursorAt, err := s.RetrieveCursor(ctx, name)
checkErr(t, err)
if cursor != "" {
t.Fatal("expected empty cursor")
}
if !cursorAt.IsZero() {
t.Fatal("expected empty cursor at")
}
})
testWithName := func(t *testing.T, name string, s storage.AllDEPStorage) {
// PKI storing and retrieval.
pemCert, pemKey, err := s.RetrieveTokenPKI(ctx, name)
if !errors.Is(err, storage.ErrNotFound) {
t.Fatalf("unexpected error: %s", err)
}
if err == nil {
t.Fatal("expected error")
}
if pemCert != nil {
t.Fatal("expected nil cert pem")
}
if pemKey != nil {
t.Fatal("expected nil key pem")
}
pemCert, pemKey = generatePKI(t, "basicdn", 1)
err = s.StoreTokenPKI(ctx, name, pemCert, pemKey)
checkErr(t, err)
pemCert2, pemKey2, err := s.RetrieveTokenPKI(ctx, name)
checkErr(t, err)
if !bytes.Equal(pemCert, pemCert2) {
t.Fatalf("pem cert mismatch: %s vs. %s", pemCert, pemCert2)
}
if !bytes.Equal(pemKey, pemKey2) {
t.Fatalf("pem key mismatch: %s vs. %s", pemKey, pemKey2)
}
// Token storing and retrieval.
tokens, err := s.RetrieveAuthTokens(ctx, name)
if !errors.Is(err, storage.ErrNotFound) {
t.Fatalf("unexpected error: %s", err)
}
if tokens != nil {
t.Fatal("expected nil tokens")
}
tokens = &client.OAuth1Tokens{
ConsumerKey: "CK_9af2f8218b150c351ad802c6f3d66abe",
ConsumerSecret: "CS_9af2f8218b150c351ad802c6f3d66abe",
AccessToken: "AT_9af2f8218b150c351ad802c6f3d66abe",
AccessSecret: "AS_9af2f8218b150c351ad802c6f3d66abe",
AccessTokenExpiry: time.Now().UTC(),
}
err = s.StoreAuthTokens(ctx, name, tokens)
checkErr(t, err)
tokens2, err := s.RetrieveAuthTokens(ctx, name)
checkErr(t, err)
checkTokens(t, tokens, tokens2)
tokens3 := &client.OAuth1Tokens{
ConsumerKey: "foo_CK_9af2f8218b150c351ad802c6f3d66abe",
ConsumerSecret: "foo_CS_9af2f8218b150c351ad802c6f3d66abe",
AccessToken: "foo_AT_9af2f8218b150c351ad802c6f3d66abe",
AccessSecret: "foo_AS_9af2f8218b150c351ad802c6f3d66abe",
AccessTokenExpiry: time.Now().Add(5 * time.Second).UTC(),
}
err = s.StoreAuthTokens(ctx, name, tokens3)
checkErr(t, err)
tokens4, err := s.RetrieveAuthTokens(ctx, name)
checkErr(t, err)
checkTokens(t, tokens3, tokens4)
// Config storing and retrieval.
config, err := s.RetrieveConfig(ctx, name)
checkErr(t, err)
emptyConfig := client.Config{}
if config == nil || *config != emptyConfig {
t.Fatalf("expected empty config: %+v", config)
}
config = &client.Config{
BaseURL: "https://config.example.com",
}
err = s.StoreConfig(ctx, name, config)
checkErr(t, err)
config2, err := s.RetrieveConfig(ctx, name)
checkErr(t, err)
if *config != *config2 {
t.Fatalf("config mismatch: %+v vs. %+v", config, config2)
}
config2 = &client.Config{
BaseURL: "https://config2.example.com",
}
err = s.StoreConfig(ctx, name, config2)
checkErr(t, err)
config3, err := s.RetrieveConfig(ctx, name)
checkErr(t, err)
if *config2 != *config3 {
t.Fatalf("config mismatch: %+v vs. %+v", config2, config3)
}
// Profile assigner storing and retrieval.
profileUUID, modTime, err := s.RetrieveAssignerProfile(ctx, name)
checkErr(t, err)
if profileUUID != "" {
t.Fatal("expected empty profileUUID")
}
if !modTime.IsZero() {
t.Fatal("expected zero modTime")
}
profileUUID = "43277A13FBCA0CFC"
err = s.StoreAssignerProfile(ctx, name, profileUUID)
checkErr(t, err)
profileUUID2, modTime, err := s.RetrieveAssignerProfile(ctx, name)
checkErr(t, err)
if profileUUID != profileUUID2 {
t.Fatalf("profileUUID mismatch: %s vs. %s", profileUUID, profileUUID2)
}
now := time.Now()
if modTime.Before(now.Add(-1*time.Minute)) || modTime.After(now.Add(1*time.Minute)) {
t.Fatalf("mismatch modTime, expected: %s (+/- 1m), actual: %s", now, modTime)
}
time.Sleep(1 * time.Second)
profileUUID3 := "foo_43277A13FBCA0CFC"
err = s.StoreAssignerProfile(ctx, name, profileUUID3)
checkErr(t, err)
profileUUID4, modTime2, err := s.RetrieveAssignerProfile(ctx, name)
checkErr(t, err)
if profileUUID3 != profileUUID4 {
t.Fatalf("profileUUID mismatch: %s vs. %s", profileUUID, profileUUID3)
}
if modTime2 == modTime {
t.Fatalf("expected time update: %s", modTime2)
}
now = time.Now()
if modTime2.Before(now.Add(-1*time.Minute)) || modTime2.After(now.Add(1*time.Minute)) {
t.Fatalf("mismatch modTime, expected: %s (+/- 1m), actual: %s", now, modTime)
}
cursor, modTime, err := s.RetrieveCursor(ctx, name)
checkErr(t, err)
if cursor != "" {
t.Fatal("expected empty cursor")
}
if !modTime.IsZero() {
t.Fatal("expected empty cursor at")
}
cursor = "MTY1NzI2ODE5Ny0x"
err = s.StoreCursor(ctx, name, cursor)
checkErr(t, err)
cursor2, modTime2, err := s.RetrieveCursor(ctx, name)
checkErr(t, err)
if cursor != cursor2 {
t.Fatalf("cursor mismatch: %s vs. %s", cursor, cursor2)
}
if modTime2.IsZero() {
t.Fatalf("expected cursor at to not be zero")
}
if now := time.Now(); modTime2.Before(now.Add(-1*time.Minute)) || modTime2.After(now.Add(1*time.Minute)) {
t.Fatalf("expected cursor at to be within bounds")
}
cursor2 = "foo_MTY1NzI2ODE5Ny0x"
err = s.StoreCursor(ctx, name, cursor2)
checkErr(t, err)
cursor3, modTime3, err := s.RetrieveCursor(ctx, name)
checkErr(t, err)
if cursor2 != cursor3 {
t.Fatalf("cursor mismatch: %s vs. %s", cursor2, cursor3)
}
if modTime3.Before(modTime2) {
t.Fatalf("cursor at should be later than previous")
}
}
t.Run("basic", func(t *testing.T) {
storage := storageFn(t)
testWithName(t, "basic", storage)
})
t.Run("multiple-names", func(t *testing.T) {
storage := storageFn(t)
testWithName(t, "name1", storage)
testWithName(t, "name2", storage)
})
}
func checkTokens(t *testing.T, t1 *client.OAuth1Tokens, t2 *client.OAuth1Tokens) {
if t1.ConsumerKey != t2.ConsumerKey {
t.Fatalf("tokens consumer_key mismatch: %s vs. %s", t1.ConsumerKey, t2.ConsumerKey)
}
if t1.ConsumerSecret != t2.ConsumerSecret {
t.Fatalf("tokens consumer_secret mismatch: %s vs. %s", t1.ConsumerSecret, t2.ConsumerSecret)
}
if t1.AccessToken != t2.AccessToken {
t.Fatalf("tokens access_token mismatch: %s vs. %s", t1.AccessToken, t2.AccessToken)
}
if t1.AccessSecret != t2.AccessSecret {
t.Fatalf("tokens access_secret mismatch: %s vs. %s", t1.AccessSecret, t2.AccessSecret)
}
diff := t1.AccessTokenExpiry.Sub(t2.AccessTokenExpiry)
if diff > 1*time.Second || diff < -1*time.Second {
t.Fatalf("tokens expiry mismatch: %s vs. %s", t1.AccessTokenExpiry, t2.AccessTokenExpiry)
}
}
func checkErr(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
func generatePKI(t *testing.T, cn string, days int) (pemCert []byte, pemKey []byte) {
key, cert, err := tokenpki.SelfSignedRSAKeypair(cn, days)
if err != nil {
t.Fatal(err)
}
pemCert = tokenpki.PEMCertificate(cert.Raw)
pemKey = tokenpki.PEMRSAPrivateKey(key)
return pemCert, pemKey
}

View File

@ -0,0 +1,160 @@
package sync
import (
"context"
"fmt"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log/ctxlog"
)
type AssignerProfileRetriever interface {
RetrieveAssignerProfile(ctx context.Context, name string) (profileUUID string, modTime time.Time, err error)
}
// Assigner assigns devices synced from the Apple DEP APIs to a profile UUID.
type Assigner struct {
client *godep.Client
name string
store AssignerProfileRetriever
logger log.Logger
debug bool
}
type AssignerOption func(*Assigner)
// NewAssigner creates a new Assigner from client and uses store to lookup
// assigner profile UUIDs. DEP name is specified with name.
func NewAssigner(client *godep.Client, name string, store AssignerProfileRetriever, opts ...AssignerOption) *Assigner {
assigner := &Assigner{
client: client,
name: name,
store: store,
logger: log.NopLogger,
}
for _, opt := range opts {
if opt != nil {
opt(assigner)
}
}
assigner.logger = assigner.logger.With("name", assigner.name)
return assigner
}
// WithAssignerLogger configures logger for the assigner.
func WithAssignerLogger(logger log.Logger) AssignerOption {
return func(a *Assigner) {
a.logger = logger
}
}
// WithDebug enables additional assigner-specific debug logging for troubleshooting.
func WithDebug() AssignerOption {
return func(a *Assigner) {
a.debug = true
}
}
// ProcessDeviceResponse processes the device response from the device sync
// DEP API endpoints and assigns the profile UUID associated with the DEP
// client DEP name.
func (a *Assigner) ProcessDeviceResponse(ctx context.Context, resp *godep.DeviceResponse) error {
if len(resp.Devices) < 1 {
// no devices means we can't assign anything
return nil
}
profileUUID, _, err := a.store.RetrieveAssignerProfile(ctx, a.name)
if err != nil {
return fmt.Errorf("retrieve profile: %w", err)
}
logger := ctxlog.Logger(ctx, a.logger)
if profileUUID == "" {
// empty UUID means we can't assign anything
if a.debug {
// the user could simply have not setup an assigner profile
// UUID yet. so hide this debug log behind the 'extra' debug
// setting to avoid unnecessary cause for concern.
logger.Debug("msg", "empty assigner profile UUID")
}
return nil
}
var serials []string
for _, device := range resp.Devices {
if a.debug {
logger.Debug(
"msg", "device",
"serial_number", device.SerialNumber,
"device_assigned_by", device.DeviceAssignedBy,
"device_assigned_date", device.DeviceAssignedDate,
"op_date", device.OpDate,
"op_type", device.OpType,
"profile_assign_time", device.ProfileAssignTime,
"push_push_time", device.ProfilePushTime,
"profile_uuid", device.ProfileUUID,
)
}
// We currently only listen for an op_type of "added", the other
// op_types are ambiguous and it would be needless to assign the
// profile UUID every single time we get an update.
if strings.ToLower(device.OpType) == "added" ||
// The op_type field is only applicable with the SyncDevices API call,
// Empty op_type come from the first call to FetchDevices without a cursor,
// and we do want to assign profiles to them.
strings.ToLower(device.OpType) == "" {
serials = append(serials, device.SerialNumber)
}
}
logger = logger.With("profile_uuid", profileUUID)
if len(serials) < 1 {
if a.debug {
logger.Debug(
"msg", "no serials to assign",
"devices", len(resp.Devices),
)
}
return nil
}
apiResp, err := a.client.AssignProfile(ctx, a.name, profileUUID, serials...)
if err != nil {
logger.Info(
"msg", "assign profile",
"devices", len(serials),
"err", err,
)
return fmt.Errorf("assign profile: %w", err)
}
logs := []interface{}{
"msg", "profile assigned",
"devices", len(serials),
}
logs = append(logs, logCountsForResults(apiResp.Devices)...)
logger.Info(logs...)
return nil
}
// logCountsForResults tries to aggregate the result types and log the counts.
func logCountsForResults(deviceResults map[string]string) (out []interface{}) {
results := map[string]int{"success": 0, "not_accessible": 0, "failed": 0, "other": 0}
for _, result := range deviceResults {
l := strings.ToLower(result)
if _, ok := results[l]; !ok {
l = "other"
}
results[l] += 1
}
for k, v := range results {
if v > 0 {
out = append(out, k, v)
}
}
return
}

View File

@ -0,0 +1,248 @@
// Package sync provides services to sync devices and assign profile UUIDs
// using the Apple DEP APIs.
package sync
import (
"context"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/log/ctxlog"
)
// CursorStorage is where the device fetch and sync cursor can be stored and
// retrieved for a given DEP name.
type CursorStorage interface {
RetrieveCursor(ctx context.Context, name string) (cursor string, modTime time.Time, err error)
StoreCursor(ctx context.Context, name string, cursor string) error
}
// DeviceResponseCallback is called every time a fetch or sync operation completes.
type DeviceResponseCallback func(context.Context, bool, *godep.DeviceResponse) error
// Syncer performs the fetch and sync cursor operations to sync devices from
// the Apple DEP service. Depending on the options supplied it can perform the
// sync continuously on a duration or just once. See the various SyncerOptions
// for configuring the behavior of the syncer.
type Syncer struct {
client *godep.Client
name string
store CursorStorage
logger log.Logger
duration time.Duration
limitOpt godep.DeviceRequestOption
callback DeviceResponseCallback
// in "continuous" mode this is a channel that is selected on to interrupt
// the duration wait to immediately perform the next sync operation(s).
syncNow <-chan struct{}
}
type SyncerOption func(*Syncer)
// WithLogger configures logger for the syncer.
func WithLogger(logger log.Logger) SyncerOption {
return func(syncer *Syncer) {
syncer.logger = logger
}
}
// WithDuration sets the "mode" of operation. If not set or set to 0 then
// the mode is "run once" and if a duration is provided then the syncer
// operates in "continuous" mode and will start a ticker for duration to wait
// beween sync cycles.
func WithDuration(duration time.Duration) SyncerOption {
return func(syncer *Syncer) {
syncer.duration = duration
}
}
// WithSyncNow specifies the channel to select on which will advise the syncer
// to end its sync wait and perform the next immediate sync.
func WithSyncNow(syncNow <-chan struct{}) SyncerOption {
return func(syncer *Syncer) {
syncer.syncNow = syncNow
}
}
// WithLimit sets the device sync limit for each fetch and sync.
func WithLimit(limit int) SyncerOption {
return func(syncer *Syncer) {
syncer.limitOpt = godep.WithLimit(limit)
}
}
// WithCallback sets the callback function to call for each fetch and sync.
func WithCallback(cb DeviceResponseCallback) SyncerOption {
return func(s *Syncer) {
s.callback = cb
}
}
// NewSyncer creates a new Syncer using client and uses store for cursor
// storage. DEP name is specified with name.
func NewSyncer(client *godep.Client, name string, store CursorStorage, opts ...SyncerOption) *Syncer {
syncer := &Syncer{
client: client,
name: name,
store: store,
logger: log.NopLogger,
}
for _, opt := range opts {
opt(syncer)
}
syncer.logger = syncer.logger.With("name", syncer.name)
return syncer
}
// Run starts a device fetch and sync loop. Errors from the DEP API are
// generally ignored so that the sync can continue on (i.e. we assume API
// errors are transient). However if a cursor storage error or other "hard"
// error occurs then the loop will end. The loop will end if the context gets
// cancelled. The loop will also exit early if there is no duration option set
// (i.e. is in "run once" mode).
func (s *Syncer) Run(ctx context.Context) error {
doFetch := true
// phaseLabel is for logging based on the value of doFetch
phaseLabel := map[bool]string{
true: "fetch",
false: "sync",
}
var resp *godep.DeviceResponse
cursor, _, err := s.store.RetrieveCursor(ctx, s.name)
if err != nil {
return err
}
logger := ctxlog.Logger(ctx, s.logger)
// set our run mode (once vs. continuous)
var ticker *time.Ticker
if s.duration > 0 {
ticker = time.NewTicker(s.duration)
logger.Debug("msg", "starting timer", "duration", s.duration)
}
for {
opts := make([]godep.DeviceRequestOption, 1, 2)
opts[0] = godep.WithCursor(cursor)
if s.limitOpt != nil {
opts = append(opts, s.limitOpt)
}
if doFetch {
resp, err = s.client.FetchDevices(ctx, s.name, opts...)
if err != nil && godep.IsCursorExhausted(err) {
logger.Debug(
"msg", "cursor returned all devices previously",
"phase", phaseLabel[doFetch],
"cursor", cursor,
)
// we only see an exhausted cursor response on a fetch.
// immediately move to a sync.
doFetch = false
continue
}
} else {
resp, err = s.client.SyncDevices(ctx, s.name, opts...)
}
if err != nil {
if godep.IsCursorExpired(err) || godep.IsCursorInvalid(err) {
logger.Info(
"msg", "cursor error, retrying with empty cursor",
"phase", phaseLabel[doFetch],
"cursor", cursor,
"err", err,
)
// note: this will re-fetch the entire device list
cursor = ""
doFetch = true
continue
}
logger.Info(
"msg", "error syncing",
"phase", phaseLabel[doFetch],
"cursor", cursor,
"err", err,
)
// errors are only logged and we just try again during the next cycle
} else {
logs := []interface{}{
"msg", "device sync",
"phase", phaseLabel[doFetch],
"more", resp.MoreToFollow,
"cursor", resp.Cursor,
"devices", len(resp.Devices),
}
if !resp.FetchedUntil.IsZero() {
// these just gunk up the logs if they're zero
logs = append(logs, "fetched_until", resp.FetchedUntil)
}
logs = append(logs, logCountsForOpTypes(doFetch, resp.Devices)...)
logger.Info(logs...)
if s.callback != nil {
err = s.callback(ctx, doFetch, resp)
if err != nil {
logger.Info("msg", "syncer callback", "err", err)
}
}
if cursor != resp.Cursor {
err = s.store.StoreCursor(ctx, s.name, resp.Cursor)
if err != nil {
return err
}
cursor = resp.Cursor
}
if resp.MoreToFollow {
continue
} else if doFetch {
doFetch = false
continue
}
}
// if we're in "run once" mode then return after one cycle
if ticker == nil {
return nil
}
select {
case <-ticker.C:
case <-s.syncNow:
logger.Debug("msg", "device sync: explicit sync requested")
case <-ctx.Done():
return ctx.Err()
}
}
}
// logCountsForOpTypes tries to aggregate the various device "op_type"
// attributes so they can be logged.
func logCountsForOpTypes(isFetch bool, devices []godep.Device) []interface{} {
opTypes := map[string]int{"added": 0, "modified": 0, "deleted": 0, "other": 0}
var opType string
for _, device := range devices {
// normalize API input
opType = strings.ToLower(device.OpType)
if isFetch && opType == "" {
// it seems no op_type is provided for a fetch sync
continue
}
// we don't want to necessarily trust arbitrary op_types so restrict
// our logging to some presets
if _, ok := opTypes[opType]; !ok {
opType = "other"
}
opTypes[opType] += 1
}
var logs []interface{}
for k, v := range opTypes {
if v > 0 {
logs = append(logs, "op_type_"+k, v)
}
}
return logs
}

View File

@ -0,0 +1,77 @@
package tokenpki
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"time"
)
// SelfSignedRSAKeypair generates a 2048-bit RSA private key and self-signs an
// X.509 certificate using it. You can set the Common Name in cn and the
// validity duration with days.
func SelfSignedRSAKeypair(cn string, days int) (*rsa.PrivateKey, *x509.Certificate, error) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, err
}
timeNow := time.Now()
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: cn,
},
NotBefore: timeNow.Add(time.Minute * -10),
NotAfter: timeNow.Add(time.Duration(days) * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
}
certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
if err != nil {
return nil, nil, err
}
cert, err := x509.ParseCertificate(certBytes)
if err != nil {
return nil, nil, err
}
return key, cert, err
}
// PEMRSAPrivateKey returns key as a PEM block.
func PEMRSAPrivateKey(key *rsa.PrivateKey) []byte {
block := &pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
}
return pem.EncodeToMemory(block)
}
// RSAKeyFromPEM decodes a PEM RSA private key.
func RSAKeyFromPEM(key []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(key)
if block.Type != "RSA PRIVATE KEY" {
return nil, errors.New("PEM type is not RSA PRIVATE KEY")
}
return x509.ParsePKCS1PrivateKey(block.Bytes)
}
// PEMCertificate returns derBytes encoded as a PEM block.
func PEMCertificate(derBytes []byte) []byte {
block := &pem.Block{
Type: "CERTIFICATE",
Bytes: derBytes,
}
return pem.EncodeToMemory(block)
}
// CertificateFromPEM decodes a PEM certificate.
func CertificateFromPEM(cert []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(cert)
if block.Type != "CERTIFICATE" {
return nil, errors.New("PEM type is not CERTIFICATE")
}
return x509.ParseCertificate(block.Bytes)
}

View File

@ -0,0 +1,73 @@
// Package tokenpki includes helpers and utilities for exchanging certificates
// and parsing token PKCS#7 S/MIME messages from the Apple ABM/ASM/BE portals.
package tokenpki
import (
"bufio"
"bytes"
"crypto"
"crypto/x509"
"encoding/base64"
"io"
"net/textproto"
"go.mozilla.org/pkcs7"
)
// UnwrapSMIME removes the S/MIME-like header wrapper around the raw encrypted
// CMS/PKCS#7 data in the downloaded token ".p7m" file from the ABM/ASM/BE
// portal.
func UnwrapSMIME(smime []byte) ([]byte, error) {
r := textproto.NewReader(bufio.NewReader(bytes.NewReader(smime)))
if _, err := r.ReadMIMEHeader(); err != nil {
return nil, err
}
d := base64.NewDecoder(base64.StdEncoding, r.DotReader())
b := new(bytes.Buffer)
_, _ = io.Copy(b, d) // writes to bytes.Buffer never fail
return b.Bytes(), nil
}
// UnwrapTokenJSON removes the S/MIME-like header wrapper around the
// the decrypted JSON tokens from the token header.
func UnwrapTokenJSON(wrapped []byte) ([]byte, error) {
r := textproto.NewReader(bufio.NewReader(bytes.NewReader(wrapped)))
if _, err := r.ReadMIMEHeader(); err != nil {
return nil, err
}
tokenJSON := new(bytes.Buffer)
for {
line, err := r.ReadLineBytes()
if err != nil {
if err == io.EOF {
break
}
return nil, err
}
line = bytes.TrimPrefix(line, []byte("-----BEGIN MESSAGE-----"))
line = bytes.TrimPrefix(line, []byte("-----END MESSAGE-----"))
_, err = tokenJSON.Write(line)
if err != nil {
return nil, err
}
}
return tokenJSON.Bytes(), nil
}
// DecryptTokenJSON decrypts and decodes the downloaded token ".p7m" file from
// the ABM/ASM/BE portal to return the actual JSON contained within.
func DecryptTokenJSON(tokenBytes []byte, cert *x509.Certificate, key crypto.PrivateKey) ([]byte, error) {
p7Bytes, err := UnwrapSMIME(tokenBytes)
if err != nil {
return nil, err
}
p7, err := pkcs7.Parse(p7Bytes)
if err != nil {
return nil, err
}
decrypted, err := p7.Decrypt(cert, key)
if err != nil {
return nil, err
}
return UnwrapTokenJSON(decrypted)
}

View File

@ -0,0 +1,39 @@
# DEP tools and scripts
A set of example shell scripts and tools for working with the `depserver` proxy and configuration APIs. For more information how and why you might use these scripts please see the [Operations Guide](../docs/operations-guide.md).
## Requirements
These scripts require a couple tools to be in your shell path:
* [curl](https://curl.se/)
* [jq](https://stedolan.github.io/jq/)
* Bourne-ish shell interpreter.
## Setup
For these scripts to work you have to have a few environment variables set first. You could embed these into their own file and use `source` to set them if you'd like to re-use them.
```bash
# the URL of the running depserver
export BASE_URL='http://[::1]:9001'
# should match the -api switch of the depserver
export APIKEY=supersecret
# the DEP name (instance) you want to use
export DEP_NAME=mdmserver1
```
Be cautious to unset these variables or exit the shell when you're done so as not to leave API keys hanging out in environment variables. Also beware the API key is provided to `curl` on the command line and will likely be visible in process lists.
## Example
First setup the environment variables per above then the scripts can be executed:
```bash
# get a the account details
% ./dep-account-detail.sh
{
"server_name": "Example Server",
...
}
```

View File

@ -0,0 +1,9 @@
#!/bin/sh
URL="${BASE_URL}/v1/tokenpki/${DEP_NAME}"
curl \
$CURL_OPTS \
-u "depserver:$APIKEY" \
-T "$1" \
"$URL"

View File

@ -0,0 +1,8 @@
#!/bin/sh
URL="${BASE_URL}/v1/tokenpki/${DEP_NAME}"
curl \
$CURL_OPTS \
-u "depserver:$APIKEY" \
"$URL"

View File

@ -0,0 +1,9 @@
#!/bin/sh
URL="${BASE_URL}/v1/assigner/${DEP_NAME}?profile_uuid=$1"
curl \
$CURL_OPTS \
-u "depserver:$APIKEY" \
-X PUT \
"$URL"

View File

@ -0,0 +1,13 @@
#!/bin/sh
# See https://developer.apple.com/documentation/devicemanagement/get_account_detail
DEP_ENDPOINT=/account
URL="${BASE_URL}/proxy/${DEP_NAME}${DEP_ENDPOINT}"
curl \
$CURL_OPTS \
-u depserver:$APIKEY \
-A "nanodep-tools/0" \
"$URL"

View File

@ -0,0 +1,15 @@
#!/bin/sh
# See https://developer.apple.com/documentation/devicemanagement/define_a_profile
DEP_ENDPOINT=/profile
URL="${BASE_URL}/proxy/${DEP_NAME}${DEP_ENDPOINT}"
curl \
$CURL_OPTS \
-u "depserver:$APIKEY" \
-X POST \
-H 'Content-type: application/json;charset=UTF8' \
-T "$1" \
-A "nanodep-tools/0" \
"$URL"

View File

@ -0,0 +1,16 @@
#!/bin/sh
# See https://developer.apple.com/documentation/devicemanagement/get_device_details
DEP_ENDPOINT=/devices
URL="${BASE_URL}/proxy/${DEP_NAME}${DEP_ENDPOINT}"
jq -n --arg device "$1" '.devices = [$device]' \
| curl \
$CURL_OPTS \
-u "depserver:$APIKEY" \
-X POST \
-H 'Content-type: application/json;charset=UTF8' \
--data-binary @- \
-A "nanodep-tools/0" \
"$URL"

View File

@ -0,0 +1,12 @@
#!/bin/sh
# See https://developer.apple.com/documentation/devicemanagement/get_a_profile
DEP_ENDPOINT=/profile
URL="${BASE_URL}/proxy/${DEP_NAME}${DEP_ENDPOINT}?profile_uuid=$1"
curl \
$CURL_OPTS \
-u "depserver:$APIKEY" \
-A "nanodep-tools/0" \
"$URL"

View File

@ -0,0 +1,17 @@
#!/bin/sh
# See https://developer.apple.com/documentation/devicemanagement/remove_a_profile-c2c
# Note that while the docs contain a profile_uuid field it is not required.
DEP_ENDPOINT=/profile/devices
URL="${BASE_URL}/proxy/${DEP_NAME}${DEP_ENDPOINT}"
jq -n --arg device "$1" '.devices = [$device]' \
| curl \
$CURL_OPTS \
-u "depserver:$APIKEY" \
-X DELETE \
-H 'Content-type: application/json;charset=UTF8' \
--data-binary @- \
-A "nanodep-tools/0" \
"$URL"

View File

@ -8,7 +8,7 @@ import (
//go:generate go run ./mockimpl/impl.go -o datastore_mock.go "s *DataStore" "fleet.Datastore"
//go:generate go run ./mockimpl/impl.go -o datastore_installers.go "s *InstallerStore" "fleet.InstallerStore"
//go:generate go run ./mockimpl/impl.go -o nanodep/storage.go "s *Storage" "github.com/micromdm/nanodep/storage.AllStorage"
//go:generate go run ./mockimpl/impl.go -o nanodep/storage.go "s *Storage" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage.AllDEPStorage"
//go:generate go run ./mockimpl/impl.go -o datastore_mdm_mock.go "fs *MDMAppleStore" "fleet.MDMAppleStore"
//go:generate go run ./mockimpl/impl.go -o scep/depot.go "d *Depot" "depot.Depot"

View File

@ -13,7 +13,7 @@ import (
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/micromdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
)
var _ fleet.Datastore = (*DataStore)(nil)

View File

@ -7,11 +7,11 @@ import (
"sync"
"time"
"github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/storage"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
)
var _ storage.AllStorage = (*Storage)(nil)
var _ storage.AllDEPStorage = (*Storage)(nil)
type RetrieveAuthTokensFunc func(ctx context.Context, name string) (*client.OAuth1Tokens, error)

View File

@ -18,11 +18,11 @@ import (
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mock"
nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View File

@ -29,6 +29,7 @@ import (
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/appmanifest"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/sso"
"github.com/fleetdm/fleet/v4/server/worker"
@ -36,7 +37,6 @@ import (
"github.com/go-kit/log/level"
"github.com/google/uuid"
"github.com/groob/plist"
"github.com/micromdm/nanodep/godep"
)
type getMDMAppleCommandResultsRequest struct {

View File

@ -31,6 +31,7 @@ import (
fleetmdm "github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/stdlogfmt"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
@ -40,7 +41,6 @@ import (
"github.com/fleetdm/fleet/v4/server/test"
kitlog "github.com/go-kit/kit/log"
"github.com/google/uuid"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/stretchr/testify/require"
)

View File

@ -19,11 +19,11 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/tokenpki"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mozilla.org/pkcs7"

View File

@ -41,6 +41,10 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
nanodep_storage "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
@ -56,10 +60,6 @@ import (
"github.com/groob/plist"
"github.com/jmoiron/sqlx"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/godep"
nanodep_storage "github.com/micromdm/nanodep/storage"
"github.com/micromdm/nanodep/tokenpki"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
@ -78,7 +78,7 @@ type integrationMDMTestSuite struct {
fleetCfg config.FleetConfig
fleetDMNextCSRStatus atomic.Value
pushProvider *mock.APNSPushProvider
depStorage nanodep_storage.AllStorage
depStorage nanodep_storage.AllDEPStorage
depSchedule *schedule.Schedule
profileSchedule *schedule.Schedule
onProfileJobDone func() // function called when profileSchedule.Trigger() job completed

View File

@ -15,12 +15,12 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
nanodep_storage "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
nanomdm_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
nanomdm_storage "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
"github.com/fleetdm/fleet/v4/server/service/async"
"github.com/fleetdm/fleet/v4/server/sso"
kitlog "github.com/go-kit/kit/log"
nanodep_storage "github.com/micromdm/nanodep/storage"
)
var _ fleet.Service = (*Service)(nil)
@ -54,7 +54,7 @@ type Service struct {
*fleet.EnterpriseOverrides
depStorage nanodep_storage.AllStorage
depStorage nanodep_storage.AllDEPStorage
mdmStorage nanomdm_storage.AllStorage
mdmPushService nanomdm_push.Pusher
mdmPushCertTopic string
@ -103,7 +103,7 @@ func NewService(
failingPolicySet fleet.FailingPolicySet,
geoIP fleet.GeoIP,
enrollHostLimiter fleet.EnrollHostLimiter,
depStorage nanodep_storage.AllStorage,
depStorage nanodep_storage.AllDEPStorage,
mdmStorage fleet.MDMAppleStore,
mdmPushService nanomdm_push.Pusher,
mdmPushCertTopic string,

View File

@ -23,6 +23,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mail"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
nanodep_storage "github.com/fleetdm/fleet/v4/server/mdm/nanodep/storage"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
nanomdm_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
@ -35,7 +36,6 @@ import (
"github.com/fleetdm/fleet/v4/server/test"
kitlog "github.com/go-kit/kit/log"
"github.com/google/uuid"
nanodep_storage "github.com/micromdm/nanodep/storage"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/throttled/throttled/v2"
@ -55,11 +55,11 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
logger := kitlog.NewNopLogger()
var (
failingPolicySet fleet.FailingPolicySet = NewMemFailingPolicySet()
enrollHostLimiter fleet.EnrollHostLimiter = nopEnrollHostLimiter{}
depStorage nanodep_storage.AllStorage = &nanodep_mock.Storage{}
mailer fleet.MailService = &mockMailService{SendEmailFn: func(e fleet.Email) error { return nil }}
c clock.Clock = clock.C
failingPolicySet fleet.FailingPolicySet = NewMemFailingPolicySet()
enrollHostLimiter fleet.EnrollHostLimiter = nopEnrollHostLimiter{}
depStorage nanodep_storage.AllDEPStorage = &nanodep_mock.Storage{}
mailer fleet.MailService = &mockMailService{SendEmailFn: func(e fleet.Email) error { return nil }}
c clock.Clock = clock.C
is fleet.InstallerStore
mdmStorage fleet.MDMAppleStore
@ -280,7 +280,7 @@ type TestServerOpts struct {
Is fleet.InstallerStore
FleetConfig *config.FleetConfig
MDMStorage fleet.MDMAppleStore
DEPStorage nanodep_storage.AllStorage
DEPStorage nanodep_storage.AllDEPStorage
SCEPStorage scep_depot.Depot
MDMPusher nanomdm_push.Pusher
HTTPServerConfig *http.Server

View File

@ -7,9 +7,9 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"github.com/micromdm/nanodep/godep"
)
// Name of the macos setup assistant job as registered in the worker. Note that

View File

@ -15,10 +15,10 @@ import (
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/ptr"
kitlog "github.com/go-kit/kit/log"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/godep"
"github.com/stretchr/testify/require"
)

View File

@ -19,9 +19,9 @@ import (
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
kitlog "github.com/go-kit/kit/log"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/godep"
)
func main() {