Skip to content

Commit 703ccf2

Browse files
democlaude
andcommitted
fix: v0.2.5 — stop residual openconnect on handshake failure + route-delete safety gate
Hotfix for v0.2.3 / v0.2.4: a failed openconnect handshake could leave a zombie child behind with vpnc-script's DNS and route mutations applied but never reverted, stranding the entire machine's networking until the user quit the app. The connect() failure branch now always SIGTERMs any residual child so vpnc-script's disconnect phase restores DNS/routes. Also: the pre-connect host-route flush now only deletes when `route get` returns a concrete IPv4 destination. Previously, if no dedicated host route existed, the fall-through destination "default" was passed to `route delete`, wiping the system default route. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 40f43d8 commit 703ccf2

8 files changed

Lines changed: 72 additions & 15 deletions

File tree

VPNMenuBar-0.2.5.zip

1.7 MB
Binary file not shown.

VPNMenuBar.app/Contents/Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,13 @@
2323
<key>CFBundlePackageType</key>
2424
<string>APPL</string>
2525
<key>CFBundleShortVersionString</key>
26-
<string>0.2.4</string>
26+
<string>0.2.5</string>
2727
<key>CFBundleSupportedPlatforms</key>
2828
<array>
2929
<string>MacOSX</string>
3030
</array>
3131
<key>CFBundleVersion</key>
32-
<string>6</string>
32+
<string>7</string>
3333
<key>DTCompiler</key>
3434
<string>com.apple.compilers.llvm.clang.1_0</string>
3535
<key>DTPlatformBuild</key>
4.13 KB
Binary file not shown.

VPNMenuBar/Core/OpenConnectProcess.swift

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -212,14 +212,18 @@ final class OpenConnectProcess: OpenConnectProcessRunning {
212212

213213
// MARK: - pre-connect host-route cleanup
214214

215-
/// Unconditionally delete any cached host route to the VPN gateway before
216-
/// spawning openconnect. Called at the top of `start()` — at that point no
217-
/// active tunnel exists, so deleting is always safe: if the route was
218-
/// correct the kernel rebuilds it on the next packet via the default
219-
/// gateway; if it was stale (left over from a different network after a
220-
/// SIGKILL/WiFi-switch without disconnect-phase), this is what unblocks
221-
/// the connect. Best-effort — any failure is logged and openconnect
222-
/// proceeds to surface its native error.
215+
/// Delete the cached host route to the VPN gateway before spawning
216+
/// openconnect — ONLY when a dedicated host route actually exists. If no
217+
/// such route is present `/sbin/route get` falls through to the default
218+
/// route and reports `destination: default`; in that case we MUST NOT
219+
/// run a delete (that would wipe the system default route and kill all
220+
/// networking). A dedicated host route always has a concrete IPv4 as its
221+
/// destination, so we gate the delete on that.
222+
///
223+
/// Called at the top of `start()` — no active tunnel exists at that
224+
/// point, so deleting a concrete host route is safe: a correct one is
225+
/// rebuilt by the kernel on the next packet; a stale one is what we want
226+
/// gone. Best-effort — failures are logged and openconnect proceeds.
223227
private func cleanupStaleHostRoute(forGateway gateway: String) {
224228
let host = Self.extractHost(from: gateway)
225229
guard !host.isEmpty else { return }
@@ -234,8 +238,17 @@ final class OpenConnectProcess: OpenConnectProcessRunning {
234238
let destinationIP = Self.parseRouteField("destination", from: hostRoute.stdout) else {
235239
return
236240
}
237-
let currentGateway = Self.parseRouteField("gateway", from: hostRoute.stdout) ?? "?"
238241

242+
// Critical safety guard: only delete when destination is a concrete
243+
// IPv4 address. If it's "default" (or any other non-IP literal) we
244+
// hit fall-through to the default route and deleting would kill the
245+
// box's entire network.
246+
guard Self.isConcreteIPv4(destinationIP) else {
247+
AppLogger.shared.info("pre-connect: no dedicated host route for \(host) (got destination=\(destinationIP)) — skipping flush")
248+
return
249+
}
250+
251+
let currentGateway = Self.parseRouteField("gateway", from: hostRoute.stdout) ?? "?"
239252
AppLogger.shared.info("pre-connect: flushing cached route to \(destinationIP) (was via \(currentGateway))")
240253

241254
let result = (try? processRunner.run(
@@ -248,6 +261,18 @@ final class OpenConnectProcess: OpenConnectProcessRunning {
248261
}
249262
}
250263

264+
/// Strict dotted-quad IPv4 check (0-255 per octet). Used as a safety gate
265+
/// so `route get` fall-through sentinels like "default" never reach the
266+
/// delete path.
267+
private static func isConcreteIPv4(_ s: String) -> Bool {
268+
let parts = s.split(separator: ".")
269+
guard parts.count == 4 else { return false }
270+
return parts.allSatisfy { part in
271+
guard let n = Int(part), n >= 0, n <= 255 else { return false }
272+
return true
273+
}
274+
}
275+
251276
/// Strip optional `https://` scheme and any `:port` / `/path` suffix
252277
/// from a gateway string, leaving just the host.
253278
private static func extractHost(from gateway: String) -> String {

VPNMenuBar/Core/VPNController.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,18 @@ final class VPNController: ObservableObject {
142142
startMonitoring()
143143
case .failed(let reason):
144144
AppLogger.shared.error("handshake failed: \(reason)")
145+
// CRITICAL: on handshake failure openconnect may have left a
146+
// zombie child that already ran vpnc-script's connect phase
147+
// (DNS and route table mutated) without ever reaching the
148+
// disconnect phase. Without a SIGTERM here those mutations
149+
// strand the whole machine's networking until the user quits
150+
// the app. Send the kill now so vpnc-script's disconnect phase
151+
// restores DNS and routes.
152+
let proc = openConnectProcess
153+
_ = await Task.detached(priority: .userInitiated) {
154+
try? proc.stop()
155+
}.value
156+
AppLogger.shared.info("post-failure cleanup: sent SIGTERM to any residual openconnect")
145157
state = .failed(reason: reason)
146158
// Don't auto-reconnect on failed initial connects (wrong creds, missing deps).
147159
}

VPNMenuBar/Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
<key>CFBundlePackageType</key>
1818
<string>APPL</string>
1919
<key>CFBundleShortVersionString</key>
20-
<string>0.2.4</string>
20+
<string>0.2.5</string>
2121
<key>CFBundleVersion</key>
22-
<string>6</string>
22+
<string>7</string>
2323
<key>LSMinimumSystemVersion</key>
2424
<string>13.0</string>
2525
<key>LSUIElement</key>

appcast.xml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,26 @@
55
<link>https://github.com/CoderZCC/VPNMenuBar</link>
66
<description>VPN MenuBar update feed</description>
77
<language>en</language>
8+
<item>
9+
<title>Version 0.2.5</title>
10+
<sparkle:version>7</sparkle:version>
11+
<sparkle:shortVersionString>0.2.5</sparkle:shortVersionString>
12+
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
13+
<description><![CDATA[
14+
<p><strong>Hotfix — strongly recommended for v0.2.3 / v0.2.4 users.</strong></p>
15+
<ul>
16+
<li>Fixed a case where a failed openconnect handshake could leave a zombie child behind, with vpnc-script's DNS and route mutations applied but never reverted — symptom: "WiFi connected but nothing has network access" until the user quit the app. connect() now always SIGTERMs any residual child on failure so vpnc-script's disconnect phase runs and restores DNS/routes.</li>
17+
<li>Pre-connect route flush now only deletes when the kernel returns a concrete IPv4 destination. Previously, when no dedicated host route existed, `route get` fell through to the default route (destination="default") and the subsequent delete wiped the system default route.</li>
18+
</ul>
19+
]]></description>
20+
<pubDate>Fri, 24 Apr 2026 17:25:00 +0800</pubDate>
21+
<enclosure
22+
url="https://github.com/CoderZCC/VPNMenuBar/releases/download/v0.2.5/VPNMenuBar-0.2.5.zip"
23+
type="application/octet-stream"
24+
sparkle:edSignature="TT2i8OAe43EVJVf4bO6bf/gftOEKEXW/DG9HmPRoU95TZaYxU1A+7Q8XW+woboWJaRCCAQwLSK+dYHnFXSRADQ=="
25+
length="1784270"
26+
/>
27+
</item>
828
<item>
929
<title>Version 0.2.4</title>
1030
<sparkle:version>6</sparkle:version>

project.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ targets:
3636
properties:
3737
CFBundleName: VPNMenuBar
3838
CFBundleDisplayName: VPN MenuBar
39-
CFBundleShortVersionString: "0.2.4"
40-
CFBundleVersion: "6"
39+
CFBundleShortVersionString: "0.2.5"
40+
CFBundleVersion: "7"
4141
LSMinimumSystemVersion: "13.0"
4242
LSUIElement: true
4343
NSUserNotificationAlertStyle: alert

0 commit comments

Comments
 (0)