Zion Boggan zionboggan.com ↗

initial commit

b87c4f9   Zion Boggan committed on May 4, 2026 (1 month ago)
LICENSE +21 -0
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
README.md +79 -0
@@ -0,0 +1,79 @@
+# mullvad-ios-killswitch
+
+Generate iOS `.mobileconfig` configuration profiles from WireGuard `.conf` files
+with a **true kill switch** and OnDemand auto-connect baked in.
+
+## Why this exists
+
+The WireGuard iOS app's UI does not expose:
+
+- `IncludeAllNetworks` (the toggle that makes the tunnel claim the entire
+ 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 (the toggle that makes
+ iOS auto-establish the tunnel any time the device has a network).
+
+Both are gated behind Apple's MDM / configuration-profile path. Once you
+install a `.mobileconfig` that turns those keys on, the WireGuard app picks up
+the profile as a managed tunnel and the kill switch is enforced by iOS, not
+by the app. If the tunnel drops, the device does not fall back to the bare
+LTE / Wi-Fi route - traffic stops until the tunnel comes back.
+
+This script takes a regular WireGuard `.conf` you already have (downloaded
+from Mullvad, your own server, anywhere) and wraps it in a properly signed-
+shaped `.mobileconfig` with those keys flipped on.
+
+## Install
+
+No dependencies beyond Python 3.8+. Drop the script anywhere on your PATH.
+
+## Usage
+
+Single config:
+
+```bash
+python gen_mobileconfig.py \
+ my-tunnel.conf my-tunnel.mobileconfig \
+ --name "Mullvad Atlanta (iPhone)" \
+ --org "Personal Setup"
+```
+
+Batch a folder of configs (one `.mobileconfig` per `.conf`):
+
+```bash
+python gen_mobileconfig.py --batch ./wg-configs ./mobileconfigs
+```
+
+AirDrop or email the resulting `.mobileconfig` to the iOS device. Open the
+file from the Files / Mail / Messages app, accept the profile prompt in
+Settings, then open the WireGuard app once and confirm the tunnel is
+present and toggled on.
+
+## Verification
+
+After installing on iOS:
+
+1. Open WireGuard, confirm the tunnel is on.
+2. Settings -> General -> VPN & Device Management -> tap the profile,
+ confirm "Connect On Demand" is on.
+3. Toggle WireGuard off for a moment in the app. Try to load any page.
+ It should fail. Toggle back on, traffic resumes.
+
+If step 3 still loads pages while the tunnel is off, the kill switch is not
+active and the profile did not install correctly.
+
+## What the script does NOT do
+
+- Generate your WireGuard keys. Bring your own `.conf`.
+- Sign the `.mobileconfig` with an Apple developer certificate. iOS will
+ warn that the profile is unsigned. That is fine for personal use - you
+ install it manually. For a fleet deployment you should sign with `openssl
+ smime` or push it through MDM.
+- Validate the upstream `.conf`. If the file does not have an `Endpoint`
+ line, the script writes an empty `RemoteAddress` (iOS will tolerate this
+ because `WgQuickConfig` carries the real endpoint, but it is uglier in
+ the VPN settings).
+
+## License
+
+MIT. See [LICENSE](LICENSE).
examples/sample-wg.conf +9 -0
@@ -0,0 +1,9 @@
+[Interface]
+PrivateKey = REPLACE_WITH_YOUR_WIREGUARD_PRIVATE_KEY
+Address = 10.64.0.2/32,fc00:bbbb:bbbb:bb01::1/128
+DNS = 10.64.0.1
+
+[Peer]
+PublicKey = REPLACE_WITH_THE_SERVER_PUBLIC_KEY
+AllowedIPs = 0.0.0.0/0,::0/0
+Endpoint = vpn.example.com:51820
gen_mobileconfig.py +180 -0
@@ -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())