Zion Boggan
repos/Mullvad iOS Killswitch/gen_mobileconfig.py
zionboggan.com ↗
179 lines · python
History for this file →
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())