diff --git a/modules/nixos/mail-server/mobileconfig-generator.py b/modules/nixos/mail-server/mobileconfig-generator.py
new file mode 100644
index 0000000..0bbcd46
--- /dev/null
+++ b/modules/nixos/mail-server/mobileconfig-generator.py
@@ -0,0 +1,461 @@
+#!/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() diff --git a/modules/nixos/mail-server/mobileconfig.nix b/modules/nixos/mail-server/mobileconfig.nix index 5530f85..a4ffd58 100644 --- a/modules/nixos/mail-server/mobileconfig.nix +++ b/modules/nixos/mail-server/mobileconfig.nix @@ -38,427 +38,14 @@ lib.mkIf hasMailDiscoveryConfig ( defaultUsername = if lib.length accountEntries == 1 then (builtins.head accountEntries).username else null; - generatorConfig = { - inherit defaultUsername domain radicaleHost; - mailHost = cfg.fqdn; - accounts = map (entry: { - inherit (entry) email legacyPath username; - }) accountEntries; - }; + generatorScript = ./mobileconfig-generator.py; - mobileconfigGenerator = pkgs.writeTextFile { - name = "billenius-mobileconfig-generator"; - destination = "/bin/billenius-mobileconfig-generator"; - executable = true; - text = builtins.replaceStrings [ "\n " ] [ "\n" ] ''#!${pkgs.python3}/bin/python3 - import json - import re - from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - from urllib.parse import parse_qs, urlparse - from xml.sax.saxutils import escape + accountArgs = lib.concatMapStrings (entry: + " --account ${entry.username}:${entry.email}:${entry.legacyPath}" + ) accountEntries; - CONFIG = json.loads(${builtins.toJSON (builtins.toJSON generatorConfig)}) - DOMAIN = CONFIG["domain"] - MAIL_HOST = CONFIG["mailHost"] - RADICALE_HOST = CONFIG["radicaleHost"] - DEFAULT_USERNAME = CONFIG["defaultUsername"] - - ACCOUNTS = CONFIG["accounts"] - ACCOUNT_BY_USERNAME = {account["username"]: account for account in ACCOUNTS} - ACCOUNT_BY_PATH = {} - for account in ACCOUNTS: - ACCOUNT_BY_PATH[account["username"]] = account - ACCOUNT_BY_PATH[account["legacyPath"]] = account - - 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 mobileconfig_payload(email, full_name): - identifier = re.sub(r"[^A-Za-z0-9_.-]", "-", email) - - def uuid(seed): - import hashlib - digest = hashlib.sha256(seed.encode("utf-8")).hexdigest() - return f"{digest[:8]}-{digest[8:12]}-{digest[12:16]}-{digest[16:20]}-{digest[20:32]}" - - 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": uuid(f"{email}-apple-profile"), - "mail_uuid": uuid(f"{email}-apple-mail"), - "caldav_uuid": uuid(f"{email}-apple-caldav"), - "carddav_uuid": 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) - - ThreadingHTTPServer(("127.0.0.1", ${toString mobileconfigPort}), Handler).serve_forever() - ''; - }; + defaultUsernameArg = + if defaultUsername != null then " --default-username ${defaultUsername}" else ""; in { assertions = [ @@ -474,7 +61,7 @@ lib.mkIf hasMailDiscoveryConfig ( wantedBy = [ "multi-user.target" ]; serviceConfig = { DynamicUser = true; - ExecStart = "${mobileconfigGenerator}/bin/billenius-mobileconfig-generator"; + ExecStart = "${pkgs.python3}/bin/python3 ${generatorScript} --domain ${domain} --mail-host ${cfg.fqdn} --radicale-host ${radicaleHost} --port ${toString mobileconfigPort}${defaultUsernameArg}${accountArgs}"; NoNewPrivileges = true; PrivateTmp = true; ProtectHome = true;