generator
This commit is contained in:
@@ -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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>EmailAccountDescription</key>
|
||||
<string>{values["domain"]} Mail</string>
|
||||
<key>EmailAccountName</key>
|
||||
<string>{values["full_name"]}</string>
|
||||
<key>EmailAccountType</key>
|
||||
<string>EmailTypeIMAP</string>
|
||||
<key>EmailAddress</key>
|
||||
<string>{values["email"]}</string>
|
||||
<key>IncomingMailServerAuthentication</key>
|
||||
<string>EmailAuthPassword</string>
|
||||
<key>IncomingMailServerHostName</key>
|
||||
<string>{values["mail_host"]}</string>
|
||||
<key>IncomingMailServerPortNumber</key>
|
||||
<integer>993</integer>
|
||||
<key>IncomingMailServerUseSSL</key>
|
||||
<true/>
|
||||
<key>IncomingMailServerUsername</key>
|
||||
<string>{values["email"]}</string>
|
||||
<key>OutgoingMailServerAuthentication</key>
|
||||
<string>EmailAuthPassword</string>
|
||||
<key>OutgoingMailServerHostName</key>
|
||||
<string>{values["mail_host"]}</string>
|
||||
<key>OutgoingMailServerPortNumber</key>
|
||||
<integer>587</integer>
|
||||
<key>OutgoingMailServerUseSSL</key>
|
||||
<true/>
|
||||
<key>OutgoingMailServerUsername</key>
|
||||
<string>{values["email"]}</string>
|
||||
<key>OutgoingPasswordSameAsIncomingPassword</key>
|
||||
<true/>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Configures IMAP and SMTP for {values["email"]}.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Mail</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.billenius.mobileconfig.{values["identifier"]}.mail</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.mail.managed</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>{values["mail_uuid"]}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CalDAVAccountDescription</key>
|
||||
<string>{values["domain"]} Calendar</string>
|
||||
<key>CalDAVHostName</key>
|
||||
<string>{values["radicale_host"]}</string>
|
||||
<key>CalDAVPort</key>
|
||||
<integer>443</integer>
|
||||
<key>CalDAVUseSSL</key>
|
||||
<true/>
|
||||
<key>CalDAVUsername</key>
|
||||
<string>{values["email"]}</string>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Configures CalDAV for {values["email"]}.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Calendar</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.billenius.mobileconfig.{values["identifier"]}.caldav</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.caldav.account</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>{values["caldav_uuid"]}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CardDAVAccountDescription</key>
|
||||
<string>{values["domain"]} Contacts</string>
|
||||
<key>CardDAVHostName</key>
|
||||
<string>{values["radicale_host"]}</string>
|
||||
<key>CardDAVPort</key>
|
||||
<integer>443</integer>
|
||||
<key>CardDAVUseSSL</key>
|
||||
<true/>
|
||||
<key>CardDAVUsername</key>
|
||||
<string>{values["email"]}</string>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Configures CardDAV for {values["email"]}.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Contacts</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.billenius.mobileconfig.{values["identifier"]}.carddav</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.carddav.account</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>{values["carddav_uuid"]}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Configures mail, calendar, and contacts for {values["email"]}.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>{values["domain"]} Mail</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.billenius.mobileconfig.{values["identifier"]}</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>{values["domain"]}</string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<false/>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>{values["profile_uuid"]}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
|
||||
|
||||
def landing_page():
|
||||
d = escape(DOMAIN)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{d} Apple Profile</title>
|
||||
<style>
|
||||
*, *::before, *::after {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: min(480px, 100vw);
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
color: #1a1a1a;
|
||||
background: #f5f5f7;
|
||||
}}
|
||||
h1 {{ font-size: 1.3rem; margin-bottom: 0.15rem; }}
|
||||
p {{ color: #555; font-size: 0.85rem; line-height: 1.4; margin: 0.4rem 0; }}
|
||||
form {{
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 1.2rem;
|
||||
margin: 1rem 0;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}}
|
||||
label {{
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-bottom: 0.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}}
|
||||
.input-row {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #d1d1d6;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}}
|
||||
.input-row:focus-within {{ border-color: #0071e3; }}
|
||||
.input-row input {{
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
}}
|
||||
.input-row span {{
|
||||
padding: 0.55rem 0.75rem 0.55rem 0;
|
||||
font-size: 1rem;
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
}}
|
||||
input[type="text"]#full_name {{
|
||||
border: 1px solid #d1d1d6;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
input[type="text"]#full_name:focus {{ outline: 2px solid #0071e3; outline-offset: -1px; border-color: transparent; }}
|
||||
button {{
|
||||
background: #0071e3;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}}
|
||||
button:hover {{ background: #005bbf; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 data-i18n="title">{d} Apple Profile</h1>
|
||||
<p data-i18n="intro">Open this page in Safari on iPhone, iPad, or macOS. Enter your email address and optionally your full name to download the configuration profile.</p>
|
||||
<form method="get" action="/mobileconfig/">
|
||||
<div>
|
||||
<label for="username" data-i18n="email">Email address</label>
|
||||
<div class="input-row" onclick="this.querySelector('input').focus()" style="cursor:text">
|
||||
<input id="username" name="username" type="text" autocomplete="username" pattern="[A-Za-z0-9._+-]+" required>
|
||||
<span>@{d}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="full_name" data-i18n="fullname">Fullname</label>
|
||||
<input id="full_name" name="full_name" type="text" autocomplete="name" placeholder="... Billenius">
|
||||
</div>
|
||||
<button type="submit" data-i18n="submit">Download profile</button>
|
||||
</form>
|
||||
<p data-i18n="hint1">If you leave the full name blank, or only enter a first name, the profile defaults the last name to Billenius.</p>
|
||||
<p data-i18n="hint2">The profile configures IMAP, SMTP, CalDAV, and CardDAV. You will still be asked for your password during installation.</p>
|
||||
<script>
|
||||
var sv = {{
|
||||
title: "{d} Apple-profil",
|
||||
intro: "Öppna den här sidan i Safari på iPhone, iPad eller macOS. Ange din e-postadress och eventuellt ditt fullständiga namn för att ladda ner konfigurationsprofilen.",
|
||||
email: "E-postadress",
|
||||
fullname: "Fullständigt namn",
|
||||
submit: "Ladda ner profil",
|
||||
hint1: "Om du lämnar fullständigt namn tomt, eller bara anger ett förnamn, används Billenius som efternamn.",
|
||||
hint2: "Profilen konfigurerar IMAP, SMTP, CalDAV och CardDAV. Du kommer fortfarande att bli ombedd att ange ditt lösenord under installationen."
|
||||
}};
|
||||
if (navigator.language.startsWith("sv")) {{
|
||||
document.querySelectorAll("[data-i18n]").forEach(function(el) {{
|
||||
var key = el.getAttribute("data-i18n");
|
||||
if (sv[key]) el.textContent = sv[key];
|
||||
}});
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
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()
|
||||
@@ -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"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>PayloadContent</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>EmailAccountDescription</key>
|
||||
<string>{values["domain"]} Mail</string>
|
||||
<key>EmailAccountName</key>
|
||||
<string>{values["full_name"]}</string>
|
||||
<key>EmailAccountType</key>
|
||||
<string>EmailTypeIMAP</string>
|
||||
<key>EmailAddress</key>
|
||||
<string>{values["email"]}</string>
|
||||
<key>IncomingMailServerAuthentication</key>
|
||||
<string>EmailAuthPassword</string>
|
||||
<key>IncomingMailServerHostName</key>
|
||||
<string>{values["mail_host"]}</string>
|
||||
<key>IncomingMailServerPortNumber</key>
|
||||
<integer>993</integer>
|
||||
<key>IncomingMailServerUseSSL</key>
|
||||
<true/>
|
||||
<key>IncomingMailServerUsername</key>
|
||||
<string>{values["email"]}</string>
|
||||
<key>OutgoingMailServerAuthentication</key>
|
||||
<string>EmailAuthPassword</string>
|
||||
<key>OutgoingMailServerHostName</key>
|
||||
<string>{values["mail_host"]}</string>
|
||||
<key>OutgoingMailServerPortNumber</key>
|
||||
<integer>587</integer>
|
||||
<key>OutgoingMailServerUseSSL</key>
|
||||
<true/>
|
||||
<key>OutgoingMailServerUsername</key>
|
||||
<string>{values["email"]}</string>
|
||||
<key>OutgoingPasswordSameAsIncomingPassword</key>
|
||||
<true/>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Configures IMAP and SMTP for {values["email"]}.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Mail</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.billenius.mobileconfig.{values["identifier"]}.mail</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.mail.managed</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>{values["mail_uuid"]}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CalDAVAccountDescription</key>
|
||||
<string>{values["domain"]} Calendar</string>
|
||||
<key>CalDAVHostName</key>
|
||||
<string>{values["radicale_host"]}</string>
|
||||
<key>CalDAVPort</key>
|
||||
<integer>443</integer>
|
||||
<key>CalDAVUseSSL</key>
|
||||
<true/>
|
||||
<key>CalDAVUsername</key>
|
||||
<string>{values["email"]}</string>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Configures CalDAV for {values["email"]}.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Calendar</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.billenius.mobileconfig.{values["identifier"]}.caldav</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.caldav.account</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>{values["caldav_uuid"]}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CardDAVAccountDescription</key>
|
||||
<string>{values["domain"]} Contacts</string>
|
||||
<key>CardDAVHostName</key>
|
||||
<string>{values["radicale_host"]}</string>
|
||||
<key>CardDAVPort</key>
|
||||
<integer>443</integer>
|
||||
<key>CardDAVUseSSL</key>
|
||||
<true/>
|
||||
<key>CardDAVUsername</key>
|
||||
<string>{values["email"]}</string>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Configures CardDAV for {values["email"]}.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>Contacts</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.billenius.mobileconfig.{values["identifier"]}.carddav</string>
|
||||
<key>PayloadType</key>
|
||||
<string>com.apple.carddav.account</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>{values["carddav_uuid"]}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</array>
|
||||
<key>PayloadDescription</key>
|
||||
<string>Configures mail, calendar, and contacts for {values["email"]}.</string>
|
||||
<key>PayloadDisplayName</key>
|
||||
<string>{values["domain"]} Mail</string>
|
||||
<key>PayloadIdentifier</key>
|
||||
<string>com.billenius.mobileconfig.{values["identifier"]}</string>
|
||||
<key>PayloadOrganization</key>
|
||||
<string>{values["domain"]}</string>
|
||||
<key>PayloadRemovalDisallowed</key>
|
||||
<false/>
|
||||
<key>PayloadType</key>
|
||||
<string>Configuration</string>
|
||||
<key>PayloadUUID</key>
|
||||
<string>{values["profile_uuid"]}</string>
|
||||
<key>PayloadVersion</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
|
||||
def landing_page():
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{escape(DOMAIN)} Apple Profile</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 3rem auto;
|
||||
padding: 0 1.5rem;
|
||||
color: #1a1a1a;
|
||||
background: #f5f5f7;
|
||||
}}
|
||||
h1 {{ font-size: 1.4rem; margin-bottom: 0.25rem; }}
|
||||
p {{ color: #555; font-size: 0.9rem; line-height: 1.5; }}
|
||||
form {{
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}}
|
||||
label {{
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-bottom: 0.3rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}}
|
||||
.input-row {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: #fff;
|
||||
}}
|
||||
.input-row input {{
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 0.55rem 0;
|
||||
font-size: 1rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
}}
|
||||
.input-row span {{
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
border: 1px solid #d1d1d6;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
input[type="text"] {{
|
||||
border: 1px solid #d1d1d6;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
input[type="text"]:focus {{ outline: 2px solid #0071e3; outline-offset: -1px; border-color: transparent; }}
|
||||
button {{
|
||||
background: #0071e3;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 1.25rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}}
|
||||
button:hover {{ background: #005bbf; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 data-i18n="title">{escape(DOMAIN)} Apple Profile</h1>
|
||||
<p data-i18n="intro">Open this page in Safari on iPhone, iPad, or macOS. Enter your email address and optionally your full name to download the configuration profile.</p>
|
||||
<form method="get" action="/mobileconfig/">
|
||||
<div>
|
||||
<label for="username" data-i18n="email">Email address</label>
|
||||
<div class="input-row" onclick="this.querySelector('input').focus()" style="cursor:text">
|
||||
<input id="username" name="username" type="text" autocomplete="username" pattern="[A-Za-z0-9._+-]+" required>
|
||||
<span>@{escape(DOMAIN)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="full_name" data-i18n="fullname">Fullname</label>
|
||||
<input id="full_name" name="full_name" type="text" autocomplete="name" placeholder="Love Billenius">
|
||||
</div>
|
||||
<button type="submit" data-i18n="submit">Download profile</button>
|
||||
</form>
|
||||
<p data-i18n="hint1">If you leave the full name blank, or only enter a first name, the profile defaults the last name to Billenius.</p>
|
||||
<p data-i18n="hint2">The profile configures IMAP, SMTP, CalDAV, and CardDAV. You will still be asked for your password during installation.</p>
|
||||
<script>
|
||||
var sv = {{
|
||||
title: "{escape(DOMAIN)} Apple-profil",
|
||||
intro: "Öppna den här sidan i Safari på iPhone, iPad eller macOS. Ange din e-postadress och eventuellt ditt fullständiga namn för att ladda ner konfigurationsprofilen.",
|
||||
email: "E-postadress",
|
||||
fullname: "Fullständigt namn",
|
||||
submit: "Ladda ner profil",
|
||||
hint1: "Om du lämnar fullständigt namn tomt, eller bara anger ett förnamn, används Billenius som efternamn.",
|
||||
hint2: "Profilen konfigurerar IMAP, SMTP, CalDAV och CardDAV. Du kommer fortfarande att bli ombedd att ange ditt lösenord under installationen."
|
||||
}};
|
||||
if (navigator.language.startsWith("sv")) {{
|
||||
document.querySelectorAll("[data-i18n]").forEach(function(el) {{
|
||||
var key = el.getAttribute("data-i18n");
|
||||
if (sv[key]) el.textContent = sv[key];
|
||||
}});
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user