| | @@ -0,0 +1,180 @@ |
| + | #!/usr/bin/env python3 |
| + | """Generate iOS .mobileconfig profiles from WireGuard .conf files with a true |
| + | kill switch (IncludeAllNetworks=true) and OnDemand auto-connect baked in. |
| + | |
| + | The WireGuard iOS app's UI does not expose "Include All Networks" or true |
| + | kill-switch settings. The only way to enforce kill-switch behavior on iOS |
| + | (traffic blocked when the tunnel is down) is via an installable Apple |
| + | configuration profile. Once installed, it appears in the WireGuard app as a |
| + | managed tunnel and the kill switch is enforced at the iOS Network Extension |
| + | level. |
| + | |
| + | Usage: |
| + | gen_mobileconfig.py <input.conf> <output.mobileconfig> [--name LABEL] [--org ORG] |
| + | |
| + | Or, batch mode: |
| + | gen_mobileconfig.py --batch <input-dir> <output-dir> |
| + | Reads every *.conf in <input-dir> and writes a matching .mobileconfig |
| + | to <output-dir>. Profile names are derived from the .conf filename. |
| + | """ |
| + | from __future__ import annotations |
| + | import argparse |
| + | import html |
| + | import sys |
| + | import uuid |
| + | from pathlib import Path |
| + | |
| + | |
| + | def parse_conf(path: Path) -> dict: |
| + | raw = path.read_text().strip() |
| + | cfg = {"raw": raw, "endpoint_host": "", "endpoint_port": "51820"} |
| + | for line in raw.splitlines(): |
| + | line = line.strip() |
| + | if line.startswith("Endpoint"): |
| + | ep = line.split("=", 1)[1].strip() |
| + | host, _, port = ep.rpartition(":") |
| + | cfg["endpoint_host"] = host or ep |
| + | if port: |
| + | cfg["endpoint_port"] = port |
| + | return cfg |
| + | |
| + | |
| + | def build_mobileconfig(conf_path: Path, name: str, org: str, |
| + | reverse_dns: str = "vpn.killswitch") -> str: |
| + | cfg = parse_conf(conf_path) |
| + | wg_quick_xml = html.escape(cfg["raw"]) |
| + | tunnel_uuid = str(uuid.uuid4()).upper() |
| + | profile_uuid = str(uuid.uuid4()).upper() |
| + | slug = name.lower().replace(" ", "-") |
| + | payload_id_tunnel = f"{reverse_dns}.{slug}.tunnel" |
| + | payload_id_profile = f"{reverse_dns}.{slug}.profile" |
| + | |
| + | 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>PayloadType</key> |
| + | <string>Configuration</string> |
| + | <key>PayloadVersion</key> |
| + | <integer>1</integer> |
| + | <key>PayloadIdentifier</key> |
| + | <string>{payload_id_profile}</string> |
| + | <key>PayloadUUID</key> |
| + | <string>{profile_uuid}</string> |
| + | <key>PayloadDisplayName</key> |
| + | <string>{name}</string> |
| + | <key>PayloadDescription</key> |
| + | <string>WireGuard tunnel with kill switch and auto-connect enforced.</string> |
| + | <key>PayloadOrganization</key> |
| + | <string>{org}</string> |
| + | <key>PayloadRemovalDisallowed</key> |
| + | <false/> |
| + | <key>PayloadScope</key> |
| + | <string>User</string> |
| + | <key>PayloadContent</key> |
| + | <array> |
| + | <dict> |
| + | <key>PayloadType</key> |
| + | <string>com.apple.vpn.managed</string> |
| + | <key>PayloadVersion</key> |
| + | <integer>1</integer> |
| + | <key>PayloadIdentifier</key> |
| + | <string>{payload_id_tunnel}</string> |
| + | <key>PayloadUUID</key> |
| + | <string>{tunnel_uuid}</string> |
| + | <key>PayloadDisplayName</key> |
| + | <string>{name}</string> |
| + | <key>PayloadDescription</key> |
| + | <string>WireGuard VPN with kill switch.</string> |
| + | <key>PayloadOrganization</key> |
| + | <string>{org}</string> |
| + | <key>UserDefinedName</key> |
| + | <string>{name}</string> |
| + | <key>VPNType</key> |
| + | <string>VPN</string> |
| + | <key>VPNSubType</key> |
| + | <string>com.wireguard.ios</string> |
| + | <key>VPN</key> |
| + | <dict> |
| + | <key>AuthenticationMethod</key> |
| + | <string>Password</string> |
| + | <key>RemoteAddress</key> |
| + | <string>{cfg['endpoint_host']}</string> |
| + | </dict> |
| + | <key>VendorConfig</key> |
| + | <dict> |
| + | <key>WgQuickConfig</key> |
| + | <string>{wg_quick_xml}</string> |
| + | </dict> |
| + | <key>IncludeAllNetworks</key> |
| + | <true/> |
| + | <key>EnforceRoutes</key> |
| + | <true/> |
| + | <key>ExcludeLocalNetworks</key> |
| + | <false/> |
| + | <key>OnDemandEnabled</key> |
| + | <integer>1</integer> |
| + | <key>OnDemandRules</key> |
| + | <array> |
| + | <dict> |
| + | <key>Action</key> |
| + | <string>Connect</string> |
| + | </dict> |
| + | </array> |
| + | </dict> |
| + | </array> |
| + | </dict> |
| + | </plist> |
| + | """ |
| + | |
| + | |
| + | def main() -> int: |
| + | ap = argparse.ArgumentParser(description="Generate iOS .mobileconfig profiles " |
| + | "from WireGuard .conf files (with kill switch).") |
| + | ap.add_argument("input", help="path to .conf file (or input dir in --batch mode)") |
| + | ap.add_argument("output", help="output .mobileconfig (or output dir in --batch mode)") |
| + | ap.add_argument("--name", default=None, help="profile display name (default: input filename stem)") |
| + | ap.add_argument("--org", default="WireGuard Kill Switch", help="PayloadOrganization string") |
| + | ap.add_argument("--reverse-dns", default="vpn.killswitch", |
| + | help="reverse-DNS prefix for PayloadIdentifier") |
| + | ap.add_argument("--batch", action="store_true", |
| + | help="treat input and output as directories") |
| + | args = ap.parse_args() |
| + | |
| + | if args.batch: |
| + | in_dir = Path(args.input) |
| + | out_dir = Path(args.output) |
| + | if not in_dir.is_dir(): |
| + | print(f"ERR: {in_dir} is not a directory", file=sys.stderr) |
| + | return 2 |
| + | out_dir.mkdir(parents=True, exist_ok=True) |
| + | count = 0 |
| + | for conf in sorted(in_dir.glob("*.conf")): |
| + | name = args.name or conf.stem |
| + | out_path = out_dir / f"{conf.stem}.mobileconfig" |
| + | out_path.write_text( |
| + | build_mobileconfig(conf, name=name, org=args.org, |
| + | reverse_dns=args.reverse_dns) |
| + | ) |
| + | print(f"wrote {out_path}") |
| + | count += 1 |
| + | if count == 0: |
| + | print(f"WARN: no .conf files in {in_dir}", file=sys.stderr) |
| + | return 0 |
| + | |
| + | in_path = Path(args.input) |
| + | out_path = Path(args.output) |
| + | if not in_path.is_file(): |
| + | print(f"ERR: {in_path} is not a file", file=sys.stderr) |
| + | return 2 |
| + | name = args.name or in_path.stem |
| + | out_path.write_text( |
| + | build_mobileconfig(in_path, name=name, org=args.org, |
| + | reverse_dns=args.reverse_dns) |
| + | ) |
| + | print(f"wrote {out_path}") |
| + | return 0 |
| + | |
| + | |
| + | if __name__ == "__main__": |
| + | raise SystemExit(main()) |