diff --git a/MAIL.md b/MAIL.md index 1c9a251..968c72e 100644 --- a/MAIL.md +++ b/MAIL.md @@ -8,7 +8,7 @@ This repo currently configures mail for `billenius.com` on `Hermes`. - Thunderbird autoconfig: `autoconfig.billenius.com` - Outlook-style autodiscover: `autodiscover.billenius.com` - CalDAV/CardDAV via Radicale: `cal.billenius.com` -- Apple configuration profile: `https://autoconfig.billenius.com/mobileconfig/?emailaddress=love%40billenius.com` +- Apple configuration profile: `https://autoconfig.billenius.com/mobileconfig/?username=love&full_name=Love%20Billenius` - Webmail via Roundcube: `https://mail.billenius.com` ## Repo Locations @@ -50,12 +50,12 @@ This repo does not manage the apex website host for `billenius.com`. If the publ ### Apple Mobileconfig - The canonical Apple profile entrypoint is: - - `https://autoconfig.billenius.com/mobileconfig/?emailaddress=` + - `https://autoconfig.billenius.com/mobileconfig/?username=&full_name=` - Apple profiles are hosted from `autoconfig.billenius.com` - A small landing page is available at: - `https://autoconfig.billenius.com/mobileconfig/` - Account-specific profiles are always available at: - - `https://autoconfig.billenius.com/mobileconfig/.mobileconfig` + - `https://autoconfig.billenius.com/mobileconfig/.mobileconfig` - If there is exactly one configured mail account, the profile is also available at: - `https://autoconfig.billenius.com/mobileconfig/billenius.mobileconfig` @@ -68,6 +68,8 @@ The profile configures: Passwords are not embedded in the profile. +The profile generator accepts a username, not a full email address. The generator appends `@billenius.com` automatically. If the full name is blank, or only contains a first name, the generator defaults the last name to `Billenius`. + ## DNS These records are expected for good client discovery. @@ -103,7 +105,7 @@ _carddavs._tcp.billenius.com. TXT "path=/" The preferred setup path on Apple devices is to open the hosted profile in Safari: ```text -https://autoconfig.billenius.com/mobileconfig/?emailaddress=love%40billenius.com +https://autoconfig.billenius.com/mobileconfig/?username=love&full_name=Love%20Billenius ``` Install the profile, then enter the password when prompted. diff --git a/modules/nixos/mail-server/mobileconfig.nix b/modules/nixos/mail-server/mobileconfig.nix index 02cfa5b..73655cb 100644 --- a/modules/nixos/mail-server/mobileconfig.nix +++ b/modules/nixos/mail-server/mobileconfig.nix @@ -13,242 +13,392 @@ lib.mkIf hasMailDiscoveryConfig ( domain = builtins.head cfg.domains; mobileconfigHost = "autoconfig.${domain}"; radicaleHost = "cal.${domain}"; - safeAccountName = email: builtins.replaceStrings [ "@" "+" ] [ "_" "-" ] email; - emailQueryPattern = email: - builtins.replaceStrings [ - "\\+" - "@" - ] [ - "(?:\\+|%2B)" - "(?:@|%40)" - ] (lib.escapeRegex email); + mobileconfigPort = 8426; - mkUuid = seed: - let - hash = builtins.hashString "sha256" seed; - in - "${lib.substring 0 8 hash}-${lib.substring 8 4 hash}-${lib.substring 12 4 hash}-${lib.substring 16 4 hash}-${lib.substring 20 12 hash}"; + safeLegacyPath = email: builtins.replaceStrings [ "@" "+" ] [ "_" "-" ] email; - mkMobileconfig = email: - let - safeName = safeAccountName email; - profileUuid = mkUuid "${email}-apple-profile"; - mailUuid = mkUuid "${email}-apple-mail"; - caldavUuid = mkUuid "${email}-apple-caldav"; - carddavUuid = mkUuid "${email}-apple-carddav"; - in - pkgs.writeText "${safeName}.mobileconfig" '' - - - - - PayloadContent - - - EmailAccountDescription - ${domain} Mail - EmailAccountName - ${email} - EmailAccountType - EmailTypeIMAP - EmailAddress - ${email} - IncomingMailServerAuthentication - EmailAuthPassword - IncomingMailServerHostName - ${cfg.fqdn} - IncomingMailServerPortNumber - 993 - IncomingMailServerUseSSL - - IncomingMailServerUsername - ${email} - OutgoingMailServerAuthentication - EmailAuthPassword - OutgoingMailServerHostName - ${cfg.fqdn} - OutgoingMailServerPortNumber - 587 - OutgoingMailServerUseSSL - - OutgoingMailServerUsername - ${email} - OutgoingPasswordSameAsIncomingPassword - - PayloadDescription - Configures IMAP and SMTP for ${email}. - PayloadDisplayName - Mail - PayloadIdentifier - com.billenius.mobileconfig.${safeName}.mail - PayloadType - com.apple.mail.managed - PayloadUUID - ${mailUuid} - PayloadVersion - 1 - - - CalDAVAccountDescription - ${domain} Calendar - CalDAVHostName - ${radicaleHost} - CalDAVPort - 443 - CalDAVUseSSL - - CalDAVUsername - ${email} - PayloadDescription - Configures CalDAV for ${email}. - PayloadDisplayName - Calendar - PayloadIdentifier - com.billenius.mobileconfig.${safeName}.caldav - PayloadType - com.apple.caldav.account - PayloadUUID - ${caldavUuid} - PayloadVersion - 1 - - - CardDAVAccountDescription - ${domain} Contacts - CardDAVHostName - ${radicaleHost} - CardDAVPort - 443 - CardDAVUseSSL - - CardDAVUsername - ${email} - PayloadDescription - Configures CardDAV for ${email}. - PayloadDisplayName - Contacts - PayloadIdentifier - com.billenius.mobileconfig.${safeName}.carddav - PayloadType - com.apple.carddav.account - PayloadUUID - ${carddavUuid} - PayloadVersion - 1 - - - PayloadDescription - Configures mail, calendar, and contacts for ${email}. - PayloadDisplayName - ${domain} Mail - PayloadIdentifier - com.billenius.mobileconfig.${safeName} - PayloadOrganization - ${domain} - PayloadRemovalDisallowed - - PayloadType - Configuration - PayloadUUID - ${profileUuid} - PayloadVersion - 1 - - - ''; - - mobileconfigProfiles = lib.mapAttrs ( - email: _: { - inherit email; - safeName = safeAccountName email; - profile = mkMobileconfig email; - } - ) cfg.loginAccounts; - - mobileconfigLandingPage = pkgs.writeText "mobileconfig-index.html" '' - - - - - - ${domain} Apple Profile - - -

${domain} Apple Profile

-

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

-
- - - -
-

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

- - - ''; - - mobileconfigRedirectConfig = - lib.concatStrings ( + accountEntries = + lib.filter (entry: entry.domain == domain) ( lib.mapAttrsToList ( - email: profile: '' - if ($arg_emailaddress ~* "^${emailQueryPattern email}$") { - return 302 /mobileconfig/${profile.safeName}.mobileconfig; - } - '' - ) mobileconfigProfiles + email: _: + let + parts = lib.splitString "@" email; + username = builtins.head parts; + domainPart = lib.last parts; + in + { + inherit email username; + domain = domainPart; + legacyPath = safeLegacyPath email; + } + ) cfg.loginAccounts ); - mobileconfigLocations = lib.listToAttrs ( - [ - { - name = "= /mobileconfig"; - value.return = "308 /mobileconfig/"; - } - { - name = "= /mobileconfig/"; - value.extraConfig = '' - ${mobileconfigRedirectConfig} - return 302 /mobileconfig/index.html; - ''; - } - { - name = "= /mobileconfig/index.html"; - value.extraConfig = '' - default_type text/html; - alias ${mobileconfigLandingPage}; - ''; - } - ] - ++ lib.mapAttrsToList ( - _: profile: { - name = "= /mobileconfig/${profile.safeName}.mobileconfig"; - value.extraConfig = '' - default_type application/x-apple-aspen-config; - add_header Content-Disposition 'attachment; filename="${profile.safeName}.mobileconfig"' always; - alias ${profile.profile}; - ''; - } - ) mobileconfigProfiles - ); + usernames = map (entry: entry.username) accountEntries; + defaultUsername = + if lib.length accountEntries == 1 then (builtins.head accountEntries).username else null; - defaultMobileconfigLocation = - let - accounts = lib.attrNames mobileconfigProfiles; - in - if lib.length accounts == 1 then - let - profile = mobileconfigProfiles.${builtins.head accounts}; - in - { - "= /mobileconfig/billenius.mobileconfig".extraConfig = '' - default_type application/x-apple-aspen-config; - add_header Content-Disposition 'attachment; filename="billenius.mobileconfig"' always; - alias ${profile.profile}; - ''; - } - else - { }; + generatorConfig = { + inherit defaultUsername domain radicaleHost; + mailHost = cfg.fqdn; + accounts = map (entry: { + inherit (entry) email legacyPath username; + }) accountEntries; + }; + + 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 + + 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 username 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() + ''; + }; in { - services.nginx.virtualHosts.${mobileconfigHost}.locations = mobileconfigLocations // defaultMobileconfigLocation; + assertions = [ + { + assertion = lib.length usernames == lib.length (lib.unique usernames); + message = "Mail mobileconfig usernames must be unique within ${domain}."; + } + ]; + + systemd.services.mobileconfig-generator = { + description = "Apple mobileconfig generator"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + DynamicUser = true; + ExecStart = "${mobileconfigGenerator}/bin/billenius-mobileconfig-generator"; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + Restart = "always"; + }; + }; + + services.nginx.virtualHosts.${mobileconfigHost}.locations = { + "= /mobileconfig".return = "308 /mobileconfig/$is_args$args"; + "/mobileconfig/" = { + proxyPass = "http://127.0.0.1:${toString mobileconfigPort}"; + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + }; } )