From 9930123606efa8d41290b614f75c410ff1fe4da1 Mon Sep 17 00:00:00 2001 From: Love Billenius Date: Tue, 14 Apr 2026 17:37:15 +0200 Subject: [PATCH] generator --- .../mail-server/mobileconfig-generator.py | 461 ++++++++++++++++++ modules/nixos/mail-server/mobileconfig.nix | 427 +--------------- 2 files changed, 468 insertions(+), 420 deletions(-) create mode 100644 modules/nixos/mail-server/mobileconfig-generator.py 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""" + + + + 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() 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""" - - - - 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(): - return f""" - - - - - {escape(DOMAIN)} Apple Profile - - - -

{escape(DOMAIN)} 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.

-
-
- -
- - @{escape(DOMAIN)} -
-
-
- - -
- -
-

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;