194 lines
7.3 KiB
Python
194 lines
7.3 KiB
Python
#!/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()
|