#!/usr/bin/env python3 """Apple mobileconfig profile generator HTTP server. Example: ./mobileconfig-generator.py \\ --domain billenius.com \\ --mail-host mail.billenius.com \\ --radicale-host cal.billenius.com \\ --account love:love@billenius.com:love_billenius.com \\ --port 8426 """ import argparse import hashlib import re from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parse_qs, urlparse from xml.sax.saxutils import escape DOMAIN = "" MAIL_HOST = "" RADICALE_HOST = "" DEFAULT_USERNAME = None PORT = 8426 ACCOUNT_BY_USERNAME = {} ACCOUNT_BY_PATH = {} def parse_account(value): """Parse 'username:email:legacyPath' into a dict.""" parts = value.split(":") if len(parts) != 3: raise argparse.ArgumentTypeError( f"account must be username:email:legacyPath, got {value!r}" ) return {"username": parts[0], "email": parts[1], "legacyPath": parts[2]} def init(args): global DOMAIN, MAIL_HOST, RADICALE_HOST, DEFAULT_USERNAME, PORT global ACCOUNT_BY_USERNAME, ACCOUNT_BY_PATH DOMAIN = args.domain MAIL_HOST = args.mail_host RADICALE_HOST = args.radicale_host DEFAULT_USERNAME = args.default_username PORT = args.port accounts = args.account or [] ACCOUNT_BY_USERNAME = {a["username"]: a for a in accounts} ACCOUNT_BY_PATH = {} for a in accounts: ACCOUNT_BY_PATH[a["username"]] = a ACCOUNT_BY_PATH[a["legacyPath"]] = a def first(values): if not values: return "" return values[0] def title_from_username(username): pieces = re.split(r"[._+-]+", username) words = [piece.capitalize() for piece in pieces if piece] return " ".join(words) if words else username def normalize_full_name(username, raw_name): full_name = " ".join(raw_name.split()) if not full_name: full_name = title_from_username(username) if " " not in full_name: full_name = f"{full_name} Billenius" return full_name def resolve_account(params): username = first(params.get("username")) or first(params.get("user")) email = first(params.get("emailaddress")) if username: username = username.strip() if "@" in username: local_part, _, domain_part = username.partition("@") if domain_part.lower() != DOMAIN.lower(): return None username = local_part elif email: email = email.strip() local_part, _, domain_part = email.partition("@") if not domain_part or domain_part.lower() != DOMAIN.lower(): return None username = local_part else: return None return ACCOUNT_BY_USERNAME.get(username) def deterministic_uuid(seed): digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() return f"{digest[:8]}-{digest[8:12]}-{digest[12:16]}-{digest[16:20]}-{digest[20:32]}" def mobileconfig_payload(email, full_name): identifier = re.sub(r"[^A-Za-z0-9_.-]", "-", email) values = { "domain": escape(DOMAIN), "email": escape(email), "full_name": escape(full_name), "identifier": escape(identifier), "mail_host": escape(MAIL_HOST), "radicale_host": escape(RADICALE_HOST), "profile_uuid": deterministic_uuid(f"{email}-apple-profile"), "mail_uuid": deterministic_uuid(f"{email}-apple-mail"), "caldav_uuid": deterministic_uuid(f"{email}-apple-caldav"), "carddav_uuid": deterministic_uuid(f"{email}-apple-carddav"), } return f""" PayloadContent EmailAccountDescription {values["domain"]} Mail EmailAccountName {values["full_name"]} EmailAccountType EmailTypeIMAP EmailAddress {values["email"]} IncomingMailServerAuthentication EmailAuthPassword IncomingMailServerHostName {values["mail_host"]} IncomingMailServerPortNumber 993 IncomingMailServerUseSSL IncomingMailServerUsername {values["email"]} OutgoingMailServerAuthentication EmailAuthPassword OutgoingMailServerHostName {values["mail_host"]} OutgoingMailServerPortNumber 587 OutgoingMailServerUseSSL OutgoingMailServerUsername {values["email"]} OutgoingPasswordSameAsIncomingPassword PayloadDescription Configures IMAP and SMTP for {values["email"]}. PayloadDisplayName Mail PayloadIdentifier com.billenius.mobileconfig.{values["identifier"]}.mail PayloadType com.apple.mail.managed PayloadUUID {values["mail_uuid"]} PayloadVersion 1 CalDAVAccountDescription {values["domain"]} Calendar CalDAVHostName {values["radicale_host"]} CalDAVPort 443 CalDAVUseSSL CalDAVUsername {values["email"]} PayloadDescription Configures CalDAV for {values["email"]}. PayloadDisplayName Calendar PayloadIdentifier com.billenius.mobileconfig.{values["identifier"]}.caldav PayloadType com.apple.caldav.account PayloadUUID {values["caldav_uuid"]} PayloadVersion 1 CardDAVAccountDescription {values["domain"]} Contacts CardDAVHostName {values["radicale_host"]} CardDAVPort 443 CardDAVUseSSL CardDAVUsername {values["email"]} PayloadDescription Configures CardDAV for {values["email"]}. PayloadDisplayName Contacts PayloadIdentifier com.billenius.mobileconfig.{values["identifier"]}.carddav PayloadType com.apple.carddav.account PayloadUUID {values["carddav_uuid"]} PayloadVersion 1 PayloadDescription Configures mail, calendar, and contacts for {values["email"]}. PayloadDisplayName {values["domain"]} Mail PayloadIdentifier com.billenius.mobileconfig.{values["identifier"]} PayloadOrganization {values["domain"]} PayloadRemovalDisallowed PayloadType Configuration PayloadUUID {values["profile_uuid"]} PayloadVersion 1 """ def landing_page(): d = escape(DOMAIN) return f""" {d} Apple Profile

{d} Apple Profile

Open this page in Safari on iPhone, iPad, or macOS. Enter your email address and optionally your full name to download the configuration profile.

@{d}

If you leave the full name blank, or only enter a first name, the profile defaults the last name to Billenius.

The profile configures IMAP, SMTP, CalDAV, and CardDAV. You will still be asked for your password during installation.

""" class Handler(BaseHTTPRequestHandler): def log_message(self, format, *args): return def send_html(self, body, status=200): encoded = body.encode("utf-8") self.send_response(status) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(encoded))) self.end_headers() self.wfile.write(encoded) def send_text(self, body, status=400): encoded = body.encode("utf-8") self.send_response(status) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(encoded))) self.end_headers() self.wfile.write(encoded) def send_profile(self, account, full_name, filename_base): payload = mobileconfig_payload(account["email"], full_name).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/x-apple-aspen-config") self.send_header("Content-Disposition", f'attachment; filename="{filename_base}.mobileconfig"') self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def do_GET(self): parsed = urlparse(self.path) params = parse_qs(parsed.query, keep_blank_values=True) if parsed.path == "/mobileconfig": location = "/mobileconfig/" if parsed.query: location = f"{location}?{parsed.query}" self.send_response(308) self.send_header("Location", location) self.end_headers() return if parsed.path in ("/mobileconfig/", "/mobileconfig/index.html"): account = resolve_account(params) if account is None: has_query = any(first(params.get(key)) for key in ("username", "user", "emailaddress")) if has_query: self.send_text("Unknown account", status=404) else: self.send_html(landing_page()) return full_name = normalize_full_name( account["username"], first(params.get("full_name")) or first(params.get("name")), ) self.send_profile(account, full_name, account["username"]) return if parsed.path == "/mobileconfig/billenius.mobileconfig": if DEFAULT_USERNAME is None: self.send_text("No default account", status=404) return account = ACCOUNT_BY_USERNAME[DEFAULT_USERNAME] full_name = normalize_full_name(account["username"], "") self.send_profile(account, full_name, "billenius") return if parsed.path.startswith("/mobileconfig/") and parsed.path.endswith(".mobileconfig"): basename = parsed.path[len("/mobileconfig/"):-len(".mobileconfig")] account = ACCOUNT_BY_PATH.get(basename) if account is None: self.send_text("Unknown account", status=404) return full_name = normalize_full_name( account["username"], first(params.get("full_name")) or first(params.get("name")), ) self.send_profile(account, full_name, basename) return self.send_text("Not found", status=404) if __name__ == "__main__": parser = argparse.ArgumentParser(description="Apple mobileconfig profile generator") parser.add_argument("--domain", required=True) parser.add_argument("--mail-host", required=True) parser.add_argument("--radicale-host", required=True) parser.add_argument("--default-username", default=None) parser.add_argument("--port", type=int, default=8426) parser.add_argument("--account", type=parse_account, action="append", help="username:email:legacyPath (repeatable)") init(parser.parse_args()) ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()