#!/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"""
Open this page in Safari on iPhone, iPad, or macOS. Enter your email address and optionally your full name to download the configuration profile.
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()