#!/usr/bin/env python3
"""Bundle a CA, client identity, and native Full/Split IKEv2 connections."""

import argparse
import ipaddress
import os
from pathlib import Path
import plistlib
import ssl
import subprocess
import uuid


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--server", required=True, help="VPN server hostname or IP")
    parser.add_argument("--ca", type=Path, required=True, help="CA certificate, PEM or DER")
    parser.add_argument(
        "--p12",
        type=Path,
        required=True,
        help="Client identity exported with openssl pkcs12 -legacy -macalg sha1",
    )
    imported = parser.add_mutually_exclusive_group()
    imported.add_argument(
        "--p12-password-file",
        type=Path,
        help="Embed the PKCS#12 import value from a file or /dev/stdin",
    )
    imported.add_argument(
        "--p12-no-password",
        action="store_true",
        help="Set an empty PKCS#12 import value (some Apple versions still prompt)",
    )
    parser.add_argument("--local-id", required=True, help="Client certificate SAN")
    parser.add_argument(
        "--remote-id-base",
        help="Defaults to --server; IDs are full-tunnel.BASE and split-tunnel.BASE",
    )
    parser.add_argument("--name", default="Home VPN", help="Connection display name")
    parser.add_argument(
        "--organization",
        default="IKEv2",
        help="Provider subtitle displayed beneath the VPN name",
    )
    parser.add_argument("--mode", choices=("both", "split", "full"), default="both")
    parser.add_argument(
        "--on-demand",
        action="store_true",
        help="Automatically connect the split tunnel outside the home network",
    )
    parser.add_argument(
        "--home-ssid",
        action="append",
        default=[],
        help="Trusted home Wi-Fi SSID; repeat for multiple SSIDs",
    )
    parser.add_argument(
        "--home-dns-domain",
        action="append",
        default=[],
        help="Home network's DNS search domain; repeat as needed",
    )
    parser.add_argument(
        "--home-dns-server",
        action="append",
        default=[],
        help="Home network's DNS server IP; repeat as needed",
    )
    parser.add_argument(
        "--dns-server",
        action="append",
        default=[],
        help="VPN DNS server IP; repeat as needed",
    )
    parser.add_argument(
        "--dns-domain",
        action="append",
        default=[],
        help="Domain resolved through the split tunnel; repeat as needed",
    )
    parser.add_argument("--signing-cert", type=Path, help="CMS profile-signing certificate (PEM)")
    parser.add_argument("--signing-key", type=Path, help="Matching profile-signing private key (PEM)")
    parser.add_argument("--signing-chain", type=Path, help="Intermediate signing CA certificates (PEM)")
    parser.add_argument("--output", type=Path, required=True)
    args = parser.parse_args()
    if bool(args.signing_cert) != bool(args.signing_key):
        parser.error("supply both --signing-cert and --signing-key")
    if args.signing_chain and not args.signing_cert:
        parser.error("--signing-chain requires --signing-cert and --signing-key")
    home_matches = args.home_ssid or args.home_dns_domain or args.home_dns_server
    if args.on_demand:
        if args.mode == "full":
            parser.error("--on-demand applies to the split tunnel; use --mode split or both")
        if not home_matches:
            parser.error("--on-demand requires a home network match (--home-ssid or --home-dns-domain/--home-dns-server)")
    elif home_matches:
        parser.error("home network matches require --on-demand")
    if bool(args.dns_server) != bool(args.dns_domain):
        parser.error("supply both --dns-server and --dns-domain for split DNS")
    for address in args.dns_server + args.home_dns_server:
        try:
            ipaddress.ip_address(address)
        except ValueError:
            parser.error(f"invalid DNS server IP: {address}")
    for domain in args.dns_domain + args.home_dns_domain:
        if not domain or domain == "." or any(c.isspace() for c in domain):
            parser.error(f"invalid DNS domain: {domain!r}")

    os.umask(0o077)
    identity_suffix = uuid.uuid5(uuid.NAMESPACE_URL, args.local_id).hex
    base = f"com.example.ikev2.{args.server}.{identity_suffix}"

    def payload(kind, suffix, name, **values):
        return {
            "PayloadType": kind,
            "PayloadVersion": 1,
            "PayloadIdentifier": f"{base}.{suffix}",
            "PayloadUUID": str(uuid.uuid4()).upper(),
            "PayloadDisplayName": name,
            "PayloadOrganization": args.organization,
            **values,
        }

    ca = args.ca.read_bytes()
    if b"-----BEGIN CERTIFICATE-----" in ca:
        ca = ssl.PEM_cert_to_DER_cert(ca.decode("ascii"))
    payloads = [payload("com.apple.security.root", "ca", f"{args.name} CA", PayloadContent=ca)]

    identity = payload(
        "com.apple.security.pkcs12",
        "identity",
        f"{args.name} identity",
        PayloadCertificateFileName=args.p12.name,
        PayloadContent=args.p12.read_bytes(),
    )
    if args.p12_password_file:
        import_value = args.p12_password_file.read_text().rstrip("\r\n")
        if not import_value:
            parser.error("the PKCS#12 import value must not be empty; use --p12-no-password for an explicit empty value")
        identity["Password"] = import_value
    elif args.p12_no_password:
        identity["Password"] = ""
    payloads.append(identity)

    for mode in ("full", "split") if args.mode == "both" else (args.mode,):
        name = f"{args.name} - {mode.title()} Tunnel"
        ike = {
            "RemoteAddress": args.server,
            "RemoteIdentifier": f"{mode}-tunnel.{args.remote_id_base or args.server}",
            "LocalIdentifier": args.local_id,
            "AuthenticationMethod": "Certificate",
            "ExtendedAuthEnabled": 0,
            "OnDemandEnabled": int(args.on_demand and mode == "split"),
            "DeadPeerDetectionRate": "High",
            "DisableMOBIKE": 0,
            "PayloadCertificateUUID": identity["PayloadUUID"],
        }
        if args.on_demand and mode == "split":
            rules = []
            if args.home_ssid:
                rules.append(
                    {
                        "Action": "Disconnect",
                        "InterfaceTypeMatch": "WiFi",
                        "SSIDMatch": args.home_ssid,
                    }
                )
            if args.home_dns_domain or args.home_dns_server:
                rule = {"Action": "Disconnect"}
                if args.home_dns_domain:
                    rule["DNSDomainMatch"] = args.home_dns_domain
                if args.home_dns_server:
                    rule["DNSServerAddressMatch"] = args.home_dns_server
                rules.append(rule)
            rules.append({"Action": "Connect"})
            ike["OnDemandRules"] = rules
        values = {
            "VPNType": "IKEv2",
            "UserDefinedName": name,
            "IKEv2": ike,
        }
        if mode == "full":
            # Capture all supported IP traffic, including IPv6, in full mode.
            # The server must provide IPv6 service or deliberately reject it.
            ike["IncludeAllNetworks"] = 1
        if args.dns_server:
            values["DNS"] = {
                "DNSProtocol": "Cleartext",
                "ServerAddresses": args.dns_server,
                "SearchDomains": args.dns_domain,
                "SupplementalMatchDomains": args.dns_domain if mode == "split" else [""],
                "SupplementalMatchDomainsNoSearch": 0,
            }
        payloads.append(payload("com.apple.vpn.managed", mode, name, **values))

    # Split-only and full-only exports must not replace each other on install.
    suffix = "profile" if args.mode == "both" else f"profile.{args.mode}"
    profile = payload("Configuration", suffix, args.name, PayloadContent=payloads)
    encoded = plistlib.dumps(profile)
    if args.signing_cert:
        signing = [
            "openssl",
            "cms",
            "-sign",
            "-binary",
            "-nodetach",
            "-outform",
            "DER",
            "-md",
            "sha256",
            "-signer",
            str(args.signing_cert),
            "-inkey",
            str(args.signing_key),
        ]
        if args.signing_chain:
            signing += ["-certfile", str(args.signing_chain)]
        try:
            result = subprocess.run(signing, input=encoded, capture_output=True, timeout=30)
        except (OSError, subprocess.TimeoutExpired) as error:
            parser.error(f"could not sign profile: {error}")
        if result.returncode:
            parser.error("could not sign profile: " + result.stderr.decode(errors="replace").strip())
        encoded = result.stdout
    with args.output.open("wb") as output:
        os.fchmod(output.fileno(), 0o600)
        output.write(encoded)
    print(args.output)


if __name__ == "__main__":
    main()
