| 1 | """Generate iOS .mobileconfig profiles from WireGuard .conf files with a true |
| 2 | kill switch (IncludeAllNetworks=true) and OnDemand auto-connect baked in. |
| 3 | |
| 4 | The WireGuard iOS app's UI does not expose "Include All Networks" or true |
| 5 | kill-switch settings. The only way to enforce kill-switch behavior on iOS |
| 6 | (traffic blocked when the tunnel is down) is via an installable Apple |
| 7 | configuration profile. Once installed, it appears in the WireGuard app as a |
| 8 | managed tunnel and the kill switch is enforced at the iOS Network Extension |
| 9 | level. |
| 10 | |
| 11 | Usage: |
| 12 | gen_mobileconfig.py <input.conf> <output.mobileconfig> [--name LABEL] [--org ORG] |
| 13 | |
| 14 | Or, batch mode: |
| 15 | gen_mobileconfig.py --batch <input-dir> <output-dir> |
| 16 | Reads every *.conf in <input-dir> and writes a matching .mobileconfig |
| 17 | to <output-dir>. Profile names are derived from the .conf filename. |
| 18 | """ |
| 19 | from __future__ import annotations |
| 20 | import argparse |
| 21 | import html |
| 22 | import sys |
| 23 | import uuid |
| 24 | from pathlib import Path |
| 25 | |
| 26 | |
| 27 | def parse_conf(path: Path) -> dict: |
| 28 | raw = path.read_text().strip() |
| 29 | cfg = {"raw": raw, "endpoint_host": "", "endpoint_port": "51820"} |
| 30 | for line in raw.splitlines(): |
| 31 | line = line.strip() |
| 32 | if line.startswith("Endpoint"): |
| 33 | ep = line.split("=", 1)[1].strip() |
| 34 | host, _, port = ep.rpartition(":") |
| 35 | cfg["endpoint_host"] = host or ep |
| 36 | if port: |
| 37 | cfg["endpoint_port"] = port |
| 38 | return cfg |
| 39 | |
| 40 | |
| 41 | def build_mobileconfig(conf_path: Path, name: str, org: str, |
| 42 | reverse_dns: str = "vpn.killswitch") -> str: |
| 43 | cfg = parse_conf(conf_path) |
| 44 | wg_quick_xml = html.escape(cfg["raw"]) |
| 45 | tunnel_uuid = str(uuid.uuid4()).upper() |
| 46 | profile_uuid = str(uuid.uuid4()).upper() |
| 47 | slug = name.lower().replace(" ", "-") |
| 48 | payload_id_tunnel = f"{reverse_dns}.{slug}.tunnel" |
| 49 | payload_id_profile = f"{reverse_dns}.{slug}.profile" |
| 50 | |
| 51 | return f"""<?xml version="1.0" encoding="UTF-8"?> |
| 52 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| 53 | <plist version="1.0"> |
| 54 | <dict> |
| 55 | <key>PayloadType</key> |
| 56 | <string>Configuration</string> |
| 57 | <key>PayloadVersion</key> |
| 58 | <integer>1</integer> |
| 59 | <key>PayloadIdentifier</key> |
| 60 | <string>{payload_id_profile}</string> |
| 61 | <key>PayloadUUID</key> |
| 62 | <string>{profile_uuid}</string> |
| 63 | <key>PayloadDisplayName</key> |
| 64 | <string>{name}</string> |
| 65 | <key>PayloadDescription</key> |
| 66 | <string>WireGuard tunnel with kill switch and auto-connect enforced.</string> |
| 67 | <key>PayloadOrganization</key> |
| 68 | <string>{org}</string> |
| 69 | <key>PayloadRemovalDisallowed</key> |
| 70 | <false/> |
| 71 | <key>PayloadScope</key> |
| 72 | <string>User</string> |
| 73 | <key>PayloadContent</key> |
| 74 | <array> |
| 75 | <dict> |
| 76 | <key>PayloadType</key> |
| 77 | <string>com.apple.vpn.managed</string> |
| 78 | <key>PayloadVersion</key> |
| 79 | <integer>1</integer> |
| 80 | <key>PayloadIdentifier</key> |
| 81 | <string>{payload_id_tunnel}</string> |
| 82 | <key>PayloadUUID</key> |
| 83 | <string>{tunnel_uuid}</string> |
| 84 | <key>PayloadDisplayName</key> |
| 85 | <string>{name}</string> |
| 86 | <key>PayloadDescription</key> |
| 87 | <string>WireGuard VPN with kill switch.</string> |
| 88 | <key>PayloadOrganization</key> |
| 89 | <string>{org}</string> |
| 90 | <key>UserDefinedName</key> |
| 91 | <string>{name}</string> |
| 92 | <key>VPNType</key> |
| 93 | <string>VPN</string> |
| 94 | <key>VPNSubType</key> |
| 95 | <string>com.wireguard.ios</string> |
| 96 | <key>VPN</key> |
| 97 | <dict> |
| 98 | <key>AuthenticationMethod</key> |
| 99 | <string>Password</string> |
| 100 | <key>RemoteAddress</key> |
| 101 | <string>{cfg['endpoint_host']}</string> |
| 102 | </dict> |
| 103 | <key>VendorConfig</key> |
| 104 | <dict> |
| 105 | <key>WgQuickConfig</key> |
| 106 | <string>{wg_quick_xml}</string> |
| 107 | </dict> |
| 108 | <key>IncludeAllNetworks</key> |
| 109 | <true/> |
| 110 | <key>EnforceRoutes</key> |
| 111 | <true/> |
| 112 | <key>ExcludeLocalNetworks</key> |
| 113 | <false/> |
| 114 | <key>OnDemandEnabled</key> |
| 115 | <integer>1</integer> |
| 116 | <key>OnDemandRules</key> |
| 117 | <array> |
| 118 | <dict> |
| 119 | <key>Action</key> |
| 120 | <string>Connect</string> |
| 121 | </dict> |
| 122 | </array> |
| 123 | </dict> |
| 124 | </array> |
| 125 | </dict> |
| 126 | </plist> |
| 127 | """ |
| 128 | |
| 129 | |
| 130 | def main() -> int: |
| 131 | ap = argparse.ArgumentParser(description="Generate iOS .mobileconfig profiles " |
| 132 | "from WireGuard .conf files (with kill switch).") |
| 133 | ap.add_argument("input", help="path to .conf file (or input dir in --batch mode)") |
| 134 | ap.add_argument("output", help="output .mobileconfig (or output dir in --batch mode)") |
| 135 | ap.add_argument("--name", default=None, help="profile display name (default: input filename stem)") |
| 136 | ap.add_argument("--org", default="WireGuard Kill Switch", help="PayloadOrganization string") |
| 137 | ap.add_argument("--reverse-dns", default="vpn.killswitch", |
| 138 | help="reverse-DNS prefix for PayloadIdentifier") |
| 139 | ap.add_argument("--batch", action="store_true", |
| 140 | help="treat input and output as directories") |
| 141 | args = ap.parse_args() |
| 142 | |
| 143 | if args.batch: |
| 144 | in_dir = Path(args.input) |
| 145 | out_dir = Path(args.output) |
| 146 | if not in_dir.is_dir(): |
| 147 | print(f"ERR: {in_dir} is not a directory", file=sys.stderr) |
| 148 | return 2 |
| 149 | out_dir.mkdir(parents=True, exist_ok=True) |
| 150 | count = 0 |
| 151 | for conf in sorted(in_dir.glob("*.conf")): |
| 152 | name = args.name or conf.stem |
| 153 | out_path = out_dir / f"{conf.stem}.mobileconfig" |
| 154 | out_path.write_text( |
| 155 | build_mobileconfig(conf, name=name, org=args.org, |
| 156 | reverse_dns=args.reverse_dns) |
| 157 | ) |
| 158 | print(f"wrote {out_path}") |
| 159 | count += 1 |
| 160 | if count == 0: |
| 161 | print(f"WARN: no .conf files in {in_dir}", file=sys.stderr) |
| 162 | return 0 |
| 163 | |
| 164 | in_path = Path(args.input) |
| 165 | out_path = Path(args.output) |
| 166 | if not in_path.is_file(): |
| 167 | print(f"ERR: {in_path} is not a file", file=sys.stderr) |
| 168 | return 2 |
| 169 | name = args.name or in_path.stem |
| 170 | out_path.write_text( |
| 171 | build_mobileconfig(in_path, name=name, org=args.org, |
| 172 | reverse_dns=args.reverse_dns) |
| 173 | ) |
| 174 | print(f"wrote {out_path}") |
| 175 | return 0 |
| 176 | |
| 177 | |
| 178 | if __name__ == "__main__": |
| 179 | raise SystemExit(main()) |