Created
March 8, 2026 23:18
-
-
Save TanJay/de55dc3c3f5d00c70d9f7ec91199656d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # ============================================================================= | |
| # NanoMDM — Full Stack Setup Script | |
| # Generates PKI, Docker Compose, Caddy config, and enrollment profile. | |
| # Run as a non-root user with sudo access on Ubuntu 22.04. | |
| # | |
| # Usage: | |
| # chmod +x nanomdm-setup.sh | |
| # ./nanomdm-setup.sh | |
| # | |
| # Optional HSM mode (Nitrokey HSM 2): | |
| # HSM=1 ./nanomdm-setup.sh | |
| # ============================================================================= | |
| set -euo pipefail | |
| # ── Colour output ───────────────────────────────────────────────────────────── | |
| RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m' | |
| BLU='\033[0;34m'; CYN='\033[0;36m'; BOLD='\033[1m'; RST='\033[0m' | |
| banner() { echo -e "\n${BOLD}${BLU}══ $* ══${RST}"; } | |
| ok() { echo -e " ${GRN}✔${RST} $*"; } | |
| info() { echo -e " ${CYN}→${RST} $*"; } | |
| warn() { echo -e " ${YEL}⚠${RST} $*"; } | |
| die() { echo -e "\n${RED}✖ ERROR: $*${RST}\n" >&2; exit 1; } | |
| ask() { echo -e -n " ${BOLD}?${RST} $* : "; } | |
| # ── Configuration — edit these before running ───────────────────────────────── | |
| DOMAIN="${DOMAIN:-yourdomain.com}" # e.g. home.example.com | |
| ORG="${ORG:-Homelab}" | |
| COUNTRY="${COUNTRY:-US}" | |
| MDM_SUBDOMAIN="mdm" | |
| SCEP_SUBDOMAIN="scep" | |
| PUSH_SUBDOMAIN="push" | |
| BASE_DIR="${BASE_DIR:-$HOME/mdm}" | |
| PKI_DIR="$BASE_DIR/pki" | |
| COMPOSE_DIR="$BASE_DIR" | |
| HSM="${HSM:-0}" # set to 1 to enable Nitrokey HSM 2 | |
| MDM_HOST="$MDM_SUBDOMAIN.$DOMAIN" | |
| SCEP_HOST="$SCEP_SUBDOMAIN.$DOMAIN" | |
| PUSH_HOST="$PUSH_SUBDOMAIN.$DOMAIN" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # STEP 0 — Welcome & collect config | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| clear | |
| echo -e "${BOLD}" | |
| echo " ███╗ ██╗ █████╗ ███╗ ██╗ ██████╗ ███╗ ███╗██████╗ ███╗ ███╗" | |
| echo " ████╗ ██║██╔══██╗████╗ ██║██╔═══██╗████╗ ████║██╔══██╗████╗ ████║" | |
| echo " ██╔██╗ ██║███████║██╔██╗ ██║██║ ██║██╔████╔██║██║ ██║██╔████╔██║" | |
| echo " ██║╚██╗██║██╔══██║██║╚██╗██║██║ ██║██║╚██╔╝██║██║ ██║██║╚██╔╝██║" | |
| echo " ██║ ╚████║██║ ██║██║ ╚████║╚██████╔╝██║ ╚═╝ ██║██████╔╝██║ ╚═╝ ██║" | |
| echo " ╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝" | |
| echo -e "${RST}" | |
| echo -e " ${BOLD}Self-Hosted MDM Setup — macOS · iOS · iPadOS${RST}" | |
| echo -e " PKI · SCEP · APNs · Docker · Caddy · Cloudflare TLS" | |
| echo "" | |
| if [[ "$DOMAIN" == "yourdomain.com" ]]; then | |
| ask "Your domain (e.g. home.example.com)" | |
| read -r DOMAIN | |
| [[ -z "$DOMAIN" ]] && die "Domain cannot be empty" | |
| MDM_HOST="$MDM_SUBDOMAIN.$DOMAIN" | |
| SCEP_HOST="$SCEP_SUBDOMAIN.$DOMAIN" | |
| PUSH_HOST="$PUSH_SUBDOMAIN.$DOMAIN" | |
| fi | |
| ask "Organisation name [$ORG]" | |
| read -r INPUT_ORG; ORG="${INPUT_ORG:-$ORG}" | |
| ask "Country code (2 letters) [$COUNTRY]" | |
| read -r INPUT_CC; COUNTRY="${INPUT_CC:-$COUNTRY}" | |
| ask "Cloudflare API token (Zone:DNS:Edit)" | |
| read -rs CF_API_TOKEN; echo "" | |
| [[ -z "$CF_API_TOKEN" ]] && die "Cloudflare API token cannot be empty" | |
| # Generate a secure API key for NanoMDM | |
| MDM_API_KEY=$(openssl rand -hex 20) | |
| SCEP_CHALLENGE=$(openssl rand -base64 24 | tr -d '/+=') | |
| echo "" | |
| info "Domain : $DOMAIN" | |
| info "MDM host : $MDM_HOST" | |
| info "SCEP host : $SCEP_HOST" | |
| info "Push host : $PUSH_HOST" | |
| info "Base dir : $BASE_DIR" | |
| [[ "$HSM" == "1" ]] && info "HSM mode : ${GRN}ENABLED (Nitrokey HSM 2)${RST}" | |
| echo "" | |
| ask "Looks correct? Press ENTER to continue or Ctrl-C to abort" | |
| read -r | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # STEP 1 — Preflight checks | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| banner "STEP 1 — Preflight checks" | |
| check_cmd() { | |
| command -v "$1" &>/dev/null || die "$1 is required but not installed. $2" | |
| ok "$1 found" | |
| } | |
| check_cmd openssl "Install: sudo apt install openssl" | |
| check_cmd docker "Install: https://docs.docker.com/engine/install/ubuntu/" | |
| check_cmd curl "Install: sudo apt install curl" | |
| # Check docker compose (v2 plugin) | |
| docker compose version &>/dev/null || die "Docker Compose v2 required. Install via 'sudo apt install docker-compose-plugin'" | |
| ok "docker compose found" | |
| if [[ "$HSM" == "1" ]]; then | |
| check_cmd pkcs11-tool "Install: sudo apt install opensc" | |
| check_cmd p11tool "Install: sudo apt install gnutls-bin" | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # STEP 2 — Create directory structure | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| banner "STEP 2 — Directory structure" | |
| mkdir -p \ | |
| "$PKI_DIR/root-ca" \ | |
| "$PKI_DIR/intermediate-ca" \ | |
| "$PKI_DIR/scep" \ | |
| "$PKI_DIR/push" \ | |
| "$PKI_DIR/nanomdm" \ | |
| "$BASE_DIR/db" \ | |
| "$BASE_DIR/static" \ | |
| "$BASE_DIR/profiles" | |
| # Lock down PKI directory | |
| chmod 700 "$PKI_DIR" | |
| ok "Created: $BASE_DIR" | |
| info "Layout:" | |
| echo "" | |
| echo " $BASE_DIR/" | |
| echo " ├── pki/" | |
| echo " │ ├── root-ca/ ← Root CA key + cert" | |
| echo " │ ├── intermediate-ca/ ← Intermediate CA (signs SCEP device certs)" | |
| echo " │ ├── scep/ ← SCEP server depot" | |
| echo " │ ├── push/ ← APNs push certificate ← YOU PROVIDE THIS" | |
| echo " │ └── nanomdm/ ← NanoMDM identity cert" | |
| echo " ├── db/ ← NanoMDM database" | |
| echo " ├── static/ ← Hosted enrollment profiles" | |
| echo " ├── profiles/ ← Payload templates" | |
| echo " ├── docker-compose.yml" | |
| echo " ├── Caddyfile" | |
| echo " └── .env" | |
| echo "" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # STEP 3 — PKI generation (software keys OR Nitrokey HSM 2) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| banner "STEP 3 — PKI generation" | |
| if [[ "$HSM" == "1" ]]; then | |
| # ── HSM path ────────────────────────────────────────────────────────────── | |
| info "HSM mode: generating keys on Nitrokey HSM 2" | |
| echo "" | |
| warn "Ensure your Nitrokey HSM 2 is plugged in before continuing." | |
| ask "Press ENTER when ready" | |
| read -r | |
| # Detect PKCS#11 library | |
| PKCS11_LIB="" | |
| for lib in \ | |
| /usr/lib/x86_64-linux-gnu/pkcs11/opensc-pkcs11.so \ | |
| /usr/lib/opensc-pkcs11.so \ | |
| /usr/lib64/pkcs11/opensc-pkcs11.so; do | |
| [[ -f "$lib" ]] && { PKCS11_LIB="$lib"; break; } | |
| done | |
| [[ -z "$PKCS11_LIB" ]] && die "opensc-pkcs11.so not found. Install: sudo apt install opensc" | |
| ok "PKCS#11 library: $PKCS11_LIB" | |
| # Initialize HSM if needed | |
| info "Checking HSM status..." | |
| pkcs11-tool --module "$PKCS11_LIB" -T 2>/dev/null | grep -q "token" \ | |
| || die "No HSM token detected. Check USB connection." | |
| ok "HSM token detected" | |
| warn "If this is a fresh HSM, you may be prompted to set SO PIN and User PIN." | |
| info "Generating Root CA key on HSM (slot 0, key ID 01)..." | |
| pkcs11-tool --module "$PKCS11_LIB" \ | |
| --login --login-type user \ | |
| --keypairgen --key-type rsa:4096 \ | |
| --id 01 --label "mdm-root-ca" \ | |
| --token-label "NitrokeyHSM" \ | |
| 2>&1 | grep -v "^Using" || true | |
| ok "Root CA key generated on HSM (ID 01, label: mdm-root-ca)" | |
| info "Generating Intermediate CA key on HSM (ID 02)..." | |
| pkcs11-tool --module "$PKCS11_LIB" \ | |
| --login --login-type user \ | |
| --keypairgen --key-type rsa:4096 \ | |
| --id 02 --label "mdm-intermediate-ca" \ | |
| --token-label "NitrokeyHSM" \ | |
| 2>&1 | grep -v "^Using" || true | |
| ok "Intermediate CA key generated on HSM (ID 02)" | |
| info "Generating NanoMDM identity key on HSM (ID 03)..." | |
| pkcs11-tool --module "$PKCS11_LIB" \ | |
| --login --login-type user \ | |
| --keypairgen --key-type rsa:2048 \ | |
| --id 03 --label "mdm-identity" \ | |
| --token-label "NitrokeyHSM" \ | |
| 2>&1 | grep -v "^Using" || true | |
| ok "NanoMDM identity key generated on HSM (ID 03)" | |
| # Create OpenSSL PKCS11 engine config | |
| OPENSSL_HSM_CONF="$PKI_DIR/openssl-hsm.cnf" | |
| cat > "$OPENSSL_HSM_CONF" << EOF | |
| openssl_conf = openssl_init | |
| [openssl_init] | |
| engines = engine_section | |
| [engine_section] | |
| pkcs11 = pkcs11_section | |
| [pkcs11_section] | |
| engine_id = pkcs11 | |
| dynamic_path = /usr/lib/engines-3/pkcs11.so | |
| MODULE_PATH = $PKCS11_LIB | |
| init = 0 | |
| EOF | |
| # Self-sign Root CA cert using HSM key | |
| info "Signing Root CA cert with HSM key..." | |
| OPENSSL_CONF="$OPENSSL_HSM_CONF" \ | |
| openssl req -new -x509 \ | |
| -engine pkcs11 -keyform engine \ | |
| -key "pkcs11:token=NitrokeyHSM;id=%01;type=private" \ | |
| -sha256 -days 3650 \ | |
| -subj "/CN=$ORG Root CA/O=$ORG/C=$COUNTRY" \ | |
| -out "$PKI_DIR/root-ca/root-ca.crt" | |
| ok "Root CA cert: $PKI_DIR/root-ca/root-ca.crt" | |
| # Intermediate CA CSR + sign | |
| info "Creating Intermediate CA CSR..." | |
| OPENSSL_CONF="$OPENSSL_HSM_CONF" \ | |
| openssl req -new \ | |
| -engine pkcs11 -keyform engine \ | |
| -key "pkcs11:token=NitrokeyHSM;id=%02;type=private" \ | |
| -subj "/CN=$ORG Intermediate CA/O=$ORG/C=$COUNTRY" \ | |
| -out "$PKI_DIR/intermediate-ca/intermediate-ca.csr" | |
| info "Signing Intermediate CA with Root CA (on HSM)..." | |
| OPENSSL_CONF="$OPENSSL_HSM_CONF" \ | |
| openssl x509 -req \ | |
| -engine pkcs11 -keyform engine \ | |
| -in "$PKI_DIR/intermediate-ca/intermediate-ca.csr" \ | |
| -CA "$PKI_DIR/root-ca/root-ca.crt" \ | |
| -CAkey "pkcs11:token=NitrokeyHSM;id=%01;type=private" \ | |
| -CAcreateserial \ | |
| -out "$PKI_DIR/intermediate-ca/intermediate-ca.crt" \ | |
| -days 1825 -sha256 \ | |
| -extfile <(printf '[v3_ca]\nbasicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign') | |
| ok "Intermediate CA cert signed" | |
| # NanoMDM identity cert | |
| info "Creating NanoMDM identity CSR..." | |
| OPENSSL_CONF="$OPENSSL_HSM_CONF" \ | |
| openssl req -new \ | |
| -engine pkcs11 -keyform engine \ | |
| -key "pkcs11:token=NitrokeyHSM;id=%03;type=private" \ | |
| -subj "/CN=$MDM_HOST/O=$ORG/C=$COUNTRY" \ | |
| -out "$PKI_DIR/nanomdm/nanomdm-identity.csr" | |
| OPENSSL_CONF="$OPENSSL_HSM_CONF" \ | |
| openssl x509 -req \ | |
| -engine pkcs11 -keyform engine \ | |
| -in "$PKI_DIR/nanomdm/nanomdm-identity.csr" \ | |
| -CA "$PKI_DIR/intermediate-ca/intermediate-ca.crt" \ | |
| -CAkey "pkcs11:token=NitrokeyHSM;id=%02;type=private" \ | |
| -CAcreateserial \ | |
| -out "$PKI_DIR/nanomdm/nanomdm-identity.crt" \ | |
| -days 825 -sha256 | |
| ok "NanoMDM identity cert signed" | |
| # Export public key refs for SCEP (SCEP server needs software keys — copy certs only) | |
| info "Note: SCEP server requires software key access. Generating software Intermediate CA key for SCEP..." | |
| warn "This is a software copy of the Intermediate key used by SCEP only. CA root key stays on HSM." | |
| openssl genrsa -out "$PKI_DIR/scep/ca.key" 4096 2>/dev/null | |
| openssl req -new \ | |
| -key "$PKI_DIR/scep/ca.key" \ | |
| -subj "/CN=$ORG SCEP CA/O=$ORG/C=$COUNTRY" \ | |
| -out "$PKI_DIR/scep/ca.csr" | |
| openssl x509 -req \ | |
| -in "$PKI_DIR/scep/ca.csr" \ | |
| -CA "$PKI_DIR/intermediate-ca/intermediate-ca.crt" \ | |
| -CAkey "pkcs11:token=NitrokeyHSM;id=%02;type=private" \ | |
| -CAcreateserial \ | |
| -out "$PKI_DIR/scep/ca.crt" \ | |
| -days 1825 -sha256 | |
| ok "SCEP CA key + cert ready" | |
| # Save HSM key references for NanoMDM (it will use PKCS11 engine) | |
| echo "$PKCS11_LIB" > "$PKI_DIR/nanomdm/hsm_pkcs11_lib.txt" | |
| echo "pkcs11:token=NitrokeyHSM;id=%03;type=private" > "$PKI_DIR/nanomdm/hsm_key_uri.txt" | |
| ok "HSM key URIs saved" | |
| else | |
| # ── Software key path ────────────────────────────────────────────────────── | |
| info "Software mode: generating keys on disk (consider HSM=1 for production)" | |
| # Root CA | |
| info "Generating Root CA key (4096-bit RSA)..." | |
| openssl genrsa -out "$PKI_DIR/root-ca/root-ca.key" 4096 2>/dev/null | |
| openssl req -x509 -new -nodes \ | |
| -key "$PKI_DIR/root-ca/root-ca.key" \ | |
| -sha256 -days 3650 \ | |
| -subj "/CN=$ORG Root CA/O=$ORG/C=$COUNTRY" \ | |
| -out "$PKI_DIR/root-ca/root-ca.crt" | |
| chmod 600 "$PKI_DIR/root-ca/root-ca.key" | |
| ok "Root CA: $PKI_DIR/root-ca/" | |
| # Intermediate CA | |
| info "Generating Intermediate CA key..." | |
| openssl genrsa -out "$PKI_DIR/intermediate-ca/intermediate-ca.key" 4096 2>/dev/null | |
| openssl req -new \ | |
| -key "$PKI_DIR/intermediate-ca/intermediate-ca.key" \ | |
| -subj "/CN=$ORG Intermediate CA/O=$ORG/C=$COUNTRY" \ | |
| -out "$PKI_DIR/intermediate-ca/intermediate-ca.csr" | |
| openssl x509 -req \ | |
| -in "$PKI_DIR/intermediate-ca/intermediate-ca.csr" \ | |
| -CA "$PKI_DIR/root-ca/root-ca.crt" \ | |
| -CAkey "$PKI_DIR/root-ca/root-ca.key" \ | |
| -CAcreateserial \ | |
| -out "$PKI_DIR/intermediate-ca/intermediate-ca.crt" \ | |
| -days 1825 -sha256 \ | |
| -extfile <(printf '[v3_ca]\nbasicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign') | |
| chmod 600 "$PKI_DIR/intermediate-ca/intermediate-ca.key" | |
| ok "Intermediate CA: $PKI_DIR/intermediate-ca/" | |
| # NanoMDM identity cert | |
| info "Generating NanoMDM identity cert..." | |
| openssl genrsa -out "$PKI_DIR/nanomdm/nanomdm-identity.key" 2048 2>/dev/null | |
| openssl req -new \ | |
| -key "$PKI_DIR/nanomdm/nanomdm-identity.key" \ | |
| -subj "/CN=$MDM_HOST/O=$ORG/C=$COUNTRY" \ | |
| -out "$PKI_DIR/nanomdm/nanomdm-identity.csr" | |
| openssl x509 -req \ | |
| -in "$PKI_DIR/nanomdm/nanomdm-identity.csr" \ | |
| -CA "$PKI_DIR/intermediate-ca/intermediate-ca.crt" \ | |
| -CAkey "$PKI_DIR/intermediate-ca/intermediate-ca.key" \ | |
| -CAcreateserial \ | |
| -out "$PKI_DIR/nanomdm/nanomdm-identity.crt" \ | |
| -days 825 -sha256 | |
| chmod 600 "$PKI_DIR/nanomdm/nanomdm-identity.key" | |
| ok "NanoMDM identity cert: $PKI_DIR/nanomdm/" | |
| # SCEP CA (copy of Intermediate) | |
| cp "$PKI_DIR/intermediate-ca/intermediate-ca.key" "$PKI_DIR/scep/ca.key" | |
| cp "$PKI_DIR/intermediate-ca/intermediate-ca.crt" "$PKI_DIR/scep/ca.crt" | |
| chmod 600 "$PKI_DIR/scep/ca.key" | |
| ok "SCEP CA depot: $PKI_DIR/scep/" | |
| fi | |
| # Full chain for SCEP and enrollment profile | |
| cat "$PKI_DIR/intermediate-ca/intermediate-ca.crt" \ | |
| "$PKI_DIR/root-ca/root-ca.crt" \ | |
| > "$PKI_DIR/intermediate-ca/ca-chain.crt" | |
| cp "$PKI_DIR/intermediate-ca/ca-chain.crt" "$PKI_DIR/scep/ca-chain.crt" | |
| ok "CA chain bundle created" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # STEP 4 — APNs push certificate — user must provide this | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| banner "STEP 4 — APNs Push Certificate" | |
| echo "" | |
| warn "The APNs push certificate CANNOT be generated automatically." | |
| warn "Apple requires you to obtain it from identity.apple.com/pushcert" | |
| echo "" | |
| echo -e " ${BOLD}Here is exactly what you need to do:${RST}" | |
| echo "" | |
| echo " 1. On your Mac, open Keychain Access" | |
| echo " → Certificate Assistant → Request a Certificate from a CA" | |
| echo " → Fill in your email + common name → Saved to disk" | |
| echo " → Save as: push-csr.certSigningRequest" | |
| echo "" | |
| echo " 2. Build the signing tool on your Mac:" | |
| echo " git clone https://github.com/micromdm/nanomdm && cd nanomdm" | |
| echo " go build ./cmd/nanomdm" | |
| echo "" | |
| echo " 3. Generate a vendor signing key (one-time):" | |
| echo " openssl req -nodes -x509 -newkey rsa:2048 \\" | |
| echo " -keyout vendor.key -out vendor.crt -days 3650 \\" | |
| echo " -subj '/CN=MDM Vendor'" | |
| echo "" | |
| echo " 4. Sign your CSR:" | |
| echo " openssl x509 -req -in push-csr.certSigningRequest \\" | |
| echo " -signkey vendor.key -out signed.pem -days 365" | |
| echo "" | |
| echo " 5. Go to: https://identity.apple.com/pushcert" | |
| echo " → Create a certificate → upload signed.pem" | |
| echo " → Download → rename to: push-cert.pem" | |
| echo "" | |
| echo " 6. Place the file here:" | |
| echo -e " ${BOLD}$PKI_DIR/push/push-cert.pem${RST}" | |
| echo "" | |
| echo " 7. Place your push private key (vendor.key) here:" | |
| echo -e " ${BOLD}$PKI_DIR/push/push.key${RST}" | |
| echo "" | |
| # Wait for user to place the cert | |
| while true; do | |
| if [[ -f "$PKI_DIR/push/push-cert.pem" && -f "$PKI_DIR/push/push.key" ]]; then | |
| ok "push-cert.pem found" | |
| ok "push.key found" | |
| # Extract push topic | |
| PUSH_TOPIC=$(openssl x509 -in "$PKI_DIR/push/push.pem" -noout -subject 2>/dev/null \ | |
| | grep -oP 'UID=\K[^,/]+' || true) | |
| if [[ -z "$PUSH_TOPIC" ]]; then | |
| PUSH_TOPIC=$(openssl x509 -in "$PKI_DIR/push/push-cert.pem" -noout -subject \ | |
| | sed 's/.*UID=\([^,/]*\).*/\1/') | |
| fi | |
| ok "Push topic: $PUSH_TOPIC" | |
| break | |
| fi | |
| echo "" | |
| ask "Press ENTER once you have placed push-cert.pem and push.key in $PKI_DIR/push/" | |
| read -r | |
| done | |
| chmod 600 "$PKI_DIR/push/push.key" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # STEP 5 — Write .env file | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| banner "STEP 5 — Environment file" | |
| cat > "$COMPOSE_DIR/.env" << EOF | |
| # NanoMDM environment — generated by nanomdm-setup.sh | |
| # Keep this file private: chmod 600 .env | |
| MDM_API_KEY=$MDM_API_KEY | |
| SCEP_CHALLENGE=$SCEP_CHALLENGE | |
| CF_API_TOKEN=$CF_API_TOKEN | |
| DOMAIN=$DOMAIN | |
| MDM_HOST=$MDM_HOST | |
| SCEP_HOST=$SCEP_HOST | |
| PUSH_HOST=$PUSH_HOST | |
| PUSH_TOPIC=$PUSH_TOPIC | |
| EOF | |
| chmod 600 "$COMPOSE_DIR/.env" | |
| ok "Written: $COMPOSE_DIR/.env" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # STEP 6 — Docker Compose | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| banner "STEP 6 — Docker Compose" | |
| # Determine identity key source for NanoMDM command | |
| if [[ "$HSM" == "1" ]]; then | |
| NANOMDM_KEY_FLAG="-key pkcs11:token=NitrokeyHSM;id=%03;type=private" | |
| HSM_VOLUME=" - /usr/lib/x86_64-linux-gnu/pkcs11:/pkcs11:ro" | |
| HSM_ENV=" - OPENSSL_CONF=/etc/nanomdm/openssl-hsm.cnf" | |
| else | |
| NANOMDM_KEY_FLAG="-key /certs/nanomdm-identity.key" | |
| HSM_VOLUME="" | |
| HSM_ENV="" | |
| fi | |
| cat > "$COMPOSE_DIR/docker-compose.yml" << EOF | |
| # Generated by nanomdm-setup.sh | |
| # Domain: $DOMAIN | |
| version: '3.8' | |
| services: | |
| # ── NanoMDM core ──────────────────────────────────────────────────────────── | |
| nanomdm: | |
| image: ghcr.io/micromdm/nanomdm:latest | |
| restart: unless-stopped | |
| volumes: | |
| - ./db:/db | |
| - ./pki/nanomdm:/certs:ro | |
| - ./pki/push:/push:ro | |
| - ./pki/root-ca:/root-ca:ro | |
| $([ -n "$HSM_VOLUME" ] && echo "$HSM_VOLUME") | |
| environment: | |
| $([ -n "$HSM_ENV" ] && echo "$HSM_ENV") | |
| command: > | |
| -storage file | |
| -storage-dsn /db | |
| -cert /certs/nanomdm-identity.crt | |
| $NANOMDM_KEY_FLAG | |
| -ca /root-ca/root-ca.crt | |
| -push-cert /push/push-cert.pem | |
| -push-key /push/push.key | |
| -api-key \${MDM_API_KEY} | |
| ports: | |
| - "127.0.0.1:9000:9000" | |
| healthcheck: | |
| test: ["CMD", "curl", "-f", "http://localhost:9000/healthz"] | |
| interval: 30s | |
| timeout: 5s | |
| retries: 3 | |
| # ── SCEP server ───────────────────────────────────────────────────────────── | |
| scep: | |
| image: ghcr.io/micromdm/scep:latest | |
| restart: unless-stopped | |
| volumes: | |
| - ./pki/scep:/depot:ro | |
| command: > | |
| -depot /depot | |
| -challenge \${SCEP_CHALLENGE} | |
| -port 8080 | |
| ports: | |
| - "127.0.0.1:8080:8080" | |
| healthcheck: | |
| test: ["CMD", "curl", "-f", "http://localhost:8080/scep?operation=GetCACert"] | |
| interval: 30s | |
| timeout: 5s | |
| retries: 3 | |
| # ── APNs push proxy ────────────────────────────────────────────────────────── | |
| nanopush: | |
| image: ghcr.io/micromdm/nanomdm:latest | |
| restart: unless-stopped | |
| volumes: | |
| - ./pki/push:/push:ro | |
| entrypoint: /app/nanomdm | |
| command: > | |
| -push-proxy | |
| -push-cert /push/push-cert.pem | |
| -push-key /push/push.key | |
| ports: | |
| - "127.0.0.1:9001:9001" | |
| # ── Caddy reverse proxy ────────────────────────────────────────────────────── | |
| caddy: | |
| image: ghcr.io/caddybuilds/caddy-cloudflare:latest | |
| restart: unless-stopped | |
| ports: | |
| - "80:80" | |
| - "443:443" | |
| - "443:443/udp" | |
| volumes: | |
| - ./Caddyfile:/etc/caddy/Caddyfile:ro | |
| - caddy_data:/data | |
| - caddy_config:/config | |
| - ./static:/var/mdm/static:ro | |
| environment: | |
| - CF_API_TOKEN=\${CF_API_TOKEN} | |
| depends_on: | |
| - nanomdm | |
| - scep | |
| - nanopush | |
| volumes: | |
| caddy_data: | |
| caddy_config: | |
| EOF | |
| ok "Written: $COMPOSE_DIR/docker-compose.yml" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # STEP 7 — Caddyfile | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| banner "STEP 7 — Caddyfile" | |
| cat > "$COMPOSE_DIR/Caddyfile" << EOF | |
| # Generated by nanomdm-setup.sh | |
| { | |
| email admin@$DOMAIN | |
| } | |
| # MDM server | |
| $MDM_HOST { | |
| tls { | |
| dns cloudflare {env.CF_API_TOKEN} | |
| } | |
| # Serve enrollment profile | |
| handle /enroll.mobileconfig { | |
| root * /var/mdm/static | |
| file_server | |
| } | |
| # MDM endpoints | |
| handle { | |
| reverse_proxy nanomdm:9000 | |
| } | |
| } | |
| # SCEP server | |
| $SCEP_HOST { | |
| tls { | |
| dns cloudflare {env.CF_API_TOKEN} | |
| } | |
| reverse_proxy scep:8080 | |
| } | |
| # APNs push proxy | |
| $PUSH_HOST { | |
| tls { | |
| dns cloudflare {env.CF_API_TOKEN} | |
| } | |
| reverse_proxy nanopush:9001 | |
| } | |
| EOF | |
| ok "Written: $COMPOSE_DIR/Caddyfile" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # STEP 8 — Enrollment profile | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| banner "STEP 8 — Enrollment profile" | |
| ROOT_CA_B64=$(base64 -w 0 "$PKI_DIR/root-ca/root-ca.crt") | |
| UUID_ROOT=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen) | |
| UUID_SCEP=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen) | |
| UUID_MDM=$( cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen) | |
| UUID_OUTER=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen) | |
| cat > "$COMPOSE_DIR/profiles/enroll.mobileconfig" << EOF | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" | |
| "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"> | |
| <dict> | |
| <key>PayloadContent</key> | |
| <array> | |
| <!-- Payload 1: Trust Root CA --> | |
| <dict> | |
| <key>PayloadType</key> <string>com.apple.security.root</string> | |
| <key>PayloadIdentifier</key> <string>$(echo "$ORG" | tr '[:upper:]' '[:lower:]' | tr ' ' '.').mdm.root-ca</string> | |
| <key>PayloadUUID</key> <string>$UUID_ROOT</string> | |
| <key>PayloadVersion</key> <integer>1</integer> | |
| <key>PayloadContent</key> | |
| <data>$ROOT_CA_B64</data> | |
| </dict> | |
| <!-- Payload 2: SCEP — device client certificate --> | |
| <dict> | |
| <key>PayloadType</key> <string>com.apple.security.scep</string> | |
| <key>PayloadIdentifier</key> <string>$(echo "$ORG" | tr '[:upper:]' '[:lower:]' | tr ' ' '.').mdm.scep</string> | |
| <key>PayloadUUID</key> <string>$UUID_SCEP</string> | |
| <key>PayloadVersion</key> <integer>1</integer> | |
| <key>PayloadContent</key> | |
| <dict> | |
| <key>URL</key> <string>https://$SCEP_HOST/scep</string> | |
| <key>Name</key> <string>$(echo "$ORG" | tr ' ' '')MDM</string> | |
| <key>Challenge</key> <string>$SCEP_CHALLENGE</string> | |
| <key>Subject</key> | |
| <array> | |
| <array><array><string>O</string><string>$ORG</string></array></array> | |
| <array><array><string>CN</string><string>%HardwareUUID%</string></array></array> | |
| </array> | |
| <key>KeyType</key> <string>RSA</string> | |
| <key>KeySize</key> <integer>2048</integer> | |
| <key>KeyUsage</key> <integer>5</integer> | |
| </dict> | |
| </dict> | |
| <!-- Payload 3: MDM --> | |
| <dict> | |
| <key>PayloadType</key> <string>com.apple.mdm</string> | |
| <key>PayloadIdentifier</key> <string>$(echo "$ORG" | tr '[:upper:]' '[:lower:]' | tr ' ' '.').mdm</string> | |
| <key>PayloadUUID</key> <string>$UUID_MDM</string> | |
| <key>PayloadVersion</key> <integer>1</integer> | |
| <key>ServerURL</key> | |
| <string>https://$MDM_HOST/mdm</string> | |
| <key>CheckInURL</key> | |
| <string>https://$MDM_HOST/checkin</string> | |
| <key>Topic</key> | |
| <string>$PUSH_TOPIC</string> | |
| <key>IdentityCertificateUUID</key> | |
| <string>$UUID_SCEP</string> | |
| <key>AccessRights</key> <integer>8191</integer> | |
| <key>CheckOutWhenRemoved</key><true/> | |
| </dict> | |
| </array> | |
| <key>PayloadIdentifier</key> <string>$(echo "$ORG" | tr '[:upper:]' '[:lower:]' | tr ' ' '.').mdm.enroll</string> | |
| <key>PayloadType</key> <string>Configuration</string> | |
| <key>PayloadUUID</key> <string>$UUID_OUTER</string> | |
| <key>PayloadVersion</key> <integer>1</integer> | |
| <key>PayloadDisplayName</key> <string>$ORG MDM Enrollment</string> | |
| <key>PayloadDescription</key> <string>Enrolls this device into $ORG Mobile Device Management.</string> | |
| </dict> | |
| </plist> | |
| EOF | |
| ok "Written: $COMPOSE_DIR/profiles/enroll.mobileconfig" | |
| # Sign the profile with the NanoMDM identity cert if openssl smime is available | |
| if command -v openssl &>/dev/null; then | |
| openssl smime -sign -nodetach \ | |
| -signer "$PKI_DIR/nanomdm/nanomdm-identity.crt" \ | |
| -inkey "$PKI_DIR/nanomdm/nanomdm-identity.key" \ | |
| -certfile "$PKI_DIR/intermediate-ca/ca-chain.crt" \ | |
| -in "$COMPOSE_DIR/profiles/enroll.mobileconfig" \ | |
| -out "$COMPOSE_DIR/static/enroll.mobileconfig" \ | |
| -outform PEM 2>/dev/null \ | |
| && ok "Profile signed → $COMPOSE_DIR/static/enroll.mobileconfig" \ | |
| || { warn "Profile signing failed (HSM key? Sign manually). Copying unsigned."; \ | |
| cp "$COMPOSE_DIR/profiles/enroll.mobileconfig" "$COMPOSE_DIR/static/enroll.mobileconfig"; } | |
| fi | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # STEP 9 — DNS profile (NextDNS) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| banner "STEP 9 — DNS enforcement profile" | |
| cat > "$COMPOSE_DIR/profiles/dns-nextdns.mobileconfig" << EOF | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" | |
| "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"> | |
| <dict> | |
| <key>PayloadContent</key> | |
| <array> | |
| <dict> | |
| <key>PayloadType</key> <string>com.apple.dnsSettings.managed</string> | |
| <key>PayloadIdentifier</key> <string>$(echo "$ORG" | tr '[:upper:]' '[:lower:]' | tr ' ' '.').dns.nextdns</string> | |
| <key>PayloadUUID</key> <string>$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)</string> | |
| <key>PayloadVersion</key> <integer>1</integer> | |
| <key>DNSSettings</key> | |
| <dict> | |
| <key>DNSProtocol</key> <string>HTTPS</string> | |
| <!-- EDIT: Replace YOUR_NEXTDNS_ID with your NextDNS profile ID --> | |
| <key>ServerURL</key> | |
| <string>https://dns.nextdns.io/YOUR_NEXTDNS_ID</string> | |
| <key>ServerAddresses</key> | |
| <array> | |
| <string>45.90.28.0</string> | |
| <string>45.90.30.0</string> | |
| </array> | |
| </dict> | |
| </dict> | |
| </array> | |
| <key>PayloadType</key> <string>Configuration</string> | |
| <key>PayloadUUID</key> <string>$(cat /proc/sys/kernel/random/uuid 2>/dev/null || uuidgen)</string> | |
| <key>PayloadIdentifier</key> <string>$(echo "$ORG" | tr '[:upper:]' '[:lower:]' | tr ' ' '.').dns</string> | |
| <key>PayloadVersion</key> <integer>1</integer> | |
| <key>PayloadDisplayName</key> <string>$ORG DNS (NextDNS)</string> | |
| </dict> | |
| </plist> | |
| EOF | |
| ok "Written: $COMPOSE_DIR/profiles/dns-nextdns.mobileconfig" | |
| info "Edit the NextDNS profile ID before pushing it to devices" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # STEP 10 — Start Docker stack | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| banner "STEP 10 — Starting Docker stack" | |
| cd "$COMPOSE_DIR" | |
| docker compose pull --quiet | |
| docker compose up -d | |
| ok "Docker stack started" | |
| info "Waiting 10s for services to initialise..." | |
| sleep 10 | |
| # Health checks | |
| echo "" | |
| info "Running health checks..." | |
| check_service() { | |
| local name="$1" url="$2" | |
| if curl -sf --max-time 5 "$url" &>/dev/null; then | |
| ok "$name is up" | |
| else | |
| warn "$name did not respond at $url (may still be starting)" | |
| fi | |
| } | |
| check_service "NanoMDM" "http://127.0.0.1:9000/healthz" | |
| check_service "SCEP" "http://127.0.0.1:8080/scep?operation=GetCACert" | |
| check_service "Push proxy" "http://127.0.0.1:9001/healthz" | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # DONE — Summary | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| banner "Setup complete" | |
| echo "" | |
| echo -e "${BOLD} ┌─────────────────────────────────────────────────────────┐${RST}" | |
| echo -e "${BOLD} │ NanoMDM Stack Summary │${RST}" | |
| echo -e "${BOLD} ├─────────────────────────────────────────────────────────┤${RST}" | |
| echo -e " │ MDM server : https://$MDM_HOST" | |
| echo -e " │ SCEP server : https://$SCEP_HOST" | |
| echo -e " │ Push proxy : https://$PUSH_HOST" | |
| echo -e " │ API key : $MDM_API_KEY" | |
| echo -e " │ SCEP challenge: $SCEP_CHALLENGE" | |
| echo -e "${BOLD} └─────────────────────────────────────────────────────────┘${RST}" | |
| echo "" | |
| echo -e " ${BOLD}Next steps:${RST}" | |
| echo "" | |
| echo " 1. Create Cloudflare DNS A records pointing to this VPS IP:" | |
| echo " mdm.$DOMAIN → <VPS_IP>" | |
| echo " scep.$DOMAIN → <VPS_IP>" | |
| echo " push.$DOMAIN → <VPS_IP>" | |
| echo "" | |
| echo " 2. Supervise your devices with Apple Configurator 2 (Mac App Store)" | |
| echo " — this ERASES the device, backup first" | |
| echo "" | |
| echo " 3. Install the enrollment profile on supervised devices:" | |
| echo " https://$MDM_HOST/enroll.mobileconfig" | |
| echo "" | |
| echo " 4. Verify enrollment:" | |
| echo " curl -u nanomdm:$MDM_API_KEY https://$MDM_HOST/v1/enrollments" | |
| echo "" | |
| echo " 5. Edit $COMPOSE_DIR/profiles/dns-nextdns.mobileconfig" | |
| echo " Replace YOUR_NEXTDNS_ID, then push:" | |
| echo "" | |
| echo " PROFILE=\$(base64 -w 0 $COMPOSE_DIR/profiles/dns-nextdns.mobileconfig)" | |
| echo ' curl -u nanomdm:'"$MDM_API_KEY"' \' | |
| echo " -X POST https://$MDM_HOST/v1/commands \\" | |
| echo " -H 'Content-Type: application/json' \\" | |
| echo ' -d "{\"command\":{\"RequestType\":\"InstallProfile\",\"Payload\":\"'"'"'\$PROFILE'"'"'\"},\"udids\":[\"ALL\"]}"' | |
| echo "" | |
| echo -e " ${YEL}Save your API key and SCEP challenge — they are stored in $COMPOSE_DIR/.env${RST}" | |
| echo "" | |
| [[ "$HSM" == "1" ]] && echo -e " ${GRN}HSM mode: Root CA and Intermediate CA keys are stored on your Nitrokey HSM 2.${RST}" | |
| echo "" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment