#!/usr/bin/env python3 """Generate an Apple mobileconfig profile. Modes (determined by arguments provided): CA only --ca-cert CA + client --ca-cert --client-cert --client-key Full VPN --ca-cert --client-cert --client-key --remote-address --match-domains """ import argparse import plistlib import secrets import subprocess import tempfile import uuid from pathlib import Path OPENSSL = "/usr/bin/openssl" def openssl(*args) -> bytes: return subprocess.run([OPENSSL, *args], capture_output=True, check=True).stdout def cert_der(pem_path: Path) -> bytes: return openssl("x509", "-in", str(pem_path), "-outform", "DER") def cert_cn(pem_path: Path) -> str: out = openssl("x509", "-noout", "-subject", "-nameopt", "multiline", "-in", str(pem_path)) for line in out.decode().splitlines(): line = line.strip() if line.startswith("commonName"): return line.split("=", 1)[1].strip() return pem_path.stem def build_p12(cert_path: Path, key_path: Path) -> tuple[bytes, str]: password = secrets.token_urlsafe(32) with tempfile.NamedTemporaryFile(suffix=".p12", delete=False) as tmp: tmp_path = Path(tmp.name) try: openssl( "pkcs12", "-export", "-in", str(cert_path), "-inkey", str(key_path), "-out", str(tmp_path), "-passout", f"pass:{password}", ) return tmp_path.read_bytes(), password finally: tmp_path.unlink(missing_ok=True) def main(): global OPENSSL parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) g_ca = parser.add_argument_group("CA certificate (required)") g_ca.add_argument("--ca-cert", required=True, metavar="PEM", help="CA certificate PEM file") g_client = parser.add_argument_group("Client certificate (optional)") g_client.add_argument("--client-cert", metavar="PEM", help="Client certificate PEM file") g_client.add_argument("--client-key", metavar="PEM", help="Client private key PEM file") g_vpn = parser.add_argument_group("VPN (optional, requires client certificate)") g_vpn.add_argument("--remote-address", metavar="FQDN", help="VPN gateway FQDN") g_vpn.add_argument("--dns", metavar="IP", nargs="+", help="DNS server(s) for split DNS") g_vpn.add_argument("--match-domains", metavar="DOMAIN", nargs="+", help="Split DNS domains") g_meta = parser.add_argument_group("Profile metadata") g_meta.add_argument("--identifier", metavar="ID", help="Reverse-DNS profile identifier (derived from --remote-address if omitted)") g_meta.add_argument("--output", required=True, metavar="FILE", help="Output .mobileconfig path") g_meta.add_argument("--openssl", metavar="PATH", default=OPENSSL, help=f"Path to the openssl binary (default: {OPENSSL})") g_names = parser.add_argument_group("Display name overrides") g_names.add_argument("--profile-name", metavar="NAME", help="Profile display name") g_names.add_argument("--ca-name", metavar="NAME", help="CA payload display name (default: cert CN)") g_names.add_argument("--client-name", metavar="NAME", help="Client cert payload display name (default: cert CN)") g_names.add_argument("--vpn-name", metavar="NAME", help="VPN connection payload display name (default: profile name)") args = parser.parse_args() OPENSSL = args.openssl # Validate combinations if args.client_cert and not args.client_key: parser.error("--client-key is required when --client-cert is specified") if args.client_key and not args.client_cert: parser.error("--client-cert is required when --client-key is specified") vpn_args = [args.remote_address, args.dns, args.match_domains] if any(vpn_args) and not all(vpn_args): parser.error("--remote-address and --match-domains must be specified together") if args.remote_address and not args.client_cert: parser.error("--client-cert/--client-key are required for VPN profiles") vpn = bool(args.remote_address) has_client = bool(args.client_cert) # Identifier if args.identifier: identifier = args.identifier elif args.remote_address: identifier = ".".join(reversed(args.remote_address.split("."))) else: parser.error("--identifier is required when --remote-address is not specified") ca_path = Path(args.ca_cert) ca_cn = cert_cn(ca_path) # Display names profile_name = args.profile_name or ("VPN" if vpn else "Certificates") ca_name = args.ca_name or ca_cn vpn_name = args.vpn_name or profile_name payloads = [] # CA payload payloads.append({ "PayloadType": "com.apple.security.root", "PayloadVersion": 1, "PayloadIdentifier": f"{identifier}.cacert", "PayloadUUID": str(uuid.uuid4()).upper(), "PayloadDisplayName": ca_name, "PayloadContent": cert_der(ca_path), }) # Client certificate payload if has_client: client_path = Path(args.client_cert) client_cn = cert_cn(client_path) client_name = args.client_name or client_cn p12_bytes, p12_password = build_p12(client_path, Path(args.client_key)) uuid_cert = str(uuid.uuid4()).upper() payloads.append({ "PayloadType": "com.apple.security.pkcs12", "PayloadVersion": 1, "PayloadIdentifier": f"{identifier}.usercert", "PayloadUUID": uuid_cert, "PayloadDisplayName": client_name, "Password": p12_password, "PayloadContent": p12_bytes, }) # VPN payload if vpn: payloads.append({ "PayloadType": "com.apple.vpn.managed", "PayloadVersion": 1, "PayloadIdentifier": f"{identifier}.vpn", "PayloadUUID": str(uuid.uuid4()).upper(), "PayloadDisplayName": vpn_name, "VPNType": "IKEv2", "IKEv2": { "RemoteAddress": args.remote_address, "RemoteIdentifier": args.remote_address, "LocalIdentifier": client_cn, "ServerCertificateIssuerCommonName": ca_cn, "ServerCertificateCommonName": args.remote_address, "AuthenticationMethod": "None", "ExtendedAuthEnabled": 1, "PayloadCertificateUUID": uuid_cert, "OnDemandEnabled": 0, }, "DNS": { "ServerAddresses": args.dns, "SupplementalMatchDomains": args.match_domains, "SupplementalMatchDomainsNoSearch": 1, }, }) profile = { "PayloadDisplayName": profile_name, "PayloadIdentifier": identifier, "PayloadUUID": str(uuid.uuid4()).upper(), "PayloadType": "Configuration", "PayloadVersion": 1, "PayloadContent": payloads, } out = Path(args.output) with out.open("wb") as f: plistlib.dump(profile, f, fmt=plistlib.FMT_XML) print(f"Written: {out}") if __name__ == "__main__": main()