← THE INDEX  ·  PRODUCT

WireGuard iOS Kill Switch Generator

Close the gap the WireGuard iOS UI leaves open: enforce a true kill switch via an MDM configuration profile.

The problem it solves

The WireGuard iOS app does not expose two critical settings in its UI:

  • IncludeAllNetworks: makes the tunnel claim the full default route at the iOS Network Extension level, so traffic outside the tunnel is blocked when the tunnel is down.
  • OnDemandEnabled with an unconditional Connect rule: makes iOS auto-establish the tunnel any time the device has a network.

Both are only accessible via Apple's MDM / configuration-profile path. Once a .mobileconfig with these keys is installed, the WireGuard app picks up the profile as a managed tunnel and the kill switch is enforced by iOS itself, not by the app. If the tunnel drops, traffic stops.

This script takes a regular WireGuard .conf (from Mullvad, a self-hosted server, or anywhere else) and produces the properly structured .mobileconfig with both keys enabled.

How it works

The script parses the WireGuard .conf to extract the endpoint host and port (needed for the RemoteAddress field in the VPN payload). The entire raw .conf content is HTML-escaped and injected as the WgQuickConfig key in the VendorConfig dict. This is how the WireGuard iOS app reads managed tunnels. Two UUIDs are generated for the tunnel payload and the outer profile, then the full Apple plist XML is written.

The IncludeAllNetworks and EnforceRoutes keys are set to true; ExcludeLocalNetworks is false (local network access blocked while tunnel is up). The OnDemandRules array contains a single unconditional Connect action.

Batch mode (--batch) processes a directory of .conf files and writes one .mobileconfig per file, named to match.

gen_mobileconfig.py: parse, inject, and emit the profile
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"])  # embed raw .conf as XML text
    tunnel_uuid = str(uuid.uuid4()).upper()
    profile_uuid = str(uuid.uuid4()).upper()
    # ... plist XML with:
    #   <key>IncludeAllNetworks</key>  <true/>
    #   <key>EnforceRoutes</key>       <true/>
    #   <key>OnDemandEnabled</key>     <integer>1</integer>
    #   <key>OnDemandRules</key>
    #     <array><dict><key>Action</key><string>Connect</string></dict></array>
    #   <key>WgQuickConfig</key>       <string>{wg_quick_xml}</string>

Limitations and what it does not do

The script does not generate WireGuard keys. Bring your own .conf. It does not sign the .mobileconfig with an Apple developer certificate; iOS will warn the profile is unsigned. For personal use that is fine; install it manually. For a fleet deployment, pipe the output through openssl smime or an MDM system.

Verification after install: open WireGuard, confirm the tunnel is present; check Settings → General → VPN & Device Management for 'Connect On Demand'; toggle the tunnel off and confirm that loading any page fails. If traffic still flows with the tunnel off, the kill switch profile did not install correctly.